import { createHash } from "node:crypto"; import { execFile as execFileCallback } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; const execFile = promisify(execFileCallback); const args = process.argv.slice(2).filter((arg) => arg !== "--"); const bucket = args .find((arg) => arg.startsWith("--bucket=")) ?.slice("--bucket=".length); if (!bucket || args.some((arg) => arg === "--bucket=")) throw new Error( "Usage: node scripts/publish-release-artifacts.mjs --bucket=", ); if (args.some((arg) => !arg.startsWith("--bucket="))) throw new Error("Unexpected publish argument"); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); const identicalIdentity = (left, right) => left?.version === right.version && left?.sourceRevision === right.sourceRevision && left?.artifactDigest === right.artifactDigest; const releaseKey = (digest, path) => `releases/${digest}/${path}`; const identityKey = (version) => `releases/versions/${encodeURIComponent(version)}.json`; const validateIdentity = (value) => { if ( !value || typeof value.version !== "string" || typeof value.sourceRevision !== "string" || !/^[a-f0-9]{64}$/.test(value.artifactDigest) ) throw new Error("Stored release identity is invalid"); return value; }; const temporary = await mkdtemp(join(tmpdir(), "flarebot-release-r2-")); let serial = 0; const objectGet = async (key) => { const output = join(temporary, String(serial++)); try { await execFile("pnpm", [ "exec", "wrangler", "r2", "object", "get", `${bucket}/${key}`, "--remote", `--file=${output}`, ]); return readFile(output); } catch (error) { const text = `${error.stdout ?? ""}\n${error.stderr ?? ""}\n${error.message ?? ""}`; if (/NoSuchKey|not found|does not exist/i.test(text)) return null; throw new Error(`Cannot read private release object ${key}: ${text}`); } }; const objectPut = async (key, file) => { await execFile("pnpm", [ "exec", "wrangler", "r2", "object", "put", `${bucket}/${key}`, "--remote", `--file=${file}`, ]); }; const verify = (key, bytes, expected) => { if ( !bytes || bytes.length !== expected.size || sha256(bytes) !== expected.sha256 ) throw new Error(`R2 verification failed for ${key}`); }; const ensureImmutable = async (key, file, expected) => { let stored = await objectGet(key); if (stored) { verify(key, stored, expected); return; } await objectPut(key, file); stored = await objectGet(key); verify(key, stored, expected); }; try { const manifestPath = "dist/release/manifest.json"; const manifestBytes = await readFile(manifestPath); const manifest = JSON.parse(manifestBytes); const identity = validateIdentity({ version: manifest.release, sourceRevision: manifest.sourceRevision, artifactDigest: sha256(manifestBytes), }); if (manifest.sourceDirty) throw new Error("Refusing to publish a dirty customer artifact"); const versionObject = identityKey(identity.version); const publishedForVersion = await objectGet(versionObject); if ( publishedForVersion && !identicalIdentity( validateIdentity(JSON.parse(publishedForVersion)), identity, ) ) throw new Error( "A human release version is already published with a different digest", ); const publishedLatest = await objectGet("releases/latest.json"); if ( publishedLatest && validateIdentity(JSON.parse(publishedLatest)).version === identity.version && !identicalIdentity(validateIdentity(JSON.parse(publishedLatest)), identity) ) throw new Error( "The currently published release reuses this human version with a different digest", ); await ensureImmutable( releaseKey(identity.artifactDigest, "manifest.json"), manifestPath, { size: manifestBytes.length, sha256: identity.artifactDigest, }, ); for (const file of manifest.files) { const bytes = await readFile(`dist/release/${file.path}`); verify(file.path, bytes, file); await ensureImmutable( releaseKey(identity.artifactDigest, file.path), `dist/release/${file.path}`, file, ); } const identityBytes = Buffer.from(`${JSON.stringify(identity)}\n`); const pointer = join(temporary, "identity.json"); await writeFile(pointer, identityBytes); const pointerExpected = { size: identityBytes.length, sha256: sha256(identityBytes), }; // R2 has no conditional write through this CLI, so publication must be // serialized by the operator: one publisher per human version at a time. // Every write below is re-read and verified, so a concurrent writer turns // into a loud verification failure instead of a silent overwrite. The only // residue a precise same-version interleave can leave is a version record // and latest pointer that disagree; installations consume exact pinned // identities, and the next publication reconciles the pointers. await objectPut(versionObject, pointer); verify(versionObject, await objectGet(versionObject), pointerExpected); // Never rewind another publisher's latest pointer. Re-read it and advance // only when it still contains what was observed before the upload above. const latestBeforeWrite = await objectGet("releases/latest.json"); const latestChanged = (publishedLatest === null) !== (latestBeforeWrite === null) || (publishedLatest !== null && latestBeforeWrite !== null && !latestBeforeWrite.equals(publishedLatest)); if (latestChanged) throw new Error( "The published latest release changed during publication; reconcile explicitly instead of overwriting it", ); if ( !latestBeforeWrite || !identicalIdentity( validateIdentity(JSON.parse(latestBeforeWrite)), identity, ) ) { await objectPut("releases/latest.json", pointer); verify( "releases/latest.json", await objectGet("releases/latest.json"), pointerExpected, ); } // Final gate before reporting success: re-read both pointers so an // interleaved writer is reported instead of acknowledged. verify(versionObject, await objectGet(versionObject), pointerExpected); const settledLatest = await objectGet("releases/latest.json"); const latestSettledWell = settledLatest !== null && (identicalIdentity( validateIdentity(JSON.parse(settledLatest)), identity, ) || (publishedLatest !== null && settledLatest.equals(publishedLatest))); if (!latestSettledWell) throw new Error( "The published latest release changed during publication; reconcile explicitly instead of overwriting it", ); console.log( `Verified immutable R2 release ${identity.version} (${identity.artifactDigest})`, ); } finally { await rm(temporary, { recursive: true, force: true }); }