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(), }); 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; } }