CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 11s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 0s
463 lines
16 KiB
JavaScript
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 update the feed 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}`;
|
|
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
let response;
|
|
try {
|
|
response = await fetchPublic(`${prefix}/${name}`, {
|
|
redirect: 'error',
|
|
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 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 (!requiresExistingObjectCheck(error)) {
|
|
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/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) {
|
|
putImmutable(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',
|
|
);
|
|
putImmutable('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',
|
|
);
|
|
putImmutable('update.json', latest, 'application/json');
|
|
await verifyPublic('update.json', latest);
|
|
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
|
const latestKey = `${latestPrefix}/latest.json`;
|
|
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 });
|
|
}
|
|
}
|
|
|
|
export async function resumeMacos(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const metadata = options.metadata ?? releaseMetadata(env);
|
|
const storage = storageConfiguration(env);
|
|
const feedBase = options.feedBase ?? updateSource.baseUrl;
|
|
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
|
|
throw new Error(
|
|
'Release storage does not match the update source bundled in the app.',
|
|
);
|
|
}
|
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-resume-'));
|
|
const fetchPublic = options.fetchPublic ?? fetch;
|
|
const prefix = `${feedBase}/${metadata.target}/${metadata.version}`;
|
|
try {
|
|
for (const name of [
|
|
metadata.filename,
|
|
`${metadata.filename}.sha256`,
|
|
metadata.updaterFilename,
|
|
`${metadata.updaterFilename}.sha256`,
|
|
`${metadata.updaterFilename}.sig`,
|
|
]) {
|
|
const response = await fetchPublic(`${prefix}/${name}`, {
|
|
redirect: 'error',
|
|
signal: AbortSignal.timeout(60000),
|
|
});
|
|
if (!response.ok) {
|
|
await response.body?.cancel();
|
|
throw new Error(
|
|
`Cannot resume incomplete release: ${name} HTTP ${response.status}.`,
|
|
);
|
|
}
|
|
writeFileSync(
|
|
join(directory, name),
|
|
Buffer.from(await response.arrayBuffer()),
|
|
);
|
|
}
|
|
// Reuse signature, checksum, immutable content and feed concurrency checks.
|
|
return await publishMacos({ ...options, env, metadata, directory });
|
|
} finally {
|
|
rmSync(directory, { 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] === 'resume' ||
|
|
(process.argv[2] === 'upload' && process.env.RELEASE_DIRECTORY)
|
|
) {
|
|
const publish =
|
|
process.argv[2] === 'resume' ? resumeMacos : publishMacos;
|
|
const url = await publish({
|
|
directory: process.env.RELEASE_DIRECTORY,
|
|
metadata: releaseMetadata(
|
|
process.env,
|
|
process.argv[2] === 'resume' &&
|
|
process.env.RELEASE_METADATA_DIRECTORY
|
|
? resolve(process.env.RELEASE_METADATA_DIRECTORY)
|
|
: undefined,
|
|
),
|
|
});
|
|
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, upload or resume; upload requires RELEASE_DIRECTORY.',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|