diff --git a/native/package.json b/native/package.json index 7f59812..37dbcd7 100644 --- a/native/package.json +++ b/native/package.json @@ -7,6 +7,7 @@ "dev": "tauri dev", "build": "tauri build", "build:macos": "tauri build --debug --bundles app", + "release:macos": "node release-macos.mjs", "test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked", "check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings", "dev:android": "tauri android dev", diff --git a/native/release-macos.mjs b/native/release-macos.mjs new file mode 100644 index 0000000..0d4b70f --- /dev/null +++ b/native/release-macos.mjs @@ -0,0 +1,444 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +export const appleTeamId = 'ZZX2KZGTHU'; +export const bundleIdentifier = 'de.kanbangit.desktop'; +const submissionIdPattern = + /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; + +function execute(command, args, options) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: options.inherit ? 'inherit' : 'pipe', + }); + + if (result.error || result.status !== 0) { + throw new Error( + `${options.step} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}.`, + ); + } + + return result.stdout?.trim() || result.stderr?.trim() || ''; +} + +export function releaseMacos({ + env = process.env, + platform = process.platform, + arch = process.arch, + directory = dirname(fileURLToPath(import.meta.url)), + run = execute, + log = console.log, + resumeSubmissionId = null, +} = {}) { + if (platform !== 'darwin') { + throw new Error('macOS releases require a Mac with Xcode installed.'); + } + + if ( + resumeSubmissionId !== null && + !submissionIdPattern.test(resumeSubmissionId) + ) { + throw new Error('--resume requires the Apple submission UUID.'); + } + + const identity = env.APPLE_SIGNING_IDENTITY?.trim(); + const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim(); + + if ( + !identity?.startsWith('Developer ID Application: ') || + !identity.endsWith(` (${appleTeamId})`) || + /[\r\n"]/.test(identity) + ) { + throw new Error( + `Set APPLE_SIGNING_IDENTITY to the full Developer ID Application certificate name for team ${appleTeamId}.`, + ); + } + + if (env.APPLE_TEAM_ID && env.APPLE_TEAM_ID !== appleTeamId) { + throw new Error(`APPLE_TEAM_ID must be ${appleTeamId}.`); + } + + if (!profile) { + throw new Error( + 'Set APPLE_NOTARY_KEYCHAIN_PROFILE to a notarytool profile created with store-credentials for the new Apple team.', + ); + } + + const target = + env.MACOS_RELEASE_TARGET || + { arm64: 'aarch64-apple-darwin', x64: 'x86_64-apple-darwin' }[arch]; + + if (!['aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) { + throw new Error( + 'MACOS_RELEASE_TARGET must be aarch64-apple-darwin or x86_64-apple-darwin.', + ); + } + + const config = JSON.parse( + readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'), + ); + + if ( + config.identifier !== bundleIdentifier || + config.productName !== 'KanbanGit' + ) { + throw new Error( + 'The macOS release must keep the KanbanGit product name and bundle identifier.', + ); + } + + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version)) { + throw new Error( + 'The macOS release version must be a stable MAJOR.MINOR.PATCH version.', + ); + } + + const targetDirectory = join(directory, 'src-tauri/target'); + const releaseDirectory = join(targetDirectory, 'macos-release'); + const destination = join(releaseDirectory, `${config.version}-${target}`); + + if (existsSync(destination)) { + throw new Error( + `Release output already exists: ${destination}. Move it aside or use a new version.`, + ); + } + + // Notarization is handled below with the explicit keychain profile, never ambient Apple credentials. + const buildEnv = Object.fromEntries( + Object.entries(env).filter( + ([key]) => !key.startsWith('APPLE_') && key !== 'TAURI_CONFIG', + ), + ); + buildEnv.APPLE_SIGNING_IDENTITY = identity; + buildEnv.CARGO_TARGET_DIR = targetDirectory; + + function step(label, command, args, inherit = false) { + log(label); + + return run(command, args, { + cwd: directory, + env: buildEnv, + step: label, + inherit, + }); + } + + const identities = step('Checking signing certificate', 'security', [ + 'find-identity', + '-v', + '-p', + 'codesigning', + ]); + + if ( + !identities + .split('\n') + .some((line) => line.trim().endsWith(`"${identity}"`)) + ) { + throw new Error( + `Install the Developer ID Application certificate for ${appleTeamId}, including its private key, in an unlocked keychain.`, + ); + } + + const notaryCredentials = ['--keychain-profile', profile]; + + if (env.APPLE_NOTARY_KEYCHAIN?.trim()) { + notaryCredentials.push('--keychain', env.APPLE_NOTARY_KEYCHAIN.trim()); + } + + step( + 'Checking notarization access (check the keychain profile if this fails)', + 'xcrun', + [ + 'notarytool', + 'history', + ...notaryCredentials, + '--output-format', + 'json', + ], + ); + + const app = join( + targetDirectory, + target, + 'release/bundle/macos/KanbanGit.app', + ); + if (!resumeSubmissionId) { + step( + 'Building signed macOS release', + 'pnpm', + [ + 'exec', + 'tauri', + 'build', + '--ci', + '--target', + target, + '--bundles', + 'app', + '--config', + JSON.stringify({ + bundle: { + createUpdaterArtifacts: false, + macOS: { + signingIdentity: identity, + hardenedRuntime: true, + }, + }, + }), + '--', + '--locked', + ], + true, + ); + } + + if (!existsSync(app)) { + throw new Error('The release build did not produce KanbanGit.app.'); + } + + step('Verifying Developer ID signature and team', 'codesign', [ + '--verify', + '--deep', + '--strict', + '--verbose=2', + '-R', + `=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists`, + app, + ]); + const signature = step('Checking hardened runtime', 'codesign', [ + '--display', + '--verbose=4', + app, + ]); + + if (!/^CodeDirectory .*flags=.*\bruntime\b/m.test(signature)) { + throw new Error( + 'The release signature does not enable Hardened Runtime.', + ); + } + + const cdhash = signature.match(/^CDHash=([\da-f]+)$/m)?.[1]; + + if (!cdhash) { + throw new Error( + 'The signed app has no CodeDirectory hash to match against Apple notarization.', + ); + } + + const version = step('Checking bundled release version', 'plutil', [ + '-extract', + 'CFBundleShortVersionString', + 'raw', + '-o', + '-', + join(app, 'Contents/Info.plist'), + ]); + + if (version.trim() !== config.version) { + throw new Error( + 'The built app version does not match the release version.', + ); + } + + mkdirSync(releaseDirectory, { recursive: true }); + const staging = mkdtempSync(join(releaseDirectory, '.pending-')); + + try { + const submission = join(staging, 'notarization.zip'); + let submissionId = resumeSubmissionId; + + if (!submissionId) { + step('Preparing notarization archive', 'ditto', [ + '-c', + '-k', + '--sequesterRsrc', + '--keepParent', + app, + submission, + ]); + const submitted = JSON.parse( + step('Submitting to Apple', 'xcrun', [ + 'notarytool', + 'submit', + submission, + ...notaryCredentials, + '--no-wait', + '--output-format', + 'json', + ]), + ); + submissionId = submitted.id; + + if (!submissionIdPattern.test(submissionId ?? '')) { + throw new Error( + 'Apple did not return a valid submission UUID. Check notarytool history before retrying.', + ); + } + } + + const resumeHint = `Resume without rebuilding or uploading: pnpm --dir native release:macos --resume ${submissionId}`; + log(`Apple submission: ${submissionId}. ${resumeHint}`); + writeFileSync( + join( + targetDirectory, + `notarization-${submissionId}.submission.json`, + ), + JSON.stringify( + { + submissionId, + version: config.version, + target, + cdhash, + }, + null, + 2, + ) + '\n', + ); + + let result; + + try { + result = JSON.parse( + step('Waiting for Apple notarization', 'xcrun', [ + 'notarytool', + 'wait', + submissionId, + ...notaryCredentials, + '--timeout', + '30m', + '--output-format', + 'json', + ]), + ); + } catch (error) { + throw new Error( + `${error.message} Apple may still be processing this submission. ${resumeHint}`, + ); + } + + if (result.status !== 'Accepted') { + throw new Error( + `Apple did not accept the release (status: ${result.status ?? 'unknown'}, submission: ${submissionId}). Inspect it with notarytool log. ${resumeHint}`, + ); + } + + const notaryLog = JSON.parse( + step('Checking Apple notarization log', 'xcrun', [ + 'notarytool', + 'log', + submissionId, + ...notaryCredentials, + ]), + ); + writeFileSync( + join(targetDirectory, `notarization-${submissionId}.log.json`), + JSON.stringify(notaryLog, null, 2) + '\n', + ); + const expectedArch = + target === 'aarch64-apple-darwin' ? 'arm64' : 'x86_64'; + + if ( + notaryLog.jobId !== submissionId || + notaryLog.status !== 'Accepted' || + !notaryLog.ticketContents?.some( + (ticket) => + ticket.path.endsWith('/KanbanGit.app') && + ticket.cdhash === cdhash && + ticket.arch === expectedArch, + ) + ) { + throw new Error( + 'The accepted Apple submission does not match this signed app and architecture. Restore the submitted app or create a new release.', + ); + } + + if (notaryLog.issues?.length) { + throw new Error( + `Apple reported notarization issues. Review src-tauri/target/notarization-${submissionId}.log.json before distribution.`, + ); + } + + step('Stapling notarization ticket', 'xcrun', [ + 'stapler', + 'staple', + app, + ]); + step('Validating notarization ticket', 'xcrun', [ + 'stapler', + 'validate', + app, + ]); + step('Verifying final app signature', 'codesign', [ + '--verify', + '--deep', + '--strict', + app, + ]); + step('Checking Gatekeeper acceptance', 'spctl', [ + '--assess', + '--type', + 'execute', + '--verbose=2', + app, + ]); + + const name = `KanbanGit-${config.version}-${target}.zip`; + const archive = join(staging, name); + step('Packaging notarized download', 'ditto', [ + '-c', + '-k', + '--sequesterRsrc', + '--keepParent', + app, + archive, + ]); + const checksum = createHash('sha256') + .update(readFileSync(archive)) + .digest('hex'); + writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`); + rmSync(submission, { force: true }); + renameSync(staging, destination); + log(`Verified release: ${join(destination, name)}`); + + return { + archive: join(destination, name), + checksum, + version: config.version, + target, + }; + } finally { + rmSync(staging, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + const { values } = parseArgs({ + options: { resume: { type: 'string' } }, + }); + releaseMacos({ resumeSubmissionId: values.resume ?? null }); + } catch (error) { + console.error(`macOS release failed: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/native/tests/macos-release.test.js b/native/tests/macos-release.test.js new file mode 100644 index 0000000..d0d0edc --- /dev/null +++ b/native/tests/macos-release.test.js @@ -0,0 +1,554 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { releaseMacos } from '../release-macos.mjs'; + +const identity = 'Developer ID Application: Example Developer (ZZX2KZGTHU)'; +const submissionId = '3aa7effe-4333-4b03-925c-78427354a3e6'; +const cdhash = 'a3850211578ca9130445ec77918a619778cb465d'; + +function fixture(t, overrides = {}) { + const directory = mkdtempSync(join(tmpdir(), 'kanbangit-release-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const config = { + productName: 'KanbanGit', + identifier: 'de.kanbangit.desktop', + version: '1.2.3', + }; + const configPath = join(directory, 'src-tauri/tauri.conf.json'); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(config)); + + const calls = []; + const releaseDirectory = join(directory, 'src-tauri/target/macos-release'); + const destination = join(releaseDirectory, '1.2.3-aarch64-apple-darwin'); + let stapled = false; + const options = { + directory, + platform: 'darwin', + arch: 'arm64', + log() {}, + env: { + APPLE_SIGNING_IDENTITY: identity, + APPLE_NOTARY_KEYCHAIN_PROFILE: 'kanbangit-notary', + }, + run(command, args, settings) { + calls.push({ command, args, settings }); + + if (overrides.failStep === settings.step) { + throw new Error('Simulated external failure'); + } + + if (command === 'security') { + return ( + overrides.identities ?? + `1) ABCDEF "${identity}"\n1 valid identities found` + ); + } + + if (command === 'pnpm' && !overrides.missingApp) { + const target = args[args.indexOf('--target') + 1]; + mkdirSync( + join( + directory, + 'src-tauri/target', + target, + 'release/bundle/macos/KanbanGit.app', + ), + { recursive: true }, + ); + } + + if (command === 'codesign' && args.includes('--display')) { + return ( + overrides.signature ?? + `CodeDirectory v=20500 size=1024 flags=0x10000(runtime) hashes=10\nCDHash=${cdhash}` + ); + } + + if (command === 'plutil') { + return overrides.version ?? '1.2.3'; + } + + if ( + command === 'xcrun' && + args[0] === 'notarytool' && + args[1] === 'submit' + ) { + return ( + overrides.submissionResult ?? + JSON.stringify({ id: submissionId }) + ); + } + + if ( + command === 'xcrun' && + args[0] === 'notarytool' && + args[1] === 'wait' + ) { + return ( + overrides.notaryResult ?? + JSON.stringify({ status: 'Accepted', id: submissionId }) + ); + } + + if ( + command === 'xcrun' && + args[0] === 'notarytool' && + args[1] === 'log' + ) { + return ( + overrides.notaryLog ?? + JSON.stringify({ + jobId: submissionId, + status: 'Accepted', + issues: null, + ticketContents: [ + { + path: 'notarization.zip/KanbanGit.app', + cdhash, + arch: + options.env.MACOS_RELEASE_TARGET === + 'x86_64-apple-darwin' + ? 'x86_64' + : 'arm64', + }, + ], + }) + ); + } + + if ( + command === 'xcrun' && + args[0] === 'stapler' && + args[1] === 'staple' + ) { + stapled = true; + } + + if (command === 'ditto') { + writeFileSync( + args.at(-1), + stapled + ? 'archive with stapled ticket' + : 'archive before notarization', + ); + } + + return ''; + }, + }; + + return { + options, + calls, + config, + configPath, + releaseDirectory, + destination, + }; +} + +test('publishes only the stapled app archive with its exact SHA-256 checksum after verification', (t) => { + const f = fixture(t); + const result = releaseMacos(f.options); + const contents = readFileSync(result.archive, 'utf8'); + assert.equal(contents, 'archive with stapled ticket'); + assert.equal( + result.checksum, + createHash('sha256').update(contents).digest('hex'), + ); + assert.equal( + readFileSync(`${result.archive}.sha256`, 'utf8'), + `${result.checksum} KanbanGit-1.2.3-aarch64-apple-darwin.zip\n`, + ); + assert.deepEqual(readdirSync(f.destination).sort(), [ + 'KanbanGit-1.2.3-aarch64-apple-darwin.zip', + 'KanbanGit-1.2.3-aarch64-apple-darwin.zip.sha256', + ]); + assert.deepEqual(readdirSync(f.releaseDirectory), [ + '1.2.3-aarch64-apple-darwin', + ]); + const labels = f.calls.map((call) => call.settings.step); + assert.ok( + labels.indexOf('Checking Gatekeeper acceptance') < + labels.indexOf('Packaging notarized download'), + ); + const verify = f.calls.find((call) => call.args.includes('-R')); + assert.ok(verify.args.includes('--strict')); + assert.ok(verify.args.includes('--deep')); + assert.match( + verify.args[verify.args.indexOf('-R') + 1], + /anchor apple generic.*de\.kanbangit\.desktop.*ZZX2KZGTHU.*1\.2\.840\.113635\.100\.6\.1\.13/, + ); +}); + +for (const [name, env, expected] of [ + [ + 'missing identity', + { APPLE_SIGNING_IDENTITY: '' }, + /APPLE_SIGNING_IDENTITY/, + ], + [ + 'ad hoc identity', + { APPLE_SIGNING_IDENTITY: '-' }, + /Developer ID Application/, + ], + [ + 'development certificate', + { APPLE_SIGNING_IDENTITY: 'Apple Development: Example (ZZX2KZGTHU)' }, + /Developer ID Application/, + ], + [ + 'old team certificate', + { + APPLE_SIGNING_IDENTITY: + 'Developer ID Application: Example (OLDTEAM123)', + }, + /ZZX2KZGTHU/, + ], + ['old team environment', { APPLE_TEAM_ID: 'OLDTEAM123' }, /APPLE_TEAM_ID/], + [ + 'missing notary profile', + { APPLE_NOTARY_KEYCHAIN_PROFILE: ' ' }, + /APPLE_NOTARY_KEYCHAIN_PROFILE/, + ], + [ + 'unsupported target', + { MACOS_RELEASE_TARGET: 'aarch64-apple-ios' }, + /MACOS_RELEASE_TARGET/, + ], +]) { + test(`${name} fails before running external commands`, (t) => { + const f = fixture(t); + Object.assign(f.options.env, env); + assert.throws(() => releaseMacos(f.options), expected); + assert.equal(f.calls.length, 0); + assert.equal(existsSync(f.destination), false); + }); +} + +test('does not allow a non-macOS host', (t) => { + const f = fixture(t); + assert.throws( + () => releaseMacos({ ...f.options, platform: 'linux' }), + /require a Mac/, + ); + assert.equal(f.calls.length, 0); +}); + +for (const change of [ + { identifier: 'de.oldteam.app' }, + { productName: '../Other' }, + { version: '../outside' }, + { version: '1.2.3-beta.1' }, + { version: '01.2.3' }, +]) { + test(`rejects inconsistent release configuration ${JSON.stringify(change)}`, (t) => { + const f = fixture(t); + writeFileSync(f.configPath, JSON.stringify({ ...f.config, ...change })); + assert.throws(() => releaseMacos(f.options), /release/); + assert.equal(f.calls.length, 0); + }); +} + +test('requires the exact installed signing identity before building', (t) => { + const f = fixture(t, { identities: '0 valid identities found' }); + assert.throws(() => releaseMacos(f.options), /including its private key/); + assert.equal(f.calls.length, 1); +}); + +test('validates notarization access before the build', (t) => { + const f = fixture(t, { + failStep: + 'Checking notarization access (check the keychain profile if this fails)', + }); + assert.throws(() => releaseMacos(f.options), /external failure/); + assert.equal( + f.calls.some((call) => call.command === 'pnpm'), + false, + ); +}); + +test('pins release signing, hardening and target directory without inheriting unrelated Apple credentials', (t) => { + const f = fixture(t); + Object.assign(f.options.env, { + APPLE_ID: 'old-account@example.test', + APPLE_PASSWORD: 'must-not-be-passed', + APPLE_API_KEY: 'old-key', + APPLE_CERTIFICATE: 'old-certificate', + APPLE_TEAM_ID: 'ZZX2KZGTHU', + TAURI_CONFIG: '{"bundle":{"macOS":{"hardenedRuntime":false}}}', + CARGO_TARGET_DIR: '/other-build', + APPLE_NOTARY_KEYCHAIN: '/ci/release.keychain-db', + }); + const originalEnv = { ...f.options.env }; + releaseMacos(f.options); + assert.deepEqual(f.options.env, originalEnv); + const build = f.calls.find((call) => call.command === 'pnpm'); + assert.equal(build.args.includes('--debug'), false); + assert.deepEqual(build.args.slice(-2), ['--', '--locked']); + assert.deepEqual( + JSON.parse(build.args[build.args.indexOf('--config') + 1]), + { + bundle: { + createUpdaterArtifacts: false, + macOS: { signingIdentity: identity, hardenedRuntime: true }, + }, + }, + ); + assert.equal(build.settings.env.APPLE_SIGNING_IDENTITY, identity); + assert.equal( + build.settings.env.CARGO_TARGET_DIR, + join(f.options.directory, 'src-tauri/target'), + ); + for (const key of [ + 'APPLE_ID', + 'APPLE_PASSWORD', + 'APPLE_API_KEY', + 'APPLE_CERTIFICATE', + 'TAURI_CONFIG', + ]) { + assert.equal(build.settings.env[key], undefined); + } + for (const call of f.calls.filter( + (call) => call.args[0] === 'notarytool', + )) { + assert.equal( + call.args[call.args.indexOf('--keychain-profile') + 1], + 'kanbangit-notary', + ); + assert.equal( + call.args[call.args.indexOf('--keychain') + 1], + '/ci/release.keychain-db', + ); + } +}); + +test('supports an explicit Intel target with distinct output naming', (t) => { + const f = fixture(t); + f.options.env.MACOS_RELEASE_TARGET = 'x86_64-apple-darwin'; + const result = releaseMacos(f.options); + assert.equal(result.target, 'x86_64-apple-darwin'); + assert.match( + result.archive, + /1\.2\.3-x86_64-apple-darwin\/KanbanGit-1\.2\.3-x86_64-apple-darwin\.zip$/, + ); +}); + +for (const [name, override, expected] of [ + ['missing app', { missingApp: true }, /did not produce/], + [ + 'missing hardened runtime', + { signature: 'CodeDirectory flags=0x0(none)' }, + /Hardened Runtime/, + ], + ['mismatched version', { version: '0.9.0' }, /version/], +]) { + test(`${name} prevents submission to Apple`, (t) => { + const f = fixture(t, override); + assert.throws(() => releaseMacos(f.options), expected); + assert.equal( + f.calls.some((call) => call.args[1] === 'submit'), + false, + ); + assert.equal(existsSync(f.destination), false); + }); +} + +for (const failStep of [ + 'Building signed macOS release', + 'Verifying Developer ID signature and team', + 'Preparing notarization archive', + 'Submitting to Apple', + 'Waiting for Apple notarization', + 'Checking Apple notarization log', + 'Stapling notarization ticket', + 'Validating notarization ticket', + 'Verifying final app signature', + 'Checking Gatekeeper acceptance', + 'Packaging notarized download', +]) { + test(`${failStep} failure never publishes a download`, (t) => { + const f = fixture(t, { failStep }); + assert.throws(() => releaseMacos(f.options), /external failure/); + assert.equal(existsSync(f.destination), false); + if (existsSync(f.releaseDirectory)) { + assert.deepEqual(readdirSync(f.releaseDirectory), []); + } + }); +} + +for (const notaryResult of [ + '{"status":"Invalid","id":"rejected-id"}', + '{"status":"In Progress","id":"pending-id"}', + '{"id":"no-status"}', + 'not valid JSON', +]) { + test(`unaccepted notarization prevents stapling and publication: ${notaryResult}`, (t) => { + const f = fixture(t, { notaryResult }); + assert.throws(() => releaseMacos(f.options)); + assert.equal( + f.calls.some((call) => call.args[0] === 'stapler'), + false, + ); + assert.equal(existsSync(f.destination), false); + assert.deepEqual(readdirSync(f.releaseDirectory), []); + }); +} + +test('never overwrites an existing verified release', (t) => { + const f = fixture(t); + const result = releaseMacos(f.options); + const original = readFileSync(result.archive); + f.calls.length = 0; + assert.throws(() => releaseMacos(f.options), /already exists/); + assert.deepEqual(readFileSync(result.archive), original); + assert.equal(f.calls.length, 0); +}); + +test('persists submission ID on timeout and resumes the same app without rebuilding or uploading', (t) => { + const overrides = { failStep: 'Waiting for Apple notarization' }; + const f = fixture(t, overrides); + assert.throws( + () => releaseMacos(f.options), + new RegExp(`Apple may still be processing.*--resume ${submissionId}`), + ); + const record = JSON.parse( + readFileSync( + join( + f.options.directory, + 'src-tauri/target', + `notarization-${submissionId}.submission.json`, + ), + 'utf8', + ), + ); + assert.deepEqual(record, { + submissionId, + version: '1.2.3', + target: 'aarch64-apple-darwin', + cdhash, + }); + assert.equal(existsSync(f.destination), false); + overrides.failStep = null; + f.calls.length = 0; + const result = releaseMacos({ + ...f.options, + resumeSubmissionId: submissionId, + }); + assert.equal( + readFileSync(result.archive, 'utf8'), + 'archive with stapled ticket', + ); + assert.equal( + f.calls.some( + (call) => call.command === 'pnpm' || call.args[1] === 'submit', + ), + false, + ); + assert.equal( + f.calls.find((call) => call.args[1] === 'wait').args[2], + submissionId, + ); +}); + +test('rejects invalid resume arguments before any commands', (t) => { + const f = fixture(t); + assert.throws( + () => releaseMacos({ ...f.options, resumeSubmissionId: '--invalid' }), + /submission UUID/, + ); + assert.equal(f.calls.length, 0); +}); + +test('resume never rebuilds a missing previously submitted app', (t) => { + const f = fixture(t); + assert.throws( + () => releaseMacos({ ...f.options, resumeSubmissionId: submissionId }), + /did not produce/, + ); + assert.equal( + f.calls.some( + (call) => call.command === 'pnpm' || call.args[1] === 'submit', + ), + false, + ); +}); + +for (const submissionResult of ['{}', '{"id":"not-a-uuid"}', 'invalid JSON']) { + test(`invalid submission response cannot continue: ${submissionResult}`, (t) => { + const f = fixture(t, { submissionResult }); + assert.throws(() => releaseMacos(f.options)); + assert.equal( + f.calls.some( + (call) => call.args[1] === 'wait' || call.args[0] === 'stapler', + ), + false, + ); + assert.equal(existsSync(f.destination), false); + }); +} + +for (const change of [ + { jobId: '00000000-0000-0000-0000-000000000000' }, + { status: 'Invalid' }, + { ticketContents: [] }, + { + ticketContents: [ + { + path: 'notarization.zip/KanbanGit.app', + cdhash: 'different-code', + arch: 'arm64', + }, + ], + }, + { + ticketContents: [ + { path: 'notarization.zip/KanbanGit.app', cdhash, arch: 'x86_64' }, + ], + }, + { + ticketContents: [ + { path: 'notarization.zip/Other.app', cdhash, arch: 'arm64' }, + ], + }, + { issues: [{ severity: 'warning', message: 'Review this warning' }] }, +]) { + test(`Apple log must approve the exact app without outstanding issues: ${JSON.stringify(change)}`, (t) => { + const notaryLog = JSON.stringify({ + jobId: submissionId, + status: 'Accepted', + issues: null, + ticketContents: [ + { + path: 'notarization.zip/KanbanGit.app', + cdhash, + arch: 'arm64', + }, + ], + ...change, + }); + const f = fixture(t, { notaryLog }); + assert.throws( + () => releaseMacos(f.options), + /does not match|notarization issues/, + ); + assert.equal( + f.calls.some((call) => call.args[0] === 'stapler'), + false, + ); + assert.equal(existsSync(f.destination), false); + }); +}