CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Successful in 7m2s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m35s
479 lines
14 KiB
JavaScript
479 lines
14 KiB
JavaScript
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;
|
|
}
|
|
}
|