Codesign peardock-server for macOS in CI
Release rolling / release (push) Successful in 8m29s

Gatekeeper reported the Bare server binary as damaged because it only
had a linker ad-hoc stamp after embed. Seal darwin server binaries with
the same sign-macos path used for Electron apps (codesign / rcodesign).
This commit is contained in:
Raven Scott
2026-07-16 06:30:43 -04:00
parent 658bc1ee59
commit cb93897d5d
10 changed files with 184 additions and 33 deletions
+11
View File
@@ -202,6 +202,15 @@ async function buildOne(host, outRoot) {
binary = flat
}
console.log(`[bare-standalone] wrote ${binary}`)
// Re-seal darwin binaries so Gatekeeper does not report "damaged".
// bare-runtime ships linker ad-hoc stamps; embedding invalidates them.
// Linux CI uses rcodesign (same path as Electron postPackage).
if (host.startsWith('darwin') && process.env.PEARDOCK_SKIP_MACOS_SIGN !== '1') {
console.log(`[bare-standalone] codesigning ${binary} for ${host}`)
const { signBinary } = require('./sign-macos-app.cjs')
await signBinary(binary)
}
} else {
console.warn(`[bare-standalone] WARN: expected binary not found under ${outDir}`)
console.warn(
@@ -221,6 +230,8 @@ async function buildOne(host, outRoot) {
builtAt: new Date().toISOString(),
entry: 'bin/peardock-server.mjs',
bundleId: entry.id,
macosSigned:
host.startsWith('darwin') && process.env.PEARDOCK_SKIP_MACOS_SIGN !== '1',
},
null,
2
+4 -1
View File
@@ -173,7 +173,10 @@ open peardock-darwin-arm64/peardock.app # macOS
# Windows: peardock-win32-x64\\\\peardock-client.exe
\`\`\`
macOS builds from Linux CI are **unsigned** (Gatekeeper may require right-click → Open).
macOS **client** (`.app`) and **server** (`peardock-server`) binaries are codesigned in CI
(ad-hoc / self-signed via \`rcodesign\` on Linux, or \`codesign\` on macOS). That avoids the
Gatekeeper "**damaged** and can't be opened" false positive. First open may still need
right-click → Open (not notarized unless \`MAC_CODESIGN_IDENTITY\` + Apple Developer ID).
## Checksums
+149 -18
View File
@@ -1,23 +1,28 @@
#!/usr/bin/env node
/**
* Deep-sign a peardock .app so macOS Gatekeeper does not report
* Codesign peardock macOS artifacts so Gatekeeper does not report
* "is damaged and can't be opened. You should move it to the Trash."
*
* That message almost always means an *invalid* signature (Electron binary
* still has a partial linker-signed ad-hoc stamp after packager rewrites the
* bundle, with no sealed resources).
* Targets:
* - Electron .app bundles (deep sign / nested helpers)
* - Standalone peardock-server Mach-O binaries (Bare)
*
* That message almost always means an *invalid* signature (linker ad-hoc
* stamp after packager/embed rewrites the binary, or unsigned download).
*
* Usage:
* node scripts/sign-macos-app.cjs path/to/peardock.app
* node scripts/sign-macos-app.cjs path/to/out/peardock-darwin-arm64
* node scripts/sign-macos-app.cjs path/to/out/peardock-server-darwin-arm64
* node scripts/sign-macos-app.cjs path/to/peardock-server
*
* Identity (first match wins):
* MAC_CODESIGN_IDENTITY / CSC_NAME — "Developer ID Application: …" or team identity
* otherwise ad-hoc (`-`) which is enough to make the app *valid* (not "damaged")
* otherwise ad-hoc (`-`) which is enough to make the artifact *valid* (not "damaged")
*
* Tools:
* macOS: /usr/bin/codesign (required for production identities)
* Linux CI: rcodesign (apple-codesign) for ad-hoc when present
* Linux CI: rcodesign (apple-codesign) for self-signed seal when present
*/
'use strict'
@@ -26,6 +31,7 @@ const path = require('path')
const { spawnSync, execFileSync } = require('child_process')
const ENTITLEMENTS = path.join(__dirname, 'entitlements.mac.plist')
const SERVER_BIN_NAMES = new Set(['peardock-server', 'peardock-server.exe'])
function log(...a) {
console.log('[sign-macos]', ...a)
@@ -44,6 +50,50 @@ function findApps(input) {
return []
}
/**
* Find standalone peardock-server Mach-O binaries under a path.
* @param {string} input
* @returns {string[]}
*/
function findServerBinaries(input) {
if (!fs.existsSync(input)) return []
const st = fs.statSync(input)
if (st.isFile()) {
const base = path.basename(input)
if (SERVER_BIN_NAMES.has(base) || base === 'peardock-server') return [input]
// Allow explicit path to any file named like our server binary
if (!base.endsWith('.app') && !base.endsWith('.dmg') && !base.endsWith('.pkg')) {
// Heuristic: treat as binary if caller passed a file path that looks executable
if (base.includes('peardock-server')) return [input]
}
return []
}
if (!st.isDirectory()) return []
const found = []
const stack = [input]
while (stack.length) {
const dir = stack.pop()
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const ent of entries) {
const p = path.join(dir, ent.name)
if (ent.isDirectory()) {
if (ent.name === 'node_modules' || ent.name.endsWith('.app') || ent.name.startsWith('.')) {
continue
}
stack.push(p)
} else if (ent.isFile() && SERVER_BIN_NAMES.has(ent.name)) {
found.push(p)
}
}
}
return found
}
function which(cmd) {
try {
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
@@ -281,12 +331,16 @@ function ensureRcodesignSelfSignedP12(bin) {
return { p12Path, password }
}
function codesignRcodesign(appPath, id) {
/**
* Sign a path (`.app` or standalone Mach-O) with rcodesign on Linux CI.
* Uses a self-signed p12 (not Developer ID) so the signature is *valid/sealed*.
*/
function codesignRcodesign(targetPath, id) {
const bin = which('rcodesign')
if (!bin) {
throw new Error(
'rcodesign not found (needed to sign macOS .app on Linux). ' +
'CI installs tools/rcodesign/<host>/rcodesign, or package darwin clients on macOS.'
'rcodesign not found (needed to sign macOS artifacts on Linux). ' +
'CI installs tools/rcodesign/<host>/rcodesign, or build/sign on macOS.'
)
}
if (id !== '-') {
@@ -299,7 +353,7 @@ function codesignRcodesign(appPath, id) {
const entitlements = ensureEntitlements()
const { p12Path, password } = ensureRcodesignSelfSignedP12(bin)
// Prefer full Electron-friendly sign: p12 + runtime + entitlements
// Prefer full Electron/Bare-friendly sign: p12 + runtime + entitlements
const attempts = [
[
'sign',
@@ -311,7 +365,7 @@ function codesignRcodesign(appPath, id) {
'runtime',
'--entitlements-xml-file',
entitlements,
appPath,
targetPath,
],
[
'sign',
@@ -321,9 +375,9 @@ function codesignRcodesign(appPath, id) {
password,
'--code-signature-flags',
'runtime',
appPath,
targetPath,
],
['sign', '--p12-file', p12Path, '--p12-password', password, appPath],
['sign', '--p12-file', p12Path, '--p12-password', password, targetPath],
]
let lastErr = ''
@@ -343,6 +397,68 @@ function codesignRcodesign(appPath, id) {
throw new Error(`rcodesign failed:\n${lastErr}`)
}
/**
* Sign a standalone Mach-O binary (peardock-server) with Apple codesign.
*/
function codesignDarwinBinary(binPath, id) {
const entitlements = ensureEntitlements()
const hardened = id !== '-'
try {
execFileSync('xattr', ['-cr', binPath], { stdio: 'pipe' })
} catch {
// ignore
}
log(`codesign binary identity=${id === '-' ? 'ad-hoc' : id}`)
const args = ['--force', '--sign', id, '--entitlements', entitlements]
if (hardened) args.push('--options', 'runtime', '--timestamp')
else args.push('--timestamp=none')
// Stable identifier (bare-runtime defaults to peardock-server-<hash>)
args.push('--identifier', 'com.peardock.server', binPath)
const r = spawnSync('codesign', args, { encoding: 'utf8' })
if (r.status !== 0) {
throw new Error(`codesign failed for binary:\n${r.stderr || r.stdout}`)
}
const v = spawnSync('codesign', ['--verify', '--strict', '--verbose=2', binPath], {
encoding: 'utf8',
})
if (v.status !== 0) {
throw new Error(`codesign verify failed for binary:\n${v.stderr || v.stdout}`)
}
log('verify ok:', (v.stderr || v.stdout || '').trim().split('\n').slice(0, 3).join(' | '))
}
/**
* Codesign a standalone peardock-server binary (Bare).
* Call after bare-standalone embeds the runtime for each darwin host.
*
* @param {string} binPath
* @returns {Promise<void>}
*/
function signBinary(binPath) {
if (!fs.existsSync(binPath)) throw new Error(`Binary not found: ${binPath}`)
const id = identity()
log('binary:', binPath)
log('identity:', id === '-' ? 'ad-hoc (-)' : id)
if (process.platform === 'darwin') {
codesignDarwinBinary(binPath, id)
try {
execFileSync('xattr', ['-cr', binPath], { stdio: 'pipe' })
} catch {
// ignore
}
return Promise.resolve()
}
// Linux CI cross-compile of darwin server binaries
codesignRcodesign(binPath, id)
return Promise.resolve()
}
function signApp(appPath) {
if (!fs.existsSync(appPath)) throw new Error(`App not found: ${appPath}`)
const id = identity()
@@ -412,22 +528,37 @@ function signApp(appPath) {
async function main() {
const input = process.argv[2]
if (!input) {
console.error('Usage: node scripts/sign-macos-app.cjs <path-to.app|dir>')
console.error(
'Usage: node scripts/sign-macos-app.cjs <path-to.app|peardock-server|dir>'
)
process.exit(2)
}
const resolved = path.resolve(input)
const apps = findApps(resolved)
if (!apps.length) {
console.error('No .app found at', resolved)
if (!fs.existsSync(resolved)) {
console.error('Path not found:', resolved)
process.exit(1)
}
const apps = findApps(resolved)
const bins = findServerBinaries(resolved)
if (!apps.length && !bins.length) {
console.error('No .app or peardock-server binary found at', resolved)
process.exit(1)
}
for (const app of apps) {
await signApp(app)
}
for (const bin of bins) {
// Avoid double-signing if someone nested a binary inside an .app (unlikely)
if (apps.some((a) => bin === a || bin.startsWith(a + path.sep))) continue
await signBinary(bin)
}
log('done')
}
module.exports = { signApp, findApps, identity }
module.exports = { signApp, signBinary, findApps, findServerBinaries, identity }
if (require.main === module) {
main().catch((err) => {