291 lines
20 KiB
TypeScript
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); }
|
|
}
|