Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions electron-builder.signed.cjs
Original file line number Diff line number Diff line change
@@ -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,
},
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 94 additions & 0 deletions scripts/check-macos-signed-release.js
Original file line number Diff line number Diff line change
@@ -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 };
20 changes: 12 additions & 8 deletions scripts/check-release-trust.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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') {
Expand All @@ -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')
);
}

Expand Down Expand Up @@ -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)}`),
];
}

Expand All @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions scripts/check-release-workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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'),
});
Expand All @@ -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.');
}

Expand Down
70 changes: 70 additions & 0 deletions scripts/release-signed.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading