This commit is contained in:
@@ -108,6 +108,23 @@ jobs:
|
|||||||
# Electron + bare-runtime multi-arch prebuilds typically take several minutes.
|
# Electron + bare-runtime multi-arch prebuilds typically take several minutes.
|
||||||
npm ci --no-audit --no-fund --no-progress --loglevel=warn
|
npm ci --no-audit --no-fund --no-progress --loglevel=warn
|
||||||
|
|
||||||
|
# Ad-hoc codesign for darwin .app bundles produced on Linux (fixes Gatekeeper "damaged")
|
||||||
|
- name: Install rcodesign (macOS ad-hoc signing on Linux)
|
||||||
|
timeout-minutes: 5
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VER=0.29.0
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64) TARGET=x86_64-unknown-linux-musl ;;
|
||||||
|
aarch64|arm64) TARGET=aarch64-unknown-linux-musl ;;
|
||||||
|
*) echo "unsupported arch $ARCH for rcodesign"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
URL="https://github.com/indygreg/apple-codesign/releases/download/${VER}/apple-codesign-${VER}-${TARGET}.tar.gz"
|
||||||
|
curl -fsSL --retry 3 --retry-delay 2 -o /tmp/rcodesign.tgz "$URL"
|
||||||
|
sudo tar -xzf /tmp/rcodesign.tgz -C /usr/local/bin --strip-components=1 "apple-codesign-${VER}-${TARGET}/rcodesign"
|
||||||
|
rcodesign --version
|
||||||
|
|
||||||
- name: Build all hosts + publish rolling release
|
- name: Build all hosts + publish rolling release
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|||||||
+2
-1
@@ -90,7 +90,8 @@ DRY_RUN=1 bash scripts/gitea-rolling-release.sh
|
|||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- **macOS clients from Linux** are **unsigned**. Users may need right-click → Open the first time.
|
- **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).
|
||||||
|
- Re-sign a local/downloaded build: `npm run sign:macos -- out/peardock-darwin-arm64/peardock.app`
|
||||||
- **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).
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -126,7 +126,9 @@ module.exports = {
|
|||||||
// Symlink deref + prune walk the whole graph and dominate finalize time
|
// Symlink deref + prune walk the whole graph and dominate finalize time
|
||||||
derefSymlinks: false,
|
derefSymlinks: false,
|
||||||
prune: false,
|
prune: false,
|
||||||
// Cross-package from Linux CI: no macOS codesign
|
// Signing is done in postPackage via scripts/sign-macos-app.cjs (ad-hoc or
|
||||||
|
// Developer ID). Leaving packager osxSign off avoids double-sign races;
|
||||||
|
// a partial linker-signed Electron binary is what causes "damaged / Trash".
|
||||||
osxSign: false,
|
osxSign: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -199,5 +201,22 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Always re-sign darwin builds after packager rewrites the Electron .app.
|
||||||
|
* Without this, Gatekeeper reports: "is damaged and can't be opened".
|
||||||
|
*/
|
||||||
|
postPackage: async (_forgeConfig, options) => {
|
||||||
|
const platform = options.platform || process.platform
|
||||||
|
if (platform !== 'darwin') return
|
||||||
|
const { signApp, findApps } = require('./scripts/sign-macos-app.cjs')
|
||||||
|
const paths = options.outputPaths || []
|
||||||
|
for (const outPath of paths) {
|
||||||
|
const apps = findApps(outPath)
|
||||||
|
for (const app of apps) {
|
||||||
|
console.log('[forge] postPackage codesign', app)
|
||||||
|
await signApp(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -540,6 +540,7 @@
|
|||||||
"make:client:darwin-x64": "electron-forge package --platform darwin --arch x64",
|
"make:client:darwin-x64": "electron-forge package --platform darwin --arch x64",
|
||||||
"make:client:win32-x64": "electron-forge package --platform win32 --arch x64",
|
"make:client:win32-x64": "electron-forge package --platform win32 --arch x64",
|
||||||
"make:client:win32-arm64": "electron-forge package --platform win32 --arch arm64",
|
"make:client:win32-arm64": "electron-forge package --platform win32 --arch arm64",
|
||||||
|
"sign:macos": "node scripts/sign-macos-app.cjs",
|
||||||
"release:rolling": "bash scripts/gitea-rolling-release.sh"
|
"release:rolling": "bash scripts/gitea-rolling-release.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Deep-sign a peardock .app so macOS 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).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/sign-macos-app.cjs path/to/peardock.app
|
||||||
|
* node scripts/sign-macos-app.cjs path/to/out/peardock-darwin-arm64
|
||||||
|
*
|
||||||
|
* 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")
|
||||||
|
*
|
||||||
|
* Tools:
|
||||||
|
* macOS: /usr/bin/codesign (required for production identities)
|
||||||
|
* Linux CI: rcodesign (apple-codesign) for ad-hoc when present
|
||||||
|
*/
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { spawnSync, execFileSync } = require('child_process')
|
||||||
|
|
||||||
|
const ENTITLEMENTS = path.join(__dirname, 'entitlements.mac.plist')
|
||||||
|
|
||||||
|
function log(...a) {
|
||||||
|
console.log('[sign-macos]', ...a)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findApps(input) {
|
||||||
|
const st = fs.statSync(input)
|
||||||
|
if (st.isFile() && input.endsWith('.app')) return [input]
|
||||||
|
if (st.isDirectory() && input.endsWith('.app')) return [input]
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
return fs
|
||||||
|
.readdirSync(input)
|
||||||
|
.filter((n) => n.endsWith('.app'))
|
||||||
|
.map((n) => path.join(input, n))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function which(cmd) {
|
||||||
|
try {
|
||||||
|
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
if (r.status === 0) return r.stdout.trim().split(/\r?\n/)[0]
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function identity() {
|
||||||
|
const id = process.env.MAC_CODESIGN_IDENTITY || process.env.CSC_NAME || ''
|
||||||
|
if (id && id !== '-' && id.toLowerCase() !== 'null') return id
|
||||||
|
return '-' // ad-hoc
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEntitlements() {
|
||||||
|
if (fs.existsSync(ENTITLEMENTS)) return ENTITLEMENTS
|
||||||
|
// Minimal Electron-friendly entitlements (JIT, unsigned memory for V8)
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`
|
||||||
|
fs.writeFileSync(ENTITLEMENTS, xml)
|
||||||
|
return ENTITLEMENTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk Mach-O / nested code to sign inside-out (required for valid deep signatures).
|
||||||
|
*/
|
||||||
|
function listSignTargets(appPath) {
|
||||||
|
const targets = []
|
||||||
|
const walk = (dir) => {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const ent of entries) {
|
||||||
|
const p = path.join(dir, ent.name)
|
||||||
|
if (ent.isDirectory()) {
|
||||||
|
if (ent.name === 'node_modules' || ent.name.startsWith('.')) {
|
||||||
|
// still need natives under node_modules
|
||||||
|
if (ent.name === 'node_modules') walk(p)
|
||||||
|
else if (!ent.name.startsWith('.')) walk(p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ent.name.endsWith('.app') || ent.name.endsWith('.framework')) {
|
||||||
|
// nested apps/frameworks signed as units later — still recurse for helpers
|
||||||
|
walk(p)
|
||||||
|
targets.push(p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
walk(p)
|
||||||
|
} else if (ent.isFile() || ent.isSymbolicLink()) {
|
||||||
|
const base = ent.name
|
||||||
|
if (
|
||||||
|
base.endsWith('.dylib') ||
|
||||||
|
base.endsWith('.so') ||
|
||||||
|
base.endsWith('.node') ||
|
||||||
|
base.endsWith('.bare') ||
|
||||||
|
base === 'peardock-client' ||
|
||||||
|
base.startsWith('peardock Helper') ||
|
||||||
|
base === 'Electron Framework' ||
|
||||||
|
base === 'Squirrel' ||
|
||||||
|
base === 'ReactiveObjC' ||
|
||||||
|
base === 'Mantle' ||
|
||||||
|
base === 'chrome_crashpad_handler'
|
||||||
|
) {
|
||||||
|
targets.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(appPath)
|
||||||
|
// De-dupe, nested bundles last (app itself last)
|
||||||
|
const uniq = [...new Set(targets)]
|
||||||
|
uniq.sort((a, b) => {
|
||||||
|
// deeper paths first
|
||||||
|
const da = a.split(path.sep).length
|
||||||
|
const db = b.split(path.sep).length
|
||||||
|
if (da !== db) return db - da
|
||||||
|
return b.length - a.length
|
||||||
|
})
|
||||||
|
// Ensure root .app is last
|
||||||
|
const rootIdx = uniq.indexOf(appPath)
|
||||||
|
if (rootIdx >= 0) uniq.splice(rootIdx, 1)
|
||||||
|
uniq.push(appPath)
|
||||||
|
return uniq
|
||||||
|
}
|
||||||
|
|
||||||
|
function codesignDarwin(appPath, id) {
|
||||||
|
const entitlements = ensureEntitlements()
|
||||||
|
const hardened = id !== '-'
|
||||||
|
|
||||||
|
// Clear broken signatures / quarantine that confuse Gatekeeper
|
||||||
|
try {
|
||||||
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep re-sign seals Info.plist + resources (fixes "damaged / Trash").
|
||||||
|
// Nested-first pass is only needed when --deep alone fails verification.
|
||||||
|
log(`deep codesign identity=${id === '-' ? 'ad-hoc' : id}`)
|
||||||
|
const rootArgs = [
|
||||||
|
'--force',
|
||||||
|
'--deep',
|
||||||
|
'--sign',
|
||||||
|
id,
|
||||||
|
'--entitlements',
|
||||||
|
entitlements,
|
||||||
|
]
|
||||||
|
if (hardened) rootArgs.push('--options', 'runtime', '--timestamp')
|
||||||
|
else rootArgs.push('--timestamp=none')
|
||||||
|
rootArgs.push(appPath)
|
||||||
|
|
||||||
|
let root = spawnSync('codesign', rootArgs, { encoding: 'utf8' })
|
||||||
|
if (root.status !== 0) {
|
||||||
|
throw new Error(`codesign failed for app:\n${root.stderr || root.stdout}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let v = spawnSync('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
if (v.status !== 0) {
|
||||||
|
// Inside-out sign nested Mach-O then re-seal the app
|
||||||
|
log('strict verify failed — signing nested code then app…')
|
||||||
|
const targets = listSignTargets(appPath)
|
||||||
|
for (const target of targets) {
|
||||||
|
if (target === appPath) continue
|
||||||
|
const args = ['--force', '--sign', id]
|
||||||
|
if (hardened) args.push('--options', 'runtime', '--timestamp')
|
||||||
|
else args.push('--timestamp=none')
|
||||||
|
args.push(target)
|
||||||
|
spawnSync('codesign', args, { encoding: 'utf8' })
|
||||||
|
}
|
||||||
|
root = spawnSync('codesign', rootArgs, { encoding: 'utf8' })
|
||||||
|
if (root.status !== 0) {
|
||||||
|
throw new Error(`codesign failed for app (retry):\n${root.stderr || root.stdout}`)
|
||||||
|
}
|
||||||
|
v = spawnSync('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
if (v.status !== 0) {
|
||||||
|
throw new Error(`codesign verify failed:\n${v.stderr || v.stdout}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('verify ok:', (v.stderr || v.stdout || '').trim().split('\n').slice(0, 3).join(' | '))
|
||||||
|
}
|
||||||
|
|
||||||
|
function codesignRcodesign(appPath, id) {
|
||||||
|
const bin = which('rcodesign')
|
||||||
|
if (!bin) {
|
||||||
|
throw new Error(
|
||||||
|
'rcodesign not found (needed to ad-hoc sign macOS .app on Linux). ' +
|
||||||
|
'Install apple-codesign / rcodesign in CI, or package darwin clients on macOS.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (id !== '-') {
|
||||||
|
log('WARN: rcodesign path only used for ad-hoc here; Developer ID needs codesign on macOS + certs')
|
||||||
|
}
|
||||||
|
// Clear extended attrs if possible (mac only usually)
|
||||||
|
const args = ['sign', '--ad-hoc', appPath]
|
||||||
|
log('rcodesign', args.join(' '))
|
||||||
|
const r = spawnSync(bin, args, { encoding: 'utf8', stdio: 'pipe' })
|
||||||
|
if (r.status !== 0) {
|
||||||
|
// newer CLI variants
|
||||||
|
const r2 = spawnSync(bin, ['sign', appPath, '--ad-hoc'], { encoding: 'utf8', stdio: 'pipe' })
|
||||||
|
if (r2.status !== 0) {
|
||||||
|
throw new Error(`rcodesign failed:\n${r.stderr || r.stdout}\n${r2.stderr || r2.stdout}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('rcodesign ad-hoc sign complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
function signApp(appPath) {
|
||||||
|
if (!fs.existsSync(appPath)) throw new Error(`App not found: ${appPath}`)
|
||||||
|
const id = identity()
|
||||||
|
log('app:', appPath)
|
||||||
|
log('identity:', id === '-' ? 'ad-hoc (-)' : id)
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// Ad-hoc: always use codesign -s - (osx-sign rejects identity "-" / "no identity")
|
||||||
|
// Developer ID: prefer @electron/osx-sign for Electron's nested helper layout
|
||||||
|
if (id === '-') {
|
||||||
|
codesignDarwin(appPath, id)
|
||||||
|
try {
|
||||||
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { signAsync } = require('@electron/osx-sign')
|
||||||
|
return signAsync({
|
||||||
|
app: appPath,
|
||||||
|
identity: id,
|
||||||
|
platform: 'darwin',
|
||||||
|
hardenedRuntime: true,
|
||||||
|
gatekeeperAssess: false,
|
||||||
|
optionsForFile: () => ({
|
||||||
|
entitlements: ensureEntitlements(),
|
||||||
|
hardenedRuntime: true,
|
||||||
|
}),
|
||||||
|
}).then(() => {
|
||||||
|
try {
|
||||||
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const v = spawnSync(
|
||||||
|
'codesign',
|
||||||
|
['--verify', '--deep', '--strict', '--verbose=2', appPath],
|
||||||
|
{ encoding: 'utf8' }
|
||||||
|
)
|
||||||
|
if (v.status !== 0) {
|
||||||
|
log('osx-sign verify soft-fail, falling back to codesign deep…')
|
||||||
|
codesignDarwin(appPath, id)
|
||||||
|
} else {
|
||||||
|
log('osx-sign + verify ok')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log('osx-sign unavailable or failed, using codesign:', err.message || err)
|
||||||
|
codesignDarwin(appPath, id)
|
||||||
|
try {
|
||||||
|
execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux (CI cross-package of darwin)
|
||||||
|
codesignRcodesign(appPath, id)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const input = process.argv[2]
|
||||||
|
if (!input) {
|
||||||
|
console.error('Usage: node scripts/sign-macos-app.cjs <path-to.app|dir>')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(input)
|
||||||
|
const apps = findApps(resolved)
|
||||||
|
if (!apps.length) {
|
||||||
|
console.error('No .app found at', resolved)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
for (const app of apps) {
|
||||||
|
await signApp(app)
|
||||||
|
}
|
||||||
|
log('done')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { signApp, findApps, identity }
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[sign-macos] FAILED:', err.message || err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user