#!/usr/bin/env bun // Release candidates: every push to main is one. ci.yml runs the full CI, the acceptance matrix (Linux + Windows x // Node 20 / Node 22 / Bun) and the Linux / Windows installer builds with their self-tests and privacy scans; its // record job stores the result per commit as a DRAFT release `rc-` on git.crt.fyi: the installers, // SHA256SUMS, the six acceptance run files, the matrix and green.json (uploaded last: a candidate without green.json // is incomplete). A `v*` tag then only promotes (release.yml): no rebuild, no retest. // // bun tools/release-candidate.ts gate --sha [--wait ] // ci.yml's first job. When the commit differs from its parent only in version copies (tools/desktop-version.ts: // each changed copy must be exactly the parent's text with the new version written in), CHANGELOG.md and // docs/acceptance/*.json, and the parent is a green candidate, the full CI and the acceptance matrix of the parent // count for this commit (reuse=true; the parent's run is waited for while it is still running). Only the // installers, whose embedded version changes, are built again. Otherwise reuse=false: everything runs. // bun tools/release-candidate.ts record --sha --run --dir [--reused-from ] [--yes] // The record job: holds the installers and acceptance/*.json (with --reused-from, the parent's acceptance // files are fetched from its candidate). Writes green.json and uploads everything to the draft rc-. // bun tools/release-candidate.ts status --sha [--json] // green (with the record), red (with the failed run's link), pending or none. // bun tools/release-candidate.ts wait --sha [--timeout ] // Waits for green (heartbeat every 30 s); exits 1 at once when the commit is red or has no ci run. // bun tools/release-candidate.ts fetch --sha --dir [--only ] // Downloads a green candidate's files (checked against its SHA256SUMS). // bun tools/release-candidate.ts promote --tag v --sha [--wait ] [--yes] // release.yml: waits for the tagged commit's green candidate (fails fast when it is red or missing, with the // link), checks its version against the tag, and makes the release of the tag from the candidate's files // (tools/forgejo-release.ts upsert; a prerelease for versions with a "-", which is never pruned and never // prunes). Then the older release entries are deleted (stable versions only). // bun tools/release-candidate.ts prune [--keep ] [--yes] // Deletes candidate drafts except the newest n (default 12). // // Token: FORGEJO_TOKEN (ci.yml / release.yml: the RELEASE_TOKEN secret) or the git credential helper (on the Mac). import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; import { cliKey } from './cli-key'; import { COPIES, SEMVER } from './desktop-version'; import { api, assetUrl, BASE, candidateTag, deleteTest, downloadAsset, isCandidateTag, listReleases, type Release, REPO, shortNotes, SUMS, upsert } from './forgejo-release'; const ROOT = resolve(import.meta.dir, '..'); export const GREEN = 'green.json'; const HEARTBEAT_MS = 30_000; export interface GreenRecord { schema: 1; sha: string; version: string; cliKey: string; run: string; at: string; /** the parent whose full CI and acceptance matrix count for this version-only commit */ reusedFrom?: { sha: string; run: string }; files: Record; } // --------------------------------------------------------------------------------------------------------------------- // Pure parts (tests/release-candidate.test.ts) export interface Change { file: string; status: 'A' | 'M' | 'D' | string; before: string | null; after: string | null } /** Files a version-only commit may touch besides the version copies: the changelog and the macOS acceptance results. */ const FREE = [/^CHANGELOG\.md$/, /^docs\/acceptance\.md$/, /^docs\/acceptance\/[A-Za-z0-9_-]+\.json$/]; /** * Why a commit is not a version-only change of its parent (empty = it is). Strict: every changed file is on the * allow-list, and every version copy is byte for byte the parent's text with the new version written in by the very * writer tools/desktop-version.ts uses (so no other line of package.json, Cargo.toml, ... can change). */ export function versionOnlyProblems(changes: Change[]): { from: string | null; to: string | null; problems: string[] } { const problems: string[] = []; const pkg = changes.find(c => c.file === 'package.json'); const read = (t: string | null) => (t ? COPIES[0]!.read(t) : null); const from = read(pkg?.before ?? null); const to = read(pkg?.after ?? null); if (!pkg || !from || !to || from === to) problems.push('package.json: the version does not change'); else if (!SEMVER.test(to)) problems.push(`package.json: ${to} is not a version`); for (const c of changes) { if (FREE.some(re => re.test(c.file))) continue; const copy = COPIES.find(x => x.file === c.file); if (!copy) { problems.push(`${c.file}: not a version copy (${c.status})`); continue; } if (c.status !== 'M' || c.before === null || c.after === null) { problems.push(`${c.file}: ${c.status}, not modified in place`); continue; } if (!to || copy.read(c.before) !== from || copy.write(c.before, to) !== c.after) problems.push(`${c.file}: changes more than the version`); } return { from, to, problems }; } export type Status = | { state: 'green'; record: GreenRecord; release: Release } | { state: 'red'; run: string; failed: string[]; cancelledOnly?: boolean } | { state: 'pending'; run: string; running: string[] } | { state: 'none' }; export interface Task { id: number; name: string; run_number: number; workflow_id: string; head_sha: string; status: string } /** The state of a commit from its ci.yml jobs (the newest run of that commit) and whether its record exists. Pure. */ export function taskState(tasks: Task[], sha: string, runUrl: (n: number) => string): Exclude { // the background test jobs (ci, acceptance) never gate a release (the owner's decision 2026-10-16): only the // installer builds and the record count; tools/ci-status.ts reports the tests const mine = tasks.filter(t => t.head_sha === sha && t.workflow_id === 'ci.yml' && !/^(ci|acceptance\b)/.test(t.name)); if (!mine.length) return { state: 'none' }; const latest = Math.max(...mine.map(t => t.run_number)); const jobs = mine.filter(t => t.run_number === latest); const failed = jobs.filter(t => ['failure', 'cancelled'].includes(t.status)).map(t => t.name); // a run that was only cancelled (superseded, runners freed) proves nothing either way if (failed.length) return { state: 'red', run: runUrl(latest), failed, cancelledOnly: !jobs.some(t => t.status === 'failure') }; const running = jobs.filter(t => !['success', 'skipped'].includes(t.status)).map(t => `${t.name} (${t.status})`); // every job done and green but no green.json yet: the record job is still uploading, or ran in an older run return { state: 'pending', run: runUrl(latest), running }; } /** The version of a tag (v0.8.5 -> 0.8.5), else null. */ export const tagVersion = (tag: string): string | null => (/^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag)?.[1] ?? null); /** Versions with a pre-release part (0.0.0-rehearsal.1) are prereleases: never the stable release, never pruning. */ export const isPrerelease = (version: string): boolean => version.includes('-'); // --------------------------------------------------------------------------------------------------------------------- function fail(msg: string): never { console.error(`release-candidate: ${msg}`); process.exit(1); } const t0 = Date.now(); const say = (m: string) => console.log(`[${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s] ${m}`); const sha256 = (f: string) => createHash('sha256').update(readFileSync(f)).digest('hex'); export const runUrl = (n: number) => `${BASE}/${REPO}/actions/runs/${n}`; function git(args: string[], allowFail = false): string { const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 }); if (r.status !== 0 && !allowFail) fail(`git ${args.join(' ')}: ${r.stderr.trim()}`); return r.status === 0 ? r.stdout : ''; } export function changesBetween(base: string, head: string): Change[] { const out = git(['diff', '--name-status', '--no-renames', base, head]).trim(); if (!out) return []; return out.split('\n').map(l => { const [status, file] = l.split('\t') as [string, string]; const show = (rev: string) => { const r = spawnSync('git', ['show', `${rev}:${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 }); return r.status === 0 ? r.stdout : null; }; return { file, status, before: status === 'A' ? null : show(base), after: status === 'D' ? null : show(head) }; }); } export async function tasks(): Promise { const all: Task[] = []; for (let page = 1; page <= 3; page++) { const r = await api<{ workflow_runs?: Task[] }>('GET', `/actions/tasks?limit=50&page=${page}`); all.push(...(r.workflow_runs ?? [])); if ((r.workflow_runs ?? []).length < 50) break; } return all; } async function record(release: Release): Promise { const a = release.assets.find(x => x.name === GREEN); if (!a) return null; return JSON.parse(await api('GET', assetUrl(a), undefined, true)) as GreenRecord; } export async function status(sha: string): Promise { const rel = (await listReleases()).find(r => r.tag_name === candidateTag(sha)); const rec = rel ? await record(rel) : null; if (rel && rec) return { state: 'green', record: rec, release: rel }; return taskState(await tasks(), sha, runUrl); } /** Waits for a commit's candidate: green returns, red and none (after `graceMin`) fail at once. */ export async function waitGreen(sha: string, timeoutMin: number, graceMin = 20): Promise> { const end = Date.now() + timeoutMin * 60_000; let last = 0; for (;;) { const s = await status(sha); if (s.state === 'green') { say(`candidate ${sha.slice(0, 10)} is green (run ${s.record.run}${s.record.reusedFrom ? `, CI and acceptance of ${s.record.reusedFrom.sha.slice(0, 10)}` : ''})`); return s; } if (s.state === 'red' && s.cancelledOnly) fail(`commit ${sha.slice(0, 10)}: ${s.failed.join(', ')} were cancelled in ${s.run} (Forgejo cancels a branch's older run when a newer commit is pushed: never push to main while a release runs). Nothing was released: move the tag to origin/main and continue: git tag -f v && git push -f origin refs/tags/v && bun tools/release.ts --resume --yes`); if (s.state === 'red') fail(`commit ${sha.slice(0, 10)} is red: ${s.failed.join(', ')} failed in ${s.run}. Fix it and push; nothing is released from a red commit.`); if (s.state === 'none' && Date.now() - t0 > graceMin * 60_000) fail(`commit ${sha.slice(0, 10)} has no ci run on ${BASE}/${REPO}/actions after ${graceMin} min: push it to main (ci.yml makes the candidate), or free the runners (a queued run gets no task until one is free)`); if (Date.now() > end) fail(`timeout after ${timeoutMin} min waiting for the candidate of ${sha.slice(0, 10)}${s.state === 'pending' ? `: ${s.run} still has ${s.running.join(', ') || 'the record upload'}` : ''}`); if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(`waiting for candidate ${sha.slice(0, 10)}: ${s.state === 'pending' ? `${s.run}: ${s.running.join(', ') || 'record upload'}` : 'no run yet (queued runs get no task until a runner is free)'}`); } await Bun.sleep(10_000); } } /** Download a green candidate's files (green.json excluded), each checked against its SHA256SUMS. */ export async function fetchCandidate(s: Extract, dir: string, only?: RegExp): Promise { mkdirSync(dir, { recursive: true }); const got: string[] = []; const assets = s.release.assets.filter(a => a.name !== GREEN && a.name !== SUMS && (!only || only.test(a.name))); await Promise.all(assets.map(async a => { const dest = join(dir, a.name); if (existsSync(dest) && s.record.files[a.name] && sha256(dest) === s.record.files[a.name]) { got.push(dest); return; } await downloadAsset(assetUrl(a), dest); const want = s.record.files[a.name]; if (want && sha256(dest) !== want) fail(`${a.name}: SHA-256 differs from the candidate's record`); got.push(dest); })); return got.sort(); } function output(values: Record): void { for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`); const f = process.env.GITHUB_OUTPUT; if (f) appendFileSync(f, Object.entries(values).map(([k, v]) => `${k}=${v}\n`).join('')); } async function gate(sha: string, waitMin: number): Promise { const parent = git(['rev-parse', `${sha}^`], true).trim(); if (!parent) { output({ reuse: 'false', from: '' }); say('no parent in this checkout (fetch-depth 2): everything runs'); return; } const { from, to, problems } = versionOnlyProblems(changesBetween(parent, sha)); if (problems.length) { say(`not a version-only commit: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? ' ...' : ''}: everything runs`); output({ reuse: 'false', from: '' }); return; } say(`version-only commit: ${from} -> ${to} (every copy exactly re-written, plus changelog / acceptance files); the parent ${parent.slice(0, 10)}'s CI and acceptance matrix count if it is green`); const first = await status(parent); if (first.state === 'none') { say(`the parent has no ci run (pushed together with this commit?): everything runs`); output({ reuse: 'false', from: '' }); return; } if (first.state === 'red' && first.cancelledOnly) { say(`the parent's run was cancelled (${first.run}), not failed: everything runs`); output({ reuse: 'false', from: '' }); return; } const s = await waitGreen(parent, waitMin); if (s.record.cliKey !== cliKey(ROOT)) { say(`the parent's CLI key ${s.record.cliKey} is not this commit's: everything runs`); output({ reuse: 'false', from: '' }); return; } output({ reuse: 'true', from: parent }); } async function recordCmd(sha: string, run: string, dir: string, reusedFrom: string | undefined, yes: boolean): Promise { const version = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!; const acc = join(dir, 'acceptance'); let reused: GreenRecord['reusedFrom']; if (reusedFrom) { const p = await status(reusedFrom); if (p.state !== 'green') fail(`--reused-from ${reusedFrom.slice(0, 10)} is ${p.state}, not green`); await fetchCandidate(p, acc, /^acceptance-.*\.json$/); reused = { sha: reusedFrom, run: p.record.run }; } const files = readdirSync(dir, { recursive: true }).map(String).filter(f => statSync(join(dir, f)).isFile() && basename(f) !== SUMS && basename(f) !== GREEN); const flat = files.map(f => ({ path: join(dir, f), name: f.startsWith('acceptance/') && !basename(f).startsWith('acceptance-') ? `acceptance-${basename(f)}` : basename(f) })); const names = new Set(); for (const f of flat) { if (names.has(f.name)) fail(`two files named ${f.name}`); names.add(f.name); } for (const f of flat) if (!f.name.startsWith('acceptance-') && !f.name.includes(`_${version}_`)) fail(`${f.name} is not version ${version}`); const rec: GreenRecord = { schema: 1, sha, version, cliKey: cliKey(ROOT), run, at: new Date().toISOString(), reusedFrom: reused, files: Object.fromEntries(flat.map(f => [f.name, sha256(f.path)])) }; // assets keep flat names: copy acceptance files under their asset name const stage = join(dir, '.stage'); rmSync(stage, { recursive: true, force: true }); mkdirSync(stage); const paths = flat.map(f => { const p = join(stage, f.name); writeFileSync(p, readFileSync(f.path)); return p; }); const tag = candidateTag(sha); await upsert({ tag, target: sha, title: `candidate ${sha.slice(0, 10)} (${version})`, notes: `Release candidate of ${sha} (${version}): ${run}${reused ? `; CI and acceptance matrix of ${reused.sha} (version-only parent): ${reused.run}` : ''}. Promoted by a v${version} tag (release.yml); deleted by \`release-candidate.ts prune\`.`, draft: true, files: paths, yes }); if (!yes) return; // green.json last: a candidate is complete only with it writeFileSync(join(stage, GREEN), `${JSON.stringify(rec, null, 2)}\n`); await upsert({ tag, draft: true, files: [join(stage, GREEN)], yes: true }); say(`candidate ${tag} recorded: ${flat.length} files${reused ? `, CI + acceptance reused from ${reused.sha.slice(0, 10)}` : ''}`); } async function promote(tag: string, sha: string, waitMin: number, yes: boolean): Promise { const version = tagVersion(tag) ?? fail(`${tag} is not v`); const s = await waitGreen(sha, waitMin); if (s.record.version !== version) fail(`the candidate of ${sha.slice(0, 10)} is version ${s.record.version}, the tag says ${version}`); const pre = isPrerelease(version); const dir = join(ROOT, '.promote'); rmSync(dir, { recursive: true, force: true }); const files = await fetchCandidate(s, dir, /^(?!acceptance-).+/); say(`fetched ${files.length} files of the candidate (${files.map(f => basename(f)).join(', ')})`); const notes = shortNotes(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, { tag }); const pick = (re: RegExp) => files.filter(f => re.test(basename(f))); const ship = [...pick(/\.(AppImage|deb|exe)$/), ...pick(/_acceptance\.md$/)]; if (!pick(/\.AppImage$/).length || !pick(/\.deb$/).length || !pick(/\.exe$/).length) fail('the candidate lacks the AppImage, the deb or the Windows installer'); await upsert({ tag, target: sha, title: pre ? `storebound ${version} (prerelease)` : `storebound ${version}`, notes, prerelease: pre, files: ship, yes }); if (!yes) { say('dry run: nothing released (--yes)'); return; } say(`released ${tag} from candidate ${sha.slice(0, 10)}: ${BASE}/${REPO}/releases/tag/${tag}`); } async function prune(keep: number, yes: boolean): Promise { const rcs = (await listReleases()).filter(r => isCandidateTag(r.tag_name)).sort((a, b) => b.id - a.id); const drop = rcs.slice(keep); say(`${rcs.length} candidates; deleting ${drop.length} older than the newest ${keep}`); for (const r of drop) await deleteTest(r.tag_name, yes); } function args(argv: string[]) { const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; }; return { get, has: (n: string) => argv.includes(`--${n}`) }; } if (import.meta.main) { const [cmd, ...argv] = process.argv.slice(2); const a = args(argv); const sha = () => { const s = a.get('sha') ?? fail('--sha '); if (!/^[0-9a-f]{40}$/.test(s)) fail('--sha: a full 40-character sha'); return s; }; if (cmd === 'gate') await gate(sha(), Number(a.get('wait') ?? 40)); else if (cmd === 'record') await recordCmd(sha(), a.get('run') ?? fail('--run '), resolve(a.get('dir') ?? fail('--dir ')), a.get('reused-from') || undefined, a.has('yes')); else if (cmd === 'status') { const s = await status(sha()); if (a.has('json')) console.log(JSON.stringify(s.state === 'green' ? { state: s.state, record: s.record } : s)); else console.log(s.state === 'green' ? `green: ${s.record.run}` : s.state === 'none' ? 'none' : `${s.state}: ${s.run} ${'failed' in s ? s.failed.join(', ') : s.running.join(', ')}`); } else if (cmd === 'wait') await waitGreen(sha(), Number(a.get('timeout') ?? 40)); else if (cmd === 'fetch') { const s = await waitGreen(sha(), 1); const only = a.get('only'); for (const f of await fetchCandidate(s, resolve(a.get('dir') ?? fail('--dir')), only ? new RegExp(only) : undefined)) console.log(f); } else if (cmd === 'promote') await promote(a.get('tag') ?? fail('--tag v'), sha(), Number(a.get('wait') ?? 40), a.has('yes')); else if (cmd === 'prune') await prune(Number(a.get('keep') ?? 12), a.has('yes')); else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); } }