storebound/tools/release-candidate.ts

291 lines
20 KiB
TypeScript

#!/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-<full sha>` 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 <sha> [--wait <min>]
// 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 <sha> --run <url> --dir <dir> [--reused-from <sha>] [--yes]
// The record job: <dir> 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-<sha>.
// bun tools/release-candidate.ts status --sha <sha> [--json]
// green (with the record), red (with the failed run's link), pending or none.
// bun tools/release-candidate.ts wait --sha <sha> [--timeout <min>]
// 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 <sha> --dir <dir> [--only <regex>]
// Downloads a green candidate's files (checked against its SHA256SUMS).
// bun tools/release-candidate.ts promote --tag v<version> --sha <sha> [--wait <min>] [--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 <n>] [--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<string, string>;
}
// ---------------------------------------------------------------------------------------------------------------------
// 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<Status, { state: 'green' }> {
// 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. The dev job (the owner's dev update
// channel) never gates a release either: a hiccup publishing a dev build must not hold back npm.
const mine = tasks.filter(t => t.head_sha === sha && t.workflow_id === 'ci.yml' && !/^(ci|acceptance\b|dev$)/.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<Task[]> {
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<GreenRecord | null> {
const a = release.assets.find(x => x.name === GREEN);
if (!a) return null;
return JSON.parse(await api<string>('GET', assetUrl(a), undefined, true)) as GreenRecord;
}
export async function status(sha: string): Promise<Status> {
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<Extract<Status, { state: 'green' }>> {
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<version> && git push -f origin refs/tags/v<version> && bun tools/release.ts <version> --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<Status, { state: 'green' }>, dir: string, only?: RegExp): Promise<string[]> {
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<string, string>): 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<void> {
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<void> {
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<string>();
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<void> {
const version = tagVersion(tag) ?? fail(`${tag} is not v<version>`);
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<void> {
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 <full commit 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 <url>'), resolve(a.get('dir') ?? fail('--dir <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<version>'), 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); }
}