Files
KanbanGit/native/publish-macos.mjs
T
Twyco 4ee047311f
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m32s
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
fix(KAN-65): allow rebuilding unpublished macOS releases
2026-09-28 07:51:56 +02:00

463 lines
16 KiB
JavaScript

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 { setTimeout } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
import { execute, releaseMetadata } from './macos-ci.mjs';
import {
compareVersions,
updateManifest,
updateSource,
verifyUpdateSignature,
} from './update-manifest.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 function conditionalEtag(etag) {
const value =
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
? etag.slice(1, -1)
: etag;
if (
typeof value !== 'string' ||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
) {
throw new Error(
'Cannot atomically replace an object without a valid S3 ETag.',
);
}
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
return value;
}
function requiresExistingObjectCheck(error) {
// AWS CLI can crash while formatting Hetzner's empty error Message.
// This only permits a readback; content checks and conditional writes still apply.
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
error.message,
);
}
export async function publishMacos({
env = process.env,
directory,
metadata,
run = execute,
fetchPublic = fetch,
wait = setTimeout,
publicKey = updateSource.publicKey,
feedBase = updateSource.baseUrl,
} = {}) {
const storage = storageConfiguration(env);
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
throw new Error(
'Release storage does not match the update source bundled in the app.',
);
}
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 updater = readFileSync(join(directory, metadata.updaterFilename));
const signature = readFileSync(
join(directory, `${metadata.updaterFilename}.sig`),
'utf8',
);
verifyUpdateSignature(updater, signature, publicKey);
const updateChecksum = readFileSync(
join(directory, `${metadata.updaterFilename}.sha256`),
);
if (
updateChecksum.toString() !==
`${sha256(updater)} ${metadata.updaterFilename}\n`
) {
throw new Error('Updater 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) {
try {
return run('aws', [
...common,
operation,
'--bucket',
storage.bucket,
...args,
]);
} catch (error) {
const keyIndex = args.indexOf('--key');
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
cause: error,
});
}
}
const versioningOutput = s3('get-bucket-versioning', [
'--output',
'json',
]).trim();
let versioning;
try {
// AWS CLI prints nothing for a successful empty VersioningConfiguration.
versioning = JSON.parse(versioningOutput || '{}');
} catch {
throw new Error('Invalid JSON from S3 bucket versioning check.');
}
if (
!versioning ||
typeof versioning !== 'object' ||
Array.isArray(versioning)
) {
throw new Error(
'Invalid S3 bucket versioning response: expected an object.',
);
}
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}`;
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
async function verifyPublic(name, contents, prefix = publicPrefix) {
for (let attempt = 1; attempt <= 5; attempt++) {
let response;
try {
response = await fetchPublic(`${prefix}/${name}`, {
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(60000),
});
} catch (error) {
if (attempt === 5) {
throw new Error(
`Public download request failed for ${name}: ${error.message}`,
);
}
await wait(2000);
continue;
}
if (response.ok) {
if (
sha256(Buffer.from(await response.arrayBuffer())) !==
sha256(contents)
) {
throw new Error(
`Public download verification failed for ${name}: SHA-256 mismatch.`,
);
}
return;
}
await response.body?.cancel();
const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes(
response.status,
);
if (!retryable || attempt === 5) {
throw new Error(
`Public download verification failed for ${name}: HTTP ${response.status}.`,
);
}
await wait(2000);
}
}
function assertUnpublishedRelease() {
const file = join(staging, 'published-latest.json');
try {
s3('get-object', ['--key', latestKey, file]);
} catch (error) {
if (/\(NoSuchKey\)/.test(error.message)) {
return;
}
if (requiresExistingObjectCheck(error)) {
// A broken CLI error message cannot distinguish missing from denied.
// Only an authenticated, explicitly empty listing proves absence.
const listing = JSON.parse(
s3('list-objects-v2', [
'--prefix',
latestKey,
'--max-keys',
'1',
'--output',
'json',
]),
);
if (
listing?.KeyCount === 0 &&
listing.IsTruncated === false &&
Array.isArray(listing.Contents ?? []) &&
(listing.Contents ?? []).length === 0
) {
return;
}
}
throw error;
}
const published = JSON.parse(readFileSync(file, 'utf8'));
if (compareVersions(metadata.version, published.version) <= 0) {
throw new Error(
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
);
}
}
// The macos-release concurrency group serializes the entire publication.
// Never run independent publishers against the same release destination.
function putReleaseFile(name, contents, contentType) {
const file = join(staging, name);
writeFileSync(file, contents);
function put(condition) {
s3('put-object', [
'--key',
`${keyPrefix}/${name}`,
'--body',
file,
...condition,
'--content-type',
contentType,
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(contents).digest('base64'),
'--metadata',
`sha256=${sha256(contents)}`,
]);
}
try {
put(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const existing = join(staging, `existing-${name}`);
const response = JSON.parse(
s3('get-object', [
'--key',
`${keyPrefix}/${name}`,
'--output',
'json',
existing,
]),
);
if (sha256(readFileSync(existing)) !== sha256(contents)) {
assertUnpublishedRelease();
put(['--if-match', conditionalEtag(response.ETag)]);
}
}
}
try {
const files = [
[metadata.filename, archive, 'application/x-apple-diskimage'],
[metadata.updaterFilename, updater, 'application/gzip'],
[
`${metadata.updaterFilename}.sig`,
Buffer.from(signature),
'text/plain; charset=utf-8',
],
[
`${metadata.updaterFilename}.sha256`,
updateChecksum,
'text/plain; charset=utf-8',
],
[
`${metadata.filename}.sha256`,
checksumFile,
'text/plain; charset=utf-8',
],
];
for (const [name, contents, type] of files) {
putReleaseFile(name, contents, type);
await verifyPublic(name, contents);
}
// Completion metadata is written only after every public artifact matches.
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',
);
putReleaseFile('release.json', manifest, 'application/json');
await verifyPublic('release.json', manifest);
const latest = Buffer.from(
JSON.stringify(
updateManifest(
metadata,
publicPrefix,
signature,
metadata.notes,
),
null,
2,
) + '\n',
);
putReleaseFile('update.json', latest, 'application/json');
await verifyPublic('update.json', latest);
const latestFile = join(staging, 'latest.json');
writeFileSync(latestFile, latest);
function publishLatest(condition) {
s3('put-object', [
'--key',
latestKey,
'--body',
latestFile,
...condition,
'--content-type',
'application/json',
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(latest).digest('base64'),
]);
}
try {
publishLatest(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-latest.json');
const response = JSON.parse(
s3('get-object', [
'--key',
latestKey,
'--output',
'json',
previousFile,
]),
);
const previousBytes = readFileSync(previousFile);
const previous = JSON.parse(previousBytes);
const comparison = compareVersions(
metadata.version,
previous.version,
);
if (comparison < 0) {
throw new Error('Refusing to downgrade the update feed.');
}
if (comparison === 0) {
if (!previousBytes.equals(latest)) {
throw new Error(
'Published update metadata differs; bump the app version.',
);
}
} else {
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
'latest.json',
latest,
`${storage.publicBase}/${latestPrefix}`,
);
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;
}
}