Merge pull request 'KAN-56: Sign and notarize macOS releases' (#152) from feature/KAN-56-signed-macos-release into main
Reviewed-on: #152
This commit was merged in pull request #152.
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"build": "tauri build",
|
"build": "tauri build",
|
||||||
"build:macos": "tauri build --debug --bundles app",
|
"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",
|
"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",
|
"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",
|
"dev:android": "tauri android dev",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user