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
+2 -2
View File
@@ -122,9 +122,9 @@ jobs:
echo "==> npm ci done (electron binary deferred to forge package step)" echo "==> npm ci done (electron binary deferred to forge package step)"
node -e "console.log('node', process.version); console.log('esbuild', require('esbuild').version)" node -e "console.log('node', process.version); console.log('esbuild', require('esbuild').version)"
# Ad-hoc codesign for darwin .app bundles produced on Linux (fixes Gatekeeper "damaged"). # Codesign for darwin .app + peardock-server binaries on Linux (fixes Gatekeeper "damaged").
# Binary is vendored under tools/rcodesign/ (no GitHub download — runners often stall there). # Binary is vendored under tools/rcodesign/ (no GitHub download — runners often stall there).
- name: Install rcodesign (macOS ad-hoc signing on Linux) - name: Install rcodesign (macOS codesign on Linux)
timeout-minutes: 2 timeout-minutes: 2
run: | run: |
set -euo pipefail set -euo pipefail
+2 -2
View File
@@ -227,7 +227,7 @@ Asset patterns:
- `peardock-server-VERSION-HOST.tar.gz` (+ `.sha256`) - `peardock-server-VERSION-HOST.tar.gz` (+ `.sha256`)
- `peardock-client-VERSION-HOST.tar.gz` (+ `.sha256`; macOS includes a `.app`) - `peardock-client-VERSION-HOST.tar.gz` (+ `.sha256`; macOS includes a `.app`)
macOS clients from Linux CI are ad-hoc signed. If Gatekeeper blocks: right-click → Open, or `xattr -cr ~/Applications/peardock.app`. Re-sign: `npm run sign:macos -- out/peardock-darwin-arm64/peardock.app`. macOS client (`.app`) and server (`peardock-server`) binaries from CI are codesigned (avoids Gatekeeper “damaged”). If Gatekeeper still blocks: right-click → Open, or `xattr -cr <path>`. Re-sign: `npm run sign:macos -- out/peardock-darwin-arm64/peardock.app` or `…/peardock-server-darwin-arm64/peardock-server`.
--- ---
@@ -243,7 +243,7 @@ macOS clients from Linux CI are ad-hoc signed. If Gatekeeper blocks: right-click
| `npm run soak` / `soak:24h` | Certification soak against Docker | | `npm run soak` / `soak:24h` | Certification soak against Docker |
| `npm run make` | Cross-build all server + client hosts | | `npm run make` | Cross-build all server + client hosts |
| `npm run make:server` / `make:client` | Server-only or client-only builds | | `npm run make:server` / `make:client` | Server-only or client-only builds |
| `npm run sign:macos` | Ad-hoc / identity re-sign for `.app` | | `npm run sign:macos` | Ad-hoc / identity re-sign for `.app` or `peardock-server` |
| `npm run release:checksums` | Tarball + SHA-256 (+ optional GPG) | | `npm run release:checksums` | Tarball + SHA-256 (+ optional GPG) |
| `npm run release:rolling` | Local dry-run helper for rolling publish | | `npm run release:rolling` | Local dry-run helper for rolling publish |
+1 -1
View File
@@ -343,7 +343,7 @@ v1 used Hyperswarm topics + ad-hoc JSON streams and is **not** wire-compatible.
| Rate limit errors | Middleware | `server/utils/rateLimiter.js` | | Rate limit errors | Middleware | `server/utils/rateLimiter.js` |
| Tunnel create fails | Holesail flag / SSRF | `ENABLE_HOLESAIL`; `PEARDOCK_TUNNEL_HOSTS` | | Tunnel create fails | Holesail flag / SSRF | `ENABLE_HOLESAIL`; `PEARDOCK_TUNNEL_HOSTS` |
| Swarm empty / error | Swarm mode / flag | Engine swarm state; `ENABLE_SWARM` | | Swarm empty / error | Swarm mode / flag | Engine swarm state; `ENABLE_SWARM` |
| macOS “damaged” app | Gatekeeper | `xattr -cr`; ad-hoc sign docs | | macOS “damaged” app/binary | Gatekeeper | codesign in CI (`sign-macos-app.cjs`); `xattr -cr` / right-click Open |
--- ---
+1 -1
View File
@@ -263,7 +263,7 @@ Build: `npm run build:client-bundle` → `scripts/build-client-bundle.cjs`.
| `build-client-bundle.cjs` | esbuild client bundle | | `build-client-bundle.cjs` | esbuild client bundle |
| `gitea-rolling-release.sh` | CI publish to `rolling` | | `gitea-rolling-release.sh` | CI publish to `rolling` |
| `hosts.cjs` | Canonical 64-bit host list | | `hosts.cjs` | Canonical 64-bit host list |
| `sign-macos-app.cjs` | Ad-hoc / identity sign | | `sign-macos-app.cjs` | Ad-hoc / identity sign for `.app` + `peardock-server` |
| `healthcheck.js` | Docker socket probe | | `healthcheck.js` | Docker socket probe |
| `soak.js` | Long-run certification | | `soak.js` | Long-run certification |
| `release-checksums.sh` | Checksums / optional GPG | | `release-checksums.sh` | Checksums / optional GPG |
+1 -1
View File
@@ -213,7 +213,7 @@ Admin-defined recurring maintenance (e.g. prune) persisted and restored at boot.
| 6×64-bit client hosts | Electron forge | | 6×64-bit client hosts | Electron forge |
| rolling tag | Continuous release channel | | rolling tag | Continuous release channel |
| install.sh | Server systemd + client install | | install.sh | Server systemd + client install |
| macOS ad-hoc sign | Gatekeeper-friendly CI builds | | macOS codesign (client + server) | Gatekeeper-friendly CI builds (avoids “damaged”) |
--- ---
+7 -5
View File
@@ -8,8 +8,8 @@ No 32-bit (`ia32` / `armv7`) builds.
|------|---------------|-------------------| |------|---------------|-------------------|
| `linux-x64` | yes (cross) | yes (cross via electron-forge) | | `linux-x64` | yes (cross) | yes (cross via electron-forge) |
| `linux-arm64` | yes | yes | | `linux-arm64` | yes | yes |
| `darwin-x64` | yes | yes (unsigned / ad-hoc from Linux CI) | | `darwin-x64` | yes (codesigned in CI) | yes (codesigned in CI) |
| `darwin-arm64` | yes | yes (unsigned / ad-hoc from Linux CI) | | `darwin-arm64` | yes (codesigned in CI) | yes (codesigned in CI) |
| `win32-x64` | yes | yes | | `win32-x64` | yes | yes |
| `win32-arm64` | yes | yes | | `win32-arm64` | yes | yes |
@@ -96,9 +96,11 @@ Published assets are consumed by:
### Notes ### Notes
- **macOS clients** are **ad-hoc codesigned** after package (`scripts/sign-macos-app.cjs`) so Gatekeeper does not show “damaged / move to Trash”. CI installs `rcodesign` to ad-hoc sign darwin `.app`s built on Linux. For Developer ID + notarization, set `MAC_CODESIGN_IDENTITY` / `CSC_NAME` on a Mac (or import certs in CI). - **macOS clients** (`.app`) and **peardock-server** binaries are **codesigned** after build (`scripts/sign-macos-app.cjs`) so Gatekeeper does not show “damaged / move to Trash”. Server signing runs inside `bare-standalone.cjs` for every `darwin-*` host; client signing runs in Electron Forge `postPackage`. CI installs vendored `rcodesign` to seal darwin artifacts built on Linux. For Developer ID + notarization, set `MAC_CODESIGN_IDENTITY` / `CSC_NAME` on a Mac (or import certs in CI).
- Re-sign a local/downloaded build: `npm run sign:macos -- out/peardock-darwin-arm64/peardock.app` - Re-sign a local/downloaded build:
- If the app is still blocked: `xattr -cr ~/Applications/peardock.app` or right-click → Open. - Client: `npm run sign:macos -- out/peardock-darwin-arm64/peardock.app`
- Server: `npm run sign:macos -- out/peardock-server-darwin-arm64/peardock-server`
- If still blocked: `xattr -cr <path>` or right-click → Open. Skip signing with `PEARDOCK_SKIP_MACOS_SIGN=1` (not recommended for release).
- **Native modules** must ship prebuilds for each target (Holepunch stack does). Rebuild-from-source is disabled for cross packages (`npm_config_build_from_source=false`). - **Native modules** must ship prebuilds for each target (Holepunch stack does). Rebuild-from-source is disabled for cross packages (`npm_config_build_from_source=false`).
- **Server** cross-compile uses bare-runtime platform prebuilds (no Docker-in-Docker required to *build*; runtime still needs a Docker socket). - **Server** cross-compile uses bare-runtime platform prebuilds (no Docker-in-Docker required to *build*; runtime still needs a Docker socket).
+11
View File
@@ -202,6 +202,15 @@ async function buildOne(host, outRoot) {
binary = flat binary = flat
} }
console.log(`[bare-standalone] wrote ${binary}`) 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 { } else {
console.warn(`[bare-standalone] WARN: expected binary not found under ${outDir}`) console.warn(`[bare-standalone] WARN: expected binary not found under ${outDir}`)
console.warn( console.warn(
@@ -221,6 +230,8 @@ async function buildOne(host, outRoot) {
builtAt: new Date().toISOString(), builtAt: new Date().toISOString(),
entry: 'bin/peardock-server.mjs', entry: 'bin/peardock-server.mjs',
bundleId: entry.id, bundleId: entry.id,
macosSigned:
host.startsWith('darwin') && process.env.PEARDOCK_SKIP_MACOS_SIGN !== '1',
}, },
null, null,
2 2
+4 -1
View File
@@ -173,7 +173,10 @@ open peardock-darwin-arm64/peardock.app # macOS
# Windows: peardock-win32-x64\\\\peardock-client.exe # 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 ## Checksums
+149 -18
View File
@@ -1,23 +1,28 @@
#!/usr/bin/env node #!/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." * "is damaged and can't be opened. You should move it to the Trash."
* *
* That message almost always means an *invalid* signature (Electron binary * Targets:
* still has a partial linker-signed ad-hoc stamp after packager rewrites the * - Electron .app bundles (deep sign / nested helpers)
* bundle, with no sealed resources). * - 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: * Usage:
* node scripts/sign-macos-app.cjs path/to/peardock.app * 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-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): * Identity (first match wins):
* MAC_CODESIGN_IDENTITY / CSC_NAME "Developer ID Application: …" or team identity * 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: * Tools:
* macOS: /usr/bin/codesign (required for production identities) * 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' 'use strict'
@@ -26,6 +31,7 @@ const path = require('path')
const { spawnSync, execFileSync } = require('child_process') const { spawnSync, execFileSync } = require('child_process')
const ENTITLEMENTS = path.join(__dirname, 'entitlements.mac.plist') const ENTITLEMENTS = path.join(__dirname, 'entitlements.mac.plist')
const SERVER_BIN_NAMES = new Set(['peardock-server', 'peardock-server.exe'])
function log(...a) { function log(...a) {
console.log('[sign-macos]', ...a) console.log('[sign-macos]', ...a)
@@ -44,6 +50,50 @@ function findApps(input) {
return [] 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) { function which(cmd) {
try { try {
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
@@ -281,12 +331,16 @@ function ensureRcodesignSelfSignedP12(bin) {
return { p12Path, password } 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') const bin = which('rcodesign')
if (!bin) { if (!bin) {
throw new Error( throw new Error(
'rcodesign not found (needed to sign macOS .app on Linux). ' + 'rcodesign not found (needed to sign macOS artifacts on Linux). ' +
'CI installs tools/rcodesign/<host>/rcodesign, or package darwin clients on macOS.' 'CI installs tools/rcodesign/<host>/rcodesign, or build/sign on macOS.'
) )
} }
if (id !== '-') { if (id !== '-') {
@@ -299,7 +353,7 @@ function codesignRcodesign(appPath, id) {
const entitlements = ensureEntitlements() const entitlements = ensureEntitlements()
const { p12Path, password } = ensureRcodesignSelfSignedP12(bin) const { p12Path, password } = ensureRcodesignSelfSignedP12(bin)
// Prefer full Electron-friendly sign: p12 + runtime + entitlements // Prefer full Electron/Bare-friendly sign: p12 + runtime + entitlements
const attempts = [ const attempts = [
[ [
'sign', 'sign',
@@ -311,7 +365,7 @@ function codesignRcodesign(appPath, id) {
'runtime', 'runtime',
'--entitlements-xml-file', '--entitlements-xml-file',
entitlements, entitlements,
appPath, targetPath,
], ],
[ [
'sign', 'sign',
@@ -321,9 +375,9 @@ function codesignRcodesign(appPath, id) {
password, password,
'--code-signature-flags', '--code-signature-flags',
'runtime', 'runtime',
appPath, targetPath,
], ],
['sign', '--p12-file', p12Path, '--p12-password', password, appPath], ['sign', '--p12-file', p12Path, '--p12-password', password, targetPath],
] ]
let lastErr = '' let lastErr = ''
@@ -343,6 +397,68 @@ function codesignRcodesign(appPath, id) {
throw new Error(`rcodesign failed:\n${lastErr}`) 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) { function signApp(appPath) {
if (!fs.existsSync(appPath)) throw new Error(`App not found: ${appPath}`) if (!fs.existsSync(appPath)) throw new Error(`App not found: ${appPath}`)
const id = identity() const id = identity()
@@ -412,22 +528,37 @@ function signApp(appPath) {
async function main() { async function main() {
const input = process.argv[2] const input = process.argv[2]
if (!input) { 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) process.exit(2)
} }
const resolved = path.resolve(input) const resolved = path.resolve(input)
const apps = findApps(resolved) if (!fs.existsSync(resolved)) {
if (!apps.length) { console.error('Path not found:', resolved)
console.error('No .app found at', resolved)
process.exit(1) 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) { for (const app of apps) {
await signApp(app) 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') log('done')
} }
module.exports = { signApp, findApps, identity } module.exports = { signApp, signBinary, findApps, findServerBinaries, identity }
if (require.main === module) { if (require.main === module) {
main().catch((err) => { main().catch((err) => {
+6 -2
View File
@@ -1,7 +1,11 @@
# Vendored `rcodesign` (apple-codesign) # Vendored `rcodesign` (apple-codesign)
Used by CI on Linux to **ad-hoc sign** macOS `.app` bundles produced by Used by CI on Linux to **seal-sign** macOS artifacts produced on Linux:
electron-forge cross-packaging (avoids Gatekeeper “damaged / move to Trash”).
- Electron `.app` bundles (forge `postPackage``scripts/sign-macos-app.cjs`)
- Bare `peardock-server` binaries (`scripts/bare-standalone.cjs` for every `darwin-*` host)
so Gatekeeper does not report them as “damaged / move to Trash”.
| Path | Binary | | Path | Binary |
|------|--------| |------|--------|