From f7f0356aae65ab1fc08eaf516880c0cf78a4175d Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Tue, 13 Oct 2026 21:29:52 -0700 Subject: [PATCH] Prepare signed macOS release path Add a credentialed Developer ID/Hardened Runtime/notarization configuration and exact-artifact verification path without changing the existing ad-hoc public alpha workflow.\n\nThe signed path reruns packaged runtime/backend/renderer/transcription gates, verifies the trusted app inside the DMG, disables publishing, and fails before mutation when signing or notary credentials are unavailable. --- docs/RELEASE.md | 8 ++- electron-builder.signed.cjs | 19 ++++++ package.json | 2 + scripts/check-macos-signed-release.js | 94 +++++++++++++++++++++++++++ scripts/check-release-trust.js | 20 +++--- scripts/check-release-workflow.js | 29 +++++++++ scripts/release-signed.js | 70 ++++++++++++++++++++ 7 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 electron-builder.signed.cjs create mode 100644 scripts/check-macos-signed-release.js create mode 100644 scripts/release-signed.js diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 63ed19d..4efffe0 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -254,19 +254,23 @@ Signed mode: ```bash npm run release:trust:signed +npm run release:signed:arm64 ``` -Signed mode hard-fails without Developer ID and notarization credentials. The signed packaging path remains a future optional enhancement; this phase does not add credentials, signing keys, or notarization. +Signed mode hard-fails without Developer ID and notarization credentials. The credentialed packaging path uses a separate `electron-builder.signed.cjs` configuration with Hardened Runtime, the minimal Electron JIT entitlements, distribution signing, and electron-builder notarization. Electron-builder notarizes and staples `ScriptCut.app`, then places that trusted app inside the DMG; the DMG container itself is integrity-checked with `hdiutil` and is not claimed to carry its own Developer ID signature or notarization ticket. After packaging the command reruns the packaged runtime, identity, FFmpeg, backend, renderer, creator, optional-capability, and transcription gates against the exact signed app, then verifies the app with strict `codesign`, `spctl`, and `xcrun stapler validate`. Add `-- --real-model` to `npm run release:signed:arm64` when the signed artifact also needs the first-download/offline/repair/reacquisition Whisper proof. It prepares an artifact only; it does not create a tag or GitHub Release. + +This repository does not contain signing certificates, private keys, Apple credentials, or GitHub secrets. The path can be statically validated without them, but a real signed release is qualified only when the credentialed command completes successfully on macOS arm64. Supported signing inputs: -- `CSC_LINK` and `CSC_KEY_PASSWORD` for a certificate file. +- `CSC_LINK` for a certificate file, with `CSC_KEY_PASSWORD` when the file is password-protected. - `CSC_NAME` when the certificate is already installed in the signing keychain. Supported notarization inputs: - `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. - Or `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`. +- Or `APPLE_KEYCHAIN_PROFILE`, with optional `APPLE_KEYCHAIN` for a non-default keychain. ## Notes diff --git a/electron-builder.signed.cjs b/electron-builder.signed.cjs new file mode 100644 index 0000000..69330e6 --- /dev/null +++ b/electron-builder.signed.cjs @@ -0,0 +1,19 @@ +const candidateConfig = require('./electron-builder.release.cjs'); + +module.exports = { + ...candidateConfig, + directories: { + output: 'dist/signed-release/app', + }, + mac: { + ...candidateConfig.mac, + hardenedRuntime: true, + entitlements: 'build/entitlements.mac.plist', + entitlementsInherit: 'build/entitlements.mac.inherit.plist', + type: 'distribution', + // Leave identity discovery to electron-builder when CSC_LINK is used. + // CSC_NAME can select an installed Developer ID Application identity. + identity: process.env.CSC_NAME || undefined, + notarize: true, + }, +}; diff --git a/package.json b/package.json index a875f76..24a07d6 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "release:trust": "node scripts/check-release-trust.js --candidate", "release:trust:candidate": "node scripts/check-release-trust.js --candidate", "release:trust:signed": "node scripts/check-release-trust.js --signed", + "release:signed:arm64": "node scripts/release-signed.js", + "check:signed-release": "node scripts/check-macos-signed-release.js", "runtime:prepare:mac-arm64": "node scripts/prepare-python-runtime.js --target darwin-arm64", "dist:dir:arm64:self-contained": "npm run build:frontend && npm run runtime:prepare:mac-arm64 && electron-builder --config electron-builder.self-contained.cjs --dir --arm64 && npm run check:packaged-runtime && npm run smoke:packaged-backend", "dist:mac:arm64:self-contained": "npm run build:frontend && npm run runtime:prepare:mac-arm64 && electron-builder --config electron-builder.self-contained.cjs --mac dmg --arm64 && npm run check:packaged-runtime && npm run smoke:packaged-backend", diff --git a/scripts/check-macos-signed-release.js b/scripts/check-macos-signed-release.js new file mode 100644 index 0000000..4d92e63 --- /dev/null +++ b/scripts/check-macos-signed-release.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.join(__dirname, '..'); + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function fail(message) { + throw new Error(`Signed macOS release check failed: ${message}`); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { ...result, output: `${result.stdout || ''}${result.stderr || ''}` }; +} + +function requireSuccess(command, args, label) { + const result = run(command, args); + if (result.error || result.status !== 0) { + fail(`${label}: ${(result.error?.message || result.output || 'command failed').trim()}`); + } + return result; +} + +function verifyApp(appPath) { + if (!fs.existsSync(appPath) || !appPath.endsWith('.app')) fail(`app does not exist: ${appPath}`); + requireSuccess('codesign', ['--verify', '--deep', '--strict', '--verbose=4', appPath], 'codesign verification failed'); + const details = requireSuccess('codesign', ['-dv', '--verbose=4', appPath], 'could not inspect code signature').output; + if (!/Authority=Developer ID Application:/i.test(details)) fail('Developer ID Application authority is missing'); + const teamIdentifier = details.match(/TeamIdentifier=([^\s]+)/i)?.[1]; + if (!teamIdentifier || teamIdentifier === 'not') fail('Developer ID TeamIdentifier is missing'); + if (!/flags=.*\bruntime\b/i.test(details)) fail('Hardened Runtime flag is missing'); + requireSuccess('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], 'Gatekeeper assessment failed'); + requireSuccess('xcrun', ['stapler', 'validate', appPath], 'notarization staple validation failed'); + return { teamIdentifier }; +} + +function verifyDmg(dmgPath) { + if (!fs.existsSync(dmgPath) || !dmgPath.endsWith('.dmg')) fail(`DMG does not exist: ${dmgPath}`); + requireSuccess('hdiutil', ['verify', dmgPath], 'DMG integrity verification failed'); + const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptcut-signed-release-')); + let attached = false; + try { + requireSuccess('hdiutil', ['attach', '-readonly', '-nobrowse', '-mountpoint', mountPoint, dmgPath], 'DMG mount failed'); + attached = true; + const appPath = path.join(mountPoint, 'ScriptCut.app'); + if (!fs.existsSync(appPath)) fail('DMG does not contain ScriptCut.app at its root'); + return verifyApp(appPath); + } finally { + if (attached) spawnSync('hdiutil', ['detach', mountPoint, '-force'], { cwd: root, stdio: 'ignore' }); + fs.rmSync(mountPoint, { recursive: true, force: true }); + } +} + +function main() { + if (process.platform !== 'darwin' || process.arch !== 'arm64') { + fail(`requires native macOS arm64, received ${process.platform}-${process.arch}`); + } + const appPath = optionValue('--app'); + const dmgPath = optionValue('--dmg'); + if (!appPath && !dmgPath) fail('provide --app and/or --dmg'); + let result = null; + if (appPath) result = verifyApp(path.resolve(appPath)); + if (dmgPath) result = verifyDmg(path.resolve(dmgPath)); + console.log(`Developer ID TeamIdentifier: ${result?.teamIdentifier || 'verified'}`); + console.log('App Developer ID signature: PASS'); + console.log('App Hardened Runtime: PASS'); + console.log('App Gatekeeper assessment: PASS'); + console.log('App notarization staple: PASS'); + if (dmgPath) console.log('DMG container integrity: PASS'); + console.log('Signed macOS release verification passed.'); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +module.exports = { verifyApp, verifyDmg }; diff --git a/scripts/check-release-trust.js b/scripts/check-release-trust.js index ad2d0b5..98b239f 100644 --- a/scripts/check-release-trust.js +++ b/scripts/check-release-trust.js @@ -6,7 +6,8 @@ const { spawnSync } = require('child_process'); const root = path.join(__dirname, '..'); const packagePath = path.join(root, 'package.json'); -const releaseConfigPath = path.join(root, 'electron-builder.release.cjs'); +const candidateReleaseConfigPath = path.join(root, 'electron-builder.release.cjs'); +const signedReleaseConfigPath = path.join(root, 'electron-builder.signed.cjs'); const forbiddenEntitlements = [ 'com.apple.security.get-task-allow', 'com.apple.security.cs.disable-library-validation', @@ -43,10 +44,11 @@ function envSet(...names) { return names.every((name) => typeof process.env[name] === 'string' && process.env[name] !== ''); } -function releaseConfig() { - if (!fs.existsSync(releaseConfigPath)) return null; +function releaseConfig(configPath) { + if (!fs.existsSync(configPath)) return null; try { - return require(releaseConfigPath); + delete require.cache[require.resolve(configPath)]; + return require(configPath); } catch (error) { throw new Error(`release config could not be loaded: ${error.message}`); } @@ -70,7 +72,7 @@ function validateEntitlements(filePath) { } function signingCredentialState() { - const configuredCertificate = envSet('CSC_LINK', 'CSC_KEY_PASSWORD') + const configuredCertificate = envSet('CSC_LINK') || (process.env.CSC_NAME || '').includes('Developer ID Application'); let discoverableIdentity = false; if (process.platform === 'darwin') { @@ -84,7 +86,7 @@ function notarizationCredentialState() { return ( envSet('APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER') || envSet('APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID') - || envSet('APPLE_KEYCHAIN', 'APPLE_KEYCHAIN_PROFILE') + || envSet('APPLE_KEYCHAIN_PROFILE') ); } @@ -135,7 +137,9 @@ function signedChecks(pkg, config) { ...commonChecks(pkg, config), check('Developer ID credentials configured', signingCredentialState(), 'safe boolean credential state'), check('Notarization credentials configured', notarizationCredentialState(), 'safe boolean credential state'), - check('Signed packaging config is explicit', config?.mac?.identity !== null, '3B.5A release config is candidate-only; signed packaging belongs to 3B.5B'), + check('Signed packaging does not use ad-hoc identity', config?.mac?.identity !== '-' && config?.mac?.identity !== null, `identity=${String(config?.mac?.identity)}`), + check('Signed packaging requests distribution signing', config?.mac?.type === 'distribution', `type=${String(config?.mac?.type)}`), + check('Signed packaging requests notarization', config?.mac?.notarize === true, `notarize=${String(config?.mac?.notarize)}`), ]; } @@ -147,7 +151,7 @@ function main() { } const pkg = readPackage(); - const config = releaseConfig(); + const config = releaseConfig(mode === 'candidate' ? candidateReleaseConfigPath : signedReleaseConfigPath); const checks = mode === 'candidate' ? candidateChecks(pkg, config) : signedChecks(pkg, config); let failures = 0; for (const item of checks) { diff --git a/scripts/check-release-workflow.js b/scripts/check-release-workflow.js index 3cd3dee..9f2c527 100644 --- a/scripts/check-release-workflow.js +++ b/scripts/check-release-workflow.js @@ -7,6 +7,7 @@ const root = path.join(__dirname, '..'); const workflowPath = path.join(root, '.github', 'workflows', 'release-unsigned.yml'); const ciWorkflowPath = path.join(root, '.github', 'workflows', 'ci.yml'); const releaseConfigPath = path.join(root, 'electron-builder.release.cjs'); +const signedReleaseConfigPath = path.join(root, 'electron-builder.signed.cjs'); const releaseAlphaPath = path.join(root, 'scripts', 'release-alpha.js'); function fail(message) { @@ -207,6 +208,7 @@ function main() { assert(fs.existsSync(workflowPath), '.github/workflows/release-unsigned.yml is missing'); assert(fs.existsSync(ciWorkflowPath), '.github/workflows/ci.yml is missing'); assert(fs.existsSync(releaseConfigPath), 'electron-builder.release.cjs is missing'); + assert(fs.existsSync(signedReleaseConfigPath), 'electron-builder.signed.cjs is missing'); validateWorkflowText(fs.readFileSync(workflowPath, 'utf8'), { candidateWorkflowText: fs.readFileSync(ciWorkflowPath, 'utf8'), }); @@ -219,6 +221,33 @@ function main() { assert(/notarize:\s*false/.test(releaseConfig), 'release config must keep notarization disabled for public ad-hoc candidates'); assert(!/disable-library-validation|get-task-allow|allow-dyld-environment-variables/i.test(releaseConfig), 'forbidden entitlement found in release config'); assert(!/entitlements(?:Inherit)?\s*:/.test(releaseConfig), 'public ad-hoc release config must not apply signing entitlements'); + const signedReleaseConfig = fs.readFileSync(signedReleaseConfigPath, 'utf8'); + assert(/hardenedRuntime:\s*true/.test(signedReleaseConfig), 'signed release config must enable Hardened Runtime'); + assert(/notarize:\s*true/.test(signedReleaseConfig), 'signed release config must enable notarization'); + assert(/type:\s*['"]distribution['"]/.test(signedReleaseConfig), 'signed release config must request distribution signing'); + assert(/entitlements:\s*['"]build\/entitlements\.mac\.plist['"]/.test(signedReleaseConfig), 'signed release config must set primary entitlements'); + assert(/entitlementsInherit:\s*['"]build\/entitlements\.mac\.inherit\.plist['"]/.test(signedReleaseConfig), 'signed release config must set inherited entitlements'); + assert(!/identity:\s*['"]-['"]/.test(signedReleaseConfig), 'signed release config must not use ad-hoc identity'); + const packageText = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + assert(/release:signed:arm64/.test(packageText) && /scripts\/release-signed\.js/.test(packageText), 'signed release command is missing'); + assert(/check:signed-release/.test(packageText) && /check-macos-signed-release\.js/.test(packageText), 'signed release verification command is missing'); + const signedReleaseScript = fs.readFileSync(path.join(root, 'scripts', 'release-signed.js'), 'utf8'); + for (const requiredGate of [ + 'check-packaged-runtime.js', + 'smoke-packaged-release-identity.js', + 'check-packaged-ffmpeg.js', + 'smoke-packaged-backend.js', + 'check-packaged-electron-backend.js', + 'smoke-packaged-electron-renderer.js', + 'smoke-packaged-creator-qualification.js', + 'smoke-packaged-optional-capabilities.js', + 'smoke-packaged-transcription.js', + 'check-macos-signed-release.js', + ]) { + assert(signedReleaseScript.includes(requiredGate), `signed release path is missing exact-artifact gate: ${requiredGate}`); + } + assert(/process\.argv\.includes\('--real-model'\)/.test(signedReleaseScript), 'signed release path must expose optional real-model proof'); + assert(/--publish['"],\s*['"]never['"]/.test(signedReleaseScript), 'signed release path must explicitly disable electron-builder publishing'); console.log('Ad-hoc public release workflow structure and permission checks passed.'); } diff --git a/scripts/release-signed.js b/scripts/release-signed.js new file mode 100644 index 0000000..e0150c0 --- /dev/null +++ b/scripts/release-signed.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { readProductVersion } = require('./release-identity'); + +const root = path.join(__dirname, '..'); + +function fail(message) { + throw new Error(`Signed release preparation failed: ${message}`); +} + +function run(name, command, args, options = {}) { + console.log(`\n==> ${name}`); + const result = spawnSync(command, args, { + cwd: root, + env: options.env || process.env, + stdio: 'inherit', + }); + if (result.error || result.status !== 0) { + fail(`${name} failed${result.error ? `: ${result.error.message}` : ` with exit code ${result.status}`}`); + } +} + +function main() { + if (process.platform !== 'darwin' || process.arch !== 'arm64') { + fail(`requires native macOS arm64, received ${process.platform}-${process.arch}`); + } + const version = readProductVersion(); + const outputRoot = path.join(root, 'dist', 'signed-release', 'app'); + const realModel = process.argv.includes('--real-model'); + + run('Credentialed release trust preflight', 'node', ['scripts/check-release-trust.js', '--signed']); + fs.rmSync(path.join(root, 'dist', 'signed-release'), { recursive: true, force: true }); + run('Generate release icons', 'npm', ['run', 'release:icons']); + run('Verify macOS icon', 'npm', ['run', 'check:macos-icon']); + run('Prepare FFmpeg bundle', 'npm', ['run', 'release:ffmpeg']); + run('Build frontend', 'npm', ['run', 'build:frontend']); + run('Prepare hash-locked Python runtime', 'npm', ['run', 'runtime:prepare:mac-arm64']); + run('Build DMG containing Developer ID signed and notarized app', 'npx', [ + 'electron-builder', '--config', 'electron-builder.signed.cjs', '--mac', 'dmg', '--arm64', '--publish', 'never', + ]); + + const appPath = path.join(outputRoot, 'mac-arm64', 'ScriptCut.app'); + const dmgPath = path.join(outputRoot, `ScriptCut-${version}-arm64.dmg`); + run('Packaged runtime gate', 'node', ['scripts/check-packaged-runtime.js', '--arch', 'arm64', '--app', appPath]); + run('Packaged release identity gate', 'node', ['scripts/smoke-packaged-release-identity.js', '--app', appPath]); + run('Packaged FFmpeg gate', 'node', ['scripts/check-packaged-ffmpeg.js', '--arch', 'arm64', '--app', appPath]); + run('Packaged backend gate', 'node', ['scripts/smoke-packaged-backend.js', '--arch', 'arm64', '--app', appPath]); + run('Electron-like packaged backend startup gate', 'node', ['scripts/check-packaged-electron-backend.js', '--app', appPath]); + run('Packaged Electron renderer transport gate', 'node', ['scripts/smoke-packaged-electron-renderer.js', '--app', appPath]); + run('Packaged creator qualification gate', 'node', ['scripts/smoke-packaged-creator-qualification.js', '--app', appPath]); + run('Packaged optional-capability gate', 'node', ['scripts/smoke-packaged-optional-capabilities.js', '--arch', 'arm64', '--app', appPath]); + const transcriptionArgs = ['scripts/smoke-packaged-transcription.js', '--arch', 'arm64', '--app', appPath]; + if (realModel) transcriptionArgs.push('--real-model'); + run('Packaged transcription contract gate', 'node', transcriptionArgs); + run('Verify DMG integrity and signed/notarized app trust', 'node', [ + 'scripts/check-macos-signed-release.js', '--app', appPath, '--dmg', dmgPath, + ]); + console.log(`\nSigned release artifact: ${path.relative(root, dmgPath)}`); + console.log('No tag or GitHub Release was created.'); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}