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: { TAURI_SIGNING_PRIVATE_KEY: 'test-private-key', 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' && args.includes('build') && !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 === 'pnpm' && args.includes('signer')) { writeFileSync(`${args.at(-1)}.sig`, 'signature'); } if (command === 'tar') { writeFileSync(args[1], 'updater with stapled ticket'); } if ( (command === 'hdiutil' && args[0] === 'create') || 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.dmg\n`, ); assert.deepEqual(readdirSync(f.destination).sort(), [ 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz', 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sha256', 'KanbanGit-1.2.3-aarch64-apple-darwin.app.tar.gz.sig', 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg', 'KanbanGit-1.2.3-aarch64-apple-darwin.dmg.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 updater signing key', { TAURI_SIGNING_PRIVATE_KEY: '' }, /TAURI_SIGNING_PRIVATE_KEY/, ], [ '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\.dmg$/, ); }); 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.includes('build')) || 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.includes('build')) || 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); }); } test('release versions agree across native manifests', () => { const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); const config = JSON.parse(read('../src-tauri/tauri.conf.json')); const ios = JSON.parse(read('../src-tauri/tauri.ios.conf.json')); const packageInfo = JSON.parse(read('../package.json')); assert.equal(packageInfo.version, config.version); assert.equal( read('../src-tauri/Cargo.toml').match(/^version = "([^"]+)"$/m)?.[1], config.version, ); assert.equal( read('../src-tauri/Cargo.lock').match( /name = "kanbangit-native"\nversion = "([^"]+)"/, )?.[1], config.version, ); assert.equal( read('../src-tauri/gen/apple/project.yml').match( /MARKETING_VERSION: "([^"]+)"/, )?.[1], config.version, ); assert.equal( read('../src-tauri/gen/apple/project.yml').match( /CURRENT_PROJECT_VERSION: "([^"]+)"/, )?.[1], ios.bundle.iOS.bundleVersion, ); const project = read( '../src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj', ); for (const [, version] of project.matchAll( /MARKETING_VERSION = ([^;]+);/g, )) { assert.equal(version, config.version); } for (const [, version] of project.matchAll( /CURRENT_PROJECT_VERSION = ([^;]+);/g, )) { assert.equal(version, ios.bundle.iOS.bundleVersion); } });