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'; import { createDiskImage } from './macos-dmg.mjs'; 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 verifySignedMacosApp({ app, version, step }) { 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 bundledVersion = step('Checking bundled release version', 'plutil', [ '-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', join(app, 'Contents/Info.plist'), ]); if (bundledVersion.trim() !== version) { throw new Error( 'The built app version does not match the release version.', ); } return cdhash; } 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.'); } if (!env.TAURI_SIGNING_PRIVATE_KEY?.trim()) { throw new Error( 'Set TAURI_SIGNING_PRIVATE_KEY to the permanent updater signing key.', ); } 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.'); } const cdhash = verifySignedMacosApp({ app, version: config.version, step }); 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}.dmg`; const archive = join(staging, name); createDiskImage({ app, archive, identity, run: (command, args) => step('Packaging notarized download', command, args), }); const checksum = createHash('sha256') .update(readFileSync(archive)) .digest('hex'); writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`); const updaterName = `KanbanGit-${config.version}-${target}.app.tar.gz`; const updaterArchive = join(staging, updaterName); step('Packaging updater archive', 'tar', [ '-czf', updaterArchive, '-C', dirname(app), 'KanbanGit.app', ]); step('Signing updater archive', 'pnpm', [ 'exec', 'tauri', 'signer', 'sign', updaterArchive, ]); const updaterChecksum = createHash('sha256') .update(readFileSync(updaterArchive)) .digest('hex'); writeFileSync( `${updaterArchive}.sha256`, `${updaterChecksum} ${updaterName}\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; } }