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(allowSameVersion = false) { 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')); const comparison = compareVersions(metadata.version, published.version); if (allowSameVersion && comparison < 0) { throw new Error('Refusing to downgrade the stable download.'); } if (comparison < 0 || (!allowSameVersion && comparison === 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); // An older release must not populate an absent download alias either. assertUnpublishedRelease(true); const downloadName = 'KanbanGit.dmg'; const downloadKey = `${latestPrefix}/${downloadName}`; const downloadFile = join(staging, downloadName); writeFileSync(downloadFile, archive); function publishDownload(condition) { s3('put-object', [ '--key', downloadKey, '--body', downloadFile, ...condition, '--content-type', 'application/x-apple-diskimage', '--cache-control', 'no-cache, max-age=0, must-revalidate', '--content-md5', createHash('md5').update(archive).digest('base64'), '--metadata', `sha256=${checksum}`, ]); } try { publishDownload(['--if-none-match', '*']); } catch (error) { if (!requiresExistingObjectCheck(error)) { throw error; } const previousFile = join(staging, 'previous-download.dmg'); const response = JSON.parse( s3('get-object', [ '--key', downloadKey, '--output', 'json', previousFile, ]), ); if (sha256(readFileSync(previousFile)) !== checksum) { assertUnpublishedRelease(); publishDownload(['--if-match', conditionalEtag(response.ETag)]); } } await verifyPublic( downloadName, archive, `${storage.publicBase}/${latestPrefix}`, ); 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; } }