import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { appendFileSync, copyFileSync, readlinkSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import process from 'node:process'; import { updateSource } from './update-manifest.mjs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { appleTeamId, bundleIdentifier, verifySignedMacosApp, } from './release-macos.mjs'; const nativeDirectory = dirname(fileURLToPath(import.meta.url)); export function releaseMetadata( env = process.env, directory = nativeDirectory, ) { const config = JSON.parse( readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'), ); const nativePackage = JSON.parse( readFileSync(join(directory, 'package.json'), 'utf8'), ); const packageVersion = nativePackage.version; const cargoVersion = readFileSync( join(directory, 'src-tauri/Cargo.toml'), 'utf8', ) .match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1] .match(/^version\s*=\s*"([^"]+)"/m)?.[1]; if ( !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version) || config.version !== packageVersion || config.version !== cargoVersion ) { throw new Error( 'Tauri, package.json and Cargo.toml must have the same stable release version.', ); } if (env.RELEASE_TAG !== `macos-v${config.version}`) { throw new Error( 'Release tag must be macos-vMAJOR.MINOR.PATCH and match the app version.', ); } if ( config.identifier !== bundleIdentifier || config.productName !== 'KanbanGit' ) { throw new Error( 'The macOS product name and bundle identifier must stay unchanged.', ); } if (config.plugins?.updater?.pubkey !== updateSource.publicKey) { throw new Error( 'The app updater key must match the permanent release key.', ); } if ( !/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '') ) { throw new Error( 'Set a numeric macOS bundleVersion for the About build number.', ); } if (!nativePackage.releaseNotes?.trim()) { throw new Error( 'Add releaseNotes to native/package.json before publishing.', ); } const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin'; const arch = { 'aarch64-apple-darwin': 'arm64', 'x86_64-apple-darwin': 'x86_64', }[target]; if (!arch) { throw new Error( 'MACOS_RELEASE_TARGET must select Apple Silicon or Intel macOS.', ); } const identity = env.APPLE_SIGNING_IDENTITY; if ( !identity?.startsWith('Developer ID Application: ') || !identity.endsWith(` (${appleTeamId})`) || /[\r\n"]/.test(identity) ) { throw new Error( `A Developer ID Application identity for ${appleTeamId} is required.`, ); } return { version: config.version, notes: nativePackage.releaseNotes, target, arch, filename: `KanbanGit-${config.version}-${target}.dmg`, updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`, }; } export function execute(command, args) { const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, }); if (result.error || result.status !== 0) { throw new Error( `${command} failed (exit ${result.status ?? 'unknown'}). ${result.stderr || ''}`, ); } return result.stdout?.trim() || result.stderr?.trim() || ''; } export function verifyNotarizedApp(app, metadata, run = execute) { verifySignedMacosApp({ app, version: metadata.version, step: (_label, command, args) => run(command, args), }); const arch = run('lipo', [ '-archs', join(app, 'Contents/MacOS/kanbangit-native'), ]).trim(); if (arch !== metadata.arch) { throw new Error( 'The app architecture does not match its download path.', ); } run('xcrun', ['stapler', 'validate', app]); run('spctl', ['--assess', '--type', 'execute', '--verbose=2', app]); } export function packageNotarizedApp({ env = process.env, directory = nativeDirectory, run = execute, } = {}) { const metadata = releaseMetadata(env, directory); const root = join(directory, 'src-tauri/target'); const app = join( root, metadata.target, 'release/bundle/macos/KanbanGit.app', ); verifyNotarizedApp(app, metadata, run); const releaseRoot = join(root, 'macos-release'); const destination = join( releaseRoot, `${metadata.version}-${metadata.target}`, ); if (existsSync(destination)) { throw new Error( 'Release output already exists; start from a clean checkout.', ); } mkdirSync(releaseRoot, { recursive: true }); const staging = mkdtempSync(join(releaseRoot, '.pending-')); try { const archive = join(staging, metadata.filename); const bundle = join(root, metadata.target, 'release/bundle'); copyFileSync( join( bundle, 'dmg', `KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`, ), archive, ); const mounted = join(staging, 'mounted'); mkdirSync(mounted); run('hdiutil', ['verify', archive]); run('hdiutil', [ 'attach', archive, '-readonly', '-nobrowse', '-mountpoint', mounted, ]); try { if ( readlinkSync(join(mounted, 'Applications')) !== '/Applications' ) { throw new Error( 'DMG must contain the Applications installation link.', ); } verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run); } finally { run('hdiutil', ['detach', mounted]); } rmSync(mounted, { recursive: true, force: true }); const updateArchive = join(staging, metadata.updaterFilename); copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive); copyFileSync( join(bundle, 'macos/KanbanGit.app.tar.gz.sig'), `${updateArchive}.sig`, ); if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) { throw new Error('The updater signature is missing.'); } const extracted = join(staging, 'extracted'); mkdirSync(extracted); run('tar', ['-xzf', updateArchive, '-C', extracted]); verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run); rmSync(extracted, { recursive: true, force: true }); const checksum = createHash('sha256') .update(readFileSync(archive)) .digest('hex'); for (const name of [metadata.filename, metadata.updaterFilename]) { const hash = createHash('sha256') .update(readFileSync(join(staging, name))) .digest('hex'); writeFileSync( join(staging, `${name}.sha256`), `${hash} ${name}\n`, ); } renameSync(staging, destination); return { ...metadata, directory: destination, checksum }; } finally { rmSync(staging, { recursive: true, force: true }); } } if ( process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href ) { try { let output; if (process.argv[2] === 'metadata') { output = releaseMetadata(); } else if (process.argv[2] === 'package') { output = packageNotarizedApp(); } else { throw new Error( 'Use metadata or package. Building and notarizing are handled by tauri-action.', ); } if (process.env.GITHUB_OUTPUT) { appendFileSync( process.env.GITHUB_OUTPUT, Object.entries(output) .filter(([key]) => key !== 'notes') .map(([key, value]) => `${key}=${value}\n`) .join(''), ); } console.log(JSON.stringify(output)); } catch (error) { console.error(error.message); process.exitCode = 1; } }