CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m1s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m29s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 1m34s
624 lines
20 KiB
JavaScript
624 lines
20 KiB
JavaScript
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);
|
|
}
|
|
});
|