feat(KAN-58): add macOS actions and Hetzner release pipeline
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 57s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 57s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
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 {
|
||||
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 packageVersion = JSON.parse(
|
||||
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||
).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.',
|
||||
);
|
||||
}
|
||||
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,
|
||||
target,
|
||||
arch,
|
||||
filename: `KanbanGit-${config.version}-${target}.zip`,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
run('ditto', [
|
||||
'-c',
|
||||
'-k',
|
||||
'--sequesterRsrc',
|
||||
'--keepParent',
|
||||
app,
|
||||
archive,
|
||||
]);
|
||||
const checksum = createHash('sha256')
|
||||
.update(readFileSync(archive))
|
||||
.digest('hex');
|
||||
writeFileSync(
|
||||
`${archive}.sha256`,
|
||||
`${checksum} ${metadata.filename}\n`,
|
||||
);
|
||||
const extracted = join(staging, 'extracted');
|
||||
run('ditto', ['-x', '-k', archive, extracted]);
|
||||
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
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)
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(''),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify(output));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||
|
||||
export function storageConfiguration(env = process.env) {
|
||||
const region = env.S3_REGION;
|
||||
const bucket = env.S3_BUCKET;
|
||||
const prefix = env.S3_PREFIX || 'releases/macos';
|
||||
if (!['nbg1', 'fsn1', 'hel1'].includes(region)) {
|
||||
throw new Error('S3_REGION must be nbg1, fsn1 or hel1.');
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket || '')) {
|
||||
throw new Error('Set S3_BUCKET to the Hetzner release bucket name.');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(prefix)) {
|
||||
throw new Error(
|
||||
'S3_PREFIX must contain plain path segments without leading or trailing slashes.',
|
||||
);
|
||||
}
|
||||
const endpoint = `https://${region}.your-objectstorage.com`;
|
||||
const publicBase =
|
||||
env.S3_PUBLIC_BASE_URL ||
|
||||
`https://${bucket}.${region}.your-objectstorage.com`;
|
||||
const url = new URL(publicBase);
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
/[\s]/.test(publicBase)
|
||||
) {
|
||||
throw new Error(
|
||||
'S3_PUBLIC_BASE_URL must be a public HTTPS URL without credentials or query parameters.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
region,
|
||||
bucket,
|
||||
prefix,
|
||||
endpoint,
|
||||
publicBase: publicBase.replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
export async function publishMacos({
|
||||
env = process.env,
|
||||
directory,
|
||||
metadata,
|
||||
run = execute,
|
||||
fetchPublic = fetch,
|
||||
} = {}) {
|
||||
const storage = storageConfiguration(env);
|
||||
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
||||
throw new Error(
|
||||
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
||||
);
|
||||
}
|
||||
const archive = readFileSync(join(directory, metadata.filename));
|
||||
const checksum = sha256(archive);
|
||||
const checksumFile = readFileSync(
|
||||
join(directory, `${metadata.filename}.sha256`),
|
||||
);
|
||||
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
||||
throw new Error('Release archive does not match its SHA-256 file.');
|
||||
}
|
||||
const common = [
|
||||
'--endpoint-url',
|
||||
storage.endpoint,
|
||||
'--region',
|
||||
storage.region,
|
||||
'--no-cli-pager',
|
||||
's3api',
|
||||
];
|
||||
function s3(operation, args) {
|
||||
return run('aws', [
|
||||
...common,
|
||||
operation,
|
||||
'--bucket',
|
||||
storage.bucket,
|
||||
...args,
|
||||
]);
|
||||
}
|
||||
const versioning = JSON.parse(s3('get-bucket-versioning', []));
|
||||
if (versioning.Status) {
|
||||
throw new Error(
|
||||
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
|
||||
);
|
||||
}
|
||||
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
||||
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
|
||||
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
|
||||
async function verifyPublic(name, contents) {
|
||||
const response = await fetchPublic(`${publicPrefix}/${name}`, {
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
if (
|
||||
!response.ok ||
|
||||
sha256(Buffer.from(await response.arrayBuffer())) !==
|
||||
sha256(contents)
|
||||
) {
|
||||
throw new Error(`Public download verification failed for ${name}.`);
|
||||
}
|
||||
}
|
||||
function putImmutable(name, contents, contentType) {
|
||||
const file = join(staging, name);
|
||||
writeFileSync(file, contents);
|
||||
try {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
`${keyPrefix}/${name}`,
|
||||
'--body',
|
||||
file,
|
||||
'--if-none-match',
|
||||
'*',
|
||||
'--content-type',
|
||||
contentType,
|
||||
'--cache-control',
|
||||
'public, max-age=31536000, immutable',
|
||||
'--content-md5',
|
||||
createHash('md5').update(contents).digest('base64'),
|
||||
'--metadata',
|
||||
`sha256=${sha256(contents)}`,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = join(staging, `existing-${name}`);
|
||||
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
|
||||
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||
throw new Error(
|
||||
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const files = [
|
||||
[metadata.filename, archive, 'application/zip'],
|
||||
[
|
||||
`${metadata.filename}.sha256`,
|
||||
checksumFile,
|
||||
'text/plain; charset=utf-8',
|
||||
],
|
||||
];
|
||||
for (const [name, contents, type] of files) {
|
||||
putImmutable(name, contents, type);
|
||||
await verifyPublic(name, contents);
|
||||
}
|
||||
// This completion record is written only after both public downloads match.
|
||||
const manifest = Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
version: metadata.version,
|
||||
target: metadata.target,
|
||||
url: `${publicPrefix}/${metadata.filename}`,
|
||||
sha256: checksum,
|
||||
checksumUrl: `${publicPrefix}/${metadata.filename}.sha256`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
putImmutable('release.json', manifest, 'application/json');
|
||||
await verifyPublic('release.json', manifest);
|
||||
return `${publicPrefix}/${metadata.filename}`;
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
try {
|
||||
if (process.argv[2] === 'config') {
|
||||
console.log(JSON.stringify(storageConfiguration()));
|
||||
} else if (
|
||||
process.argv[2] === 'upload' &&
|
||||
process.env.RELEASE_DIRECTORY
|
||||
) {
|
||||
const url = await publishMacos({
|
||||
directory: process.env.RELEASE_DIRECTORY,
|
||||
metadata: releaseMetadata(),
|
||||
});
|
||||
console.log(`Verified public release: ${url}`);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`Download: [KanbanGit](${url})\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Use config or upload; upload requires RELEASE_DIRECTORY.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+49
-43
@@ -37,6 +37,54 @@ function execute(command, args, options) {
|
||||
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,
|
||||
@@ -213,49 +261,7 @@ export function releaseMacos({
|
||||
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.',
|
||||
);
|
||||
}
|
||||
const cdhash = verifySignedMacosApp({ app, version: config.version, step });
|
||||
|
||||
mkdirSync(releaseDirectory, { recursive: true });
|
||||
const staging = mkdtempSync(join(releaseDirectory, '.pending-'));
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
packageNotarizedApp,
|
||||
releaseMetadata,
|
||||
verifyNotarizedApp,
|
||||
} from '../macos-ci.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-ci-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
mkdirSync(join(directory, 'src-tauri'));
|
||||
const config = {
|
||||
version: '1.2.3',
|
||||
productName: 'KanbanGit',
|
||||
identifier: 'de.kanbangit.desktop',
|
||||
};
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.conf.json'),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'package.json'),
|
||||
JSON.stringify({ version: '1.2.3' }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'[package]\nname = "kanbangit-native"\nversion = "1.2.3"\n[dependencies]\nother = "2"\n',
|
||||
);
|
||||
const env = {
|
||||
RELEASE_TAG: 'macos-v1.2.3',
|
||||
APPLE_SIGNING_IDENTITY:
|
||||
'Developer ID Application: Example (ZZX2KZGTHU)',
|
||||
};
|
||||
const calls = [];
|
||||
const run = (command, args) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'codesign' && args.includes('--display')) {
|
||||
return 'CodeDirectory flags=0x10000(runtime)\nCDHash=abcdef123456';
|
||||
}
|
||||
if (command === 'plutil') {
|
||||
return '1.2.3';
|
||||
}
|
||||
if (command === 'lipo') {
|
||||
return 'arm64';
|
||||
}
|
||||
if (command === 'ditto' && args.includes('-c')) {
|
||||
writeFileSync(args.at(-1), 'notarized archive');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return { directory, env, config, run, calls };
|
||||
}
|
||||
|
||||
test('release metadata binds tag, all app versions, identity and architecture', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(releaseMetadata(f.env, f.directory), {
|
||||
version: '1.2.3',
|
||||
target: 'aarch64-apple-darwin',
|
||||
arch: 'arm64',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
});
|
||||
assert.equal(
|
||||
releaseMetadata(
|
||||
{ ...f.env, MACOS_RELEASE_TARGET: 'x86_64-apple-darwin' },
|
||||
f.directory,
|
||||
).arch,
|
||||
'x86_64',
|
||||
);
|
||||
});
|
||||
|
||||
for (const env of [
|
||||
{ RELEASE_TAG: 'v1.2.3' },
|
||||
{ RELEASE_TAG: 'macos-v1.2.4' },
|
||||
{ RELEASE_TAG: 'macos-v1.2.3; echo bad' },
|
||||
{ MACOS_RELEASE_TARGET: 'x86_64-unknown-linux-gnu' },
|
||||
{ APPLE_SIGNING_IDENTITY: '-' },
|
||||
{ APPLE_SIGNING_IDENTITY: 'Apple Development: Example (ZZX2KZGTHU)' },
|
||||
{
|
||||
APPLE_SIGNING_IDENTITY:
|
||||
'Developer ID Application: Example (OLDTEAM123)',
|
||||
},
|
||||
]) {
|
||||
test(`rejects invalid release inputs ${JSON.stringify(env)}`, (t) => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => releaseMetadata({ ...f.env, ...env }, f.directory));
|
||||
});
|
||||
}
|
||||
|
||||
for (const [file, contents] of [
|
||||
['package.json', '{"version":"1.2.4"}'],
|
||||
['src-tauri/Cargo.toml', '[package]\nversion = "1.2.4"'],
|
||||
['src-tauri/tauri.conf.json', '{"version":"01.2.3"}'],
|
||||
[
|
||||
'src-tauri/tauri.conf.json',
|
||||
'{"version":"1.2.3","identifier":"other","productName":"KanbanGit"}',
|
||||
],
|
||||
]) {
|
||||
test(`rejects inconsistent metadata in ${file}: ${contents}`, (t) => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.directory, file), contents);
|
||||
assert.throws(() => releaseMetadata(f.env, f.directory));
|
||||
});
|
||||
}
|
||||
|
||||
test('packages only a verified app and checks the extracted download again', (t) => {
|
||||
const f = fixture(t);
|
||||
const output = packageNotarizedApp(f);
|
||||
assert.equal(
|
||||
readFileSync(join(output.directory, output.filename), 'utf8'),
|
||||
'notarized archive',
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(output.directory, `${output.filename}.sha256`),
|
||||
'utf8',
|
||||
),
|
||||
`${output.checksum} ${output.filename}\n`,
|
||||
);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 2);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 2);
|
||||
assert.match(
|
||||
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
|
||||
/extracted\/KanbanGit\.app$/,
|
||||
);
|
||||
assert.throws(() => packageNotarizedApp(f), /already exists/);
|
||||
});
|
||||
|
||||
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) {
|
||||
test(`does not publish local output when ${failure} verification fails`, (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (command === failure) {
|
||||
throw new Error('verification failed');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
assert.throws(() => packageNotarizedApp(f), /verification failed/);
|
||||
assert.equal(
|
||||
existsSync(
|
||||
join(
|
||||
f.directory,
|
||||
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an app built for a different architecture', (t) => {
|
||||
const f = fixture(t);
|
||||
const metadata = releaseMetadata(f.env, f.directory);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyNotarizedApp('KanbanGit.app', metadata, (command, args) =>
|
||||
command === 'lipo' ? 'x86_64' : f.run(command, args),
|
||||
),
|
||||
/architecture/,
|
||||
);
|
||||
});
|
||||
|
||||
test('failed validation of the extracted app discards staged download', (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (command === 'spctl' && args.at(-1).includes('extracted/')) {
|
||||
throw new Error('roundtrip invalid');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
assert.throws(() => packageNotarizedApp(f), /roundtrip invalid/);
|
||||
assert.equal(
|
||||
existsSync(
|
||||
join(
|
||||
f.directory,
|
||||
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const metadata = {
|
||||
version: '1.2.3',
|
||||
target: 'aarch64-apple-darwin',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
};
|
||||
const archive = Buffer.from('verified release');
|
||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||
writeFileSync(join(directory, metadata.filename), archive);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.filename}.sha256`),
|
||||
`${checksum} ${metadata.filename}\n`,
|
||||
);
|
||||
const env = {
|
||||
S3_BUCKET: 'release-bucket',
|
||||
S3_REGION: 'nbg1',
|
||||
AWS_ACCESS_KEY_ID: 'test-key',
|
||||
AWS_SECRET_ACCESS_KEY: 'test-secret',
|
||||
};
|
||||
const objects = new Map();
|
||||
const events = [];
|
||||
const run = (command, args) => {
|
||||
assert.equal(command, 'aws');
|
||||
assert.equal(args[1], 'https://nbg1.your-objectstorage.com');
|
||||
const operation = args[args.indexOf('s3api') + 1];
|
||||
const value = (name) => args[args.indexOf(name) + 1];
|
||||
const key = value('--key');
|
||||
events.push({ operation, key });
|
||||
if (operation === 'get-bucket-versioning') {
|
||||
return '{}';
|
||||
}
|
||||
if (operation === 'put-object') {
|
||||
assert.equal(value('--if-none-match'), '*');
|
||||
if (objects.has(key)) {
|
||||
throw new Error(
|
||||
'aws failed: (PreconditionFailed) object exists',
|
||||
);
|
||||
}
|
||||
objects.set(key, readFileSync(value('--body')));
|
||||
return '{}';
|
||||
}
|
||||
if (operation === 'get-object') {
|
||||
writeFileSync(args.at(-1), objects.get(key));
|
||||
return '{}';
|
||||
}
|
||||
throw new Error(`Unexpected S3 operation: ${operation}`);
|
||||
};
|
||||
const fetchPublic = async (url, options) => {
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.equal(
|
||||
new URL(url).hostname,
|
||||
'release-bucket.nbg1.your-objectstorage.com',
|
||||
);
|
||||
const key = new URL(url).pathname.slice(1);
|
||||
events.push({ operation: 'public-get', key });
|
||||
return {
|
||||
ok: objects.has(key),
|
||||
arrayBuffer: async () => objects.get(key),
|
||||
};
|
||||
};
|
||||
return { directory, metadata, env, objects, events, run, fetchPublic };
|
||||
}
|
||||
|
||||
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
||||
const f = fixture(t);
|
||||
const url = await publishMacos(f);
|
||||
assert.equal(
|
||||
url,
|
||||
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.events.map((event) => event.operation),
|
||||
[
|
||||
'get-bucket-versioning',
|
||||
'put-object',
|
||||
'public-get',
|
||||
'put-object',
|
||||
'public-get',
|
||||
'put-object',
|
||||
'public-get',
|
||||
],
|
||||
);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||
assert(![...f.objects.keys()].some((key) => key.includes('latest')));
|
||||
assert.deepEqual(JSON.parse(f.objects.get(f.events.at(-1).key)), {
|
||||
version: '1.2.3',
|
||||
target: f.metadata.target,
|
||||
url,
|
||||
sha256: createHash('sha256').update('verified release').digest('hex'),
|
||||
checksumUrl: `${url}.sha256`,
|
||||
});
|
||||
});
|
||||
|
||||
test('retry accepts byte-identical objects without replacing them', async (t) => {
|
||||
const f = fixture(t);
|
||||
await publishMacos(f);
|
||||
const originals = [...f.objects.entries()];
|
||||
await publishMacos(f);
|
||||
assert.deepEqual([...f.objects.entries()], originals);
|
||||
assert.equal(
|
||||
f.events.filter((event) => event.operation === 'get-object').length,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('different existing release is never overwritten', async (t) => {
|
||||
const f = fixture(t);
|
||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||
f.objects.set(key, Buffer.from('previous version content'));
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
test('checksum mismatch aborts before contacting S3', async (t) => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.directory, f.metadata.filename), 'tampered');
|
||||
await assert.rejects(publishMacos(f), /SHA-256/);
|
||||
assert.equal(f.events.length, 0);
|
||||
});
|
||||
|
||||
for (const status of ['Enabled', 'Suspended']) {
|
||||
test(`rejects ${status} bucket versioning before any write`, async (t) => {
|
||||
const f = fixture(t);
|
||||
f.run = () => JSON.stringify({ Status: status });
|
||||
await assert.rejects(publishMacos(f), /unversioned/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const operation of ['get-bucket-versioning', 'put-object', 'get-object']) {
|
||||
test(`${operation} access or network failure cannot be mistaken for a missing object`, async (t) => {
|
||||
const f = fixture(t);
|
||||
await publishMacos(f);
|
||||
const run = f.run;
|
||||
f.events.length = 0;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes(operation)) {
|
||||
throw new Error('AccessDenied');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /AccessDenied/);
|
||||
assert(!f.events.some((event) => event.key.endsWith('/release.json')));
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['private', 'wrong-content', 'network']) {
|
||||
test(`public download ${failure} prevents completion record`, async (t) => {
|
||||
const f = fixture(t);
|
||||
f.fetchPublic = async () => {
|
||||
if (failure === 'network') {
|
||||
throw new Error('network failure');
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'private',
|
||||
arrayBuffer: async () => Buffer.from('wrong'),
|
||||
};
|
||||
};
|
||||
await assert.rejects(publishMacos(f));
|
||||
assert(
|
||||
![...f.objects.keys()].some((key) => key.endsWith('/release.json')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('a partial upload can resume with the same files', async (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (
|
||||
args.includes('put-object') &&
|
||||
args[args.indexOf('--key') + 1].endsWith('.sha256')
|
||||
) {
|
||||
throw new Error('upload interrupted');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /interrupted/);
|
||||
assert.equal(f.objects.size, 1);
|
||||
f.run = run;
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 3);
|
||||
});
|
||||
|
||||
for (const overrides of [
|
||||
{ S3_REGION: '' },
|
||||
{ S3_REGION: 'eu-central-1' },
|
||||
{ S3_BUCKET: '' },
|
||||
{ S3_BUCKET: 'other/../bucket' },
|
||||
{ S3_PREFIX: '../other' },
|
||||
{ S3_PUBLIC_BASE_URL: 'http://downloads.example.com' },
|
||||
{ S3_PUBLIC_BASE_URL: 'https://secret@downloads.example.com' },
|
||||
{ S3_PUBLIC_BASE_URL: 'https://downloads.example.com?token=secret' },
|
||||
{ AWS_ACCESS_KEY_ID: '' },
|
||||
{ AWS_SECRET_ACCESS_KEY: '' },
|
||||
]) {
|
||||
test(`invalid storage configuration is rejected before S3 access: ${JSON.stringify(overrides)}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
f.env = { ...f.env, ...overrides };
|
||||
await assert.rejects(publishMacos(f));
|
||||
assert.equal(f.events.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('derives Hetzner endpoints and supports an explicit public proxy URL', () => {
|
||||
for (const region of ['nbg1', 'fsn1', 'hel1']) {
|
||||
const config = storageConfiguration({
|
||||
S3_REGION: region,
|
||||
S3_BUCKET: 'release-bucket',
|
||||
S3_PUBLIC_BASE_URL: 'https://downloads.example.com/',
|
||||
});
|
||||
assert.equal(
|
||||
config.endpoint,
|
||||
`https://${region}.your-objectstorage.com`,
|
||||
);
|
||||
assert.equal(config.publicBase, 'https://downloads.example.com');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user