CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m28s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m15s
267 lines
8.8 KiB
JavaScript
267 lines
8.8 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';
|
|
|
|
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,
|
|
wait = setTimeout,
|
|
} = {}) {
|
|
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 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) {
|
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
let response;
|
|
try {
|
|
response = await fetchPublic(`${publicPrefix}/${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 (!/\(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;
|
|
}
|
|
}
|