Skill Tracer
Skills › gstack › gstack › release-cut.ts

release-cut.ts

607 lines · scripts/release-cut.ts at 3f79d81

1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/**
3 * release-cut: tag the newest green commit on main as v<VERSION>, publish a
4 * GitHub Release whose notes are that span's CHANGELOG headlines, and
5 * fast-forward refs/heads/stable to it (docs/releases.md). Run by
6 * .github/workflows/weekly-release.yml ("Stable release") every Monday and
7 * Thursday at 14:00 UTC and by hand (--ref <sha on main> for a hotfix). Dry
8 * runs print the plan and change nothing.
9 *
10 * bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason> | --regression <reason>] [--dry-run]
11 * [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
12 *
13 * The checkout needs origin's full history (fetch-depth 0). Green, for a
14 * commit on main's first-parent history, means: its required check run
15 * succeeded (--required-check, default free-tests: the status main requires),
16 * it was gated (see below), every check run on it has completed and none
17 * failed, and the trains notes ref (--notes-ref, default
18 * refs/notes/gstack-trains) holds no active freeze. The newest such commit in
19 * the window (7 days, 200 first-parent commits) is the candidate; a commit
20 * with a check run still going is skipped, not waited for.
21 *
22 * Gated means one of two kinds of evidence. Either the backstop check run
23 * (--backstop-check, default main-backstop) succeeded: evals-main-backstop.yml
24 * publishes it, success only when a green train record covers the tree or the
25 * backstop gated it green, neutral when main moved past the push before it was
26 * gated. Or a train record in the notes ref (kind train, state green, merging
27 * or merged) lists the commit's sha or its tree in merged[]: those entries are
28 * the main commits `gstack-ship-queue verify --train` confirmed, so a neutral
29 * or absent backstop run on such a commit is not held against it, and the
30 * CANDIDATE line says "gated by train <nonce>". A red backstop run stays red
31 * either way. A commit with neither is not green.
32 *
33 * stable moves only forward: a candidate behind the current stable is refused
34 * (REFUSED non-fast-forward), as is a tag v<VERSION> that already names another
35 * commit. A cut that stopped half-way (tag and stable pushed, release not
36 * created) is finished by the next run; a completed cut reports NOTHING_NEW
37 * and exits 0.
38 *
39 * The floor: stable moves at most once per FLOOR_HOURS (72). The last move is
40 * the annotated v<VERSION> tag pointing at stable, or its GitHub Release's
41 * publish time when that is later; a cut inside the floor is REFUSED_FLOOR
42 * with NEXT_ALLOWED, and a stable without that tag is FLOOR_UNKNOWN. A
43 * scheduled run (GITHUB_EVENT_NAME=schedule) gets SCHEDULED_GRACE_HOURS (3) of
44 * slack for cron start jitter; nothing else does. Finishing a half-done cut is
45 * not a new move. Two exceptions pass the floor, each with --ref and one per
46 * cut: a security cut (--security <reason>, where the reason names a GHSA, CVE
47 * or #<n> that verifySecurity ties to the commits being released) and a
48 * regression cut (--regression <reason>, where the reason names a
49 * regression-labelled #<n> a released commit references and the earlier stable
50 * release v<X> it worked in; see verifyRegression). Only a security cut passes
51 * FLOOR_UNKNOWN. The reason and its evidence go into the tag message and the
52 * notes, and a retry recovers them from the tag.
53 */
54import * as fs from 'node:fs';
55import * as os from 'node:os';
56import * as path from 'node:path';
57import { spawnSync } from 'node:child_process';
59export type Gh = (args: string[]) => { status: number | null; stdout: string; stderr: string };
60export type Git = (args: string[]) => { ok: boolean; out: string };
62export const REQUIRED_CHECK = 'free-tests';
63export const BACKSTOP_CHECK = 'main-backstop';
64export const NOTES_REF = 'refs/notes/gstack-trains';
65const NOTES_LOCAL = 'refs/notes/gstack-trains-release';
66const RED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'stale']);
67export const WINDOW_DAYS = 7;
68export const WINDOW_COMMITS = 200;
69export const NOTES_CAP = 60_000;
70export const FLOOR_HOURS = 72;
71export const SCHEDULED_GRACE_HOURS = 3;
72const HOUR = 3_600_000;
74export const runGh: Gh = (args) => {
75 const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
76 return { status: r.status, stdout: r.stdout ?? '', stderr: r.error ? String(r.error) : r.stderr ?? '' };
77};
78export const gitIn = (cwd: string): Git => (args) => {
79 const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
80 return r.status === 0 ? { ok: true, out: (r.stdout ?? '').replace(/\n$/, '') } : { ok: false, out: (r.stderr || String(r.error ?? '')).trim() };
81};
83function parseJson<T>(r: { status: number | null; stdout: string }): T | null {
84 if (r.status !== 0) return null;
85 try { return JSON.parse(r.stdout) as T; } catch { return null; }
88/** Four-part versions compare numerically, part by part. */
89export function compareVersions(a: string, b: string): number {
90 const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
91 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
92 const d = (pa[i] ?? 0) - (pb[i] ?? 0);
93 if (d !== 0) return d;
94 }
95 return 0;
98/** Green names what gated the commit: the backstop check's name, or `train <nonce>`. Pending means a check run on it is still going. */
99export type Verdict = { green: true; via: string } | { green: false; why: string; pending: boolean };
100interface CheckRun { name: string; status: string; conclusion: string | null }
102/** Main commits a green, merging or merged train record lists in merged[], by sha and by tree, each naming the train. */
103export interface TrainEvidence { bySha: Map<string, string>; byTree: Map<string, string> }
104export interface GateOptions { requiredCheck?: string; backstopCheck?: string; evidence?: TrainEvidence; treeOf?: (sha: string) => string | null }
106/** Why a commit is not releasable, or green and through which evidence. */
107export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions = {}): Verdict {
108 const required = o.requiredCheck ?? REQUIRED_CHECK;
109 const backstop = o.backstopCheck ?? BACKSTOP_CHECK;
110 const paged = parseJson<unknown>(gh(['api', '--paginate', '--slurp', `repos/${repo}/commits/${sha}/check-runs?per_page=100`]));
111 if (!paged) return { green: false, why: 'cannot read its check runs', pending: false };
112 const pages = (Array.isArray(paged) ? paged : [paged]) as Array<{ check_runs?: CheckRun[] }>;
113 const runs = pages.flatMap(p => p.check_runs ?? []);
114 let via = backstop;
115 for (const name of [required, backstop]) {
116 const named = runs.filter(c => c.name === name);
117 if (named.some(c => c.status !== 'completed')) return { green: false, why: `${name} is still running`, pending: true };
118 if (named.some(c => c.conclusion === 'success')) continue;
119 const train = name === backstop && name !== required && o.evidence ? o.evidence.bySha.get(sha) ?? o.evidence.byTree.get(o.treeOf?.(sha) ?? '') : undefined;
120 if (train) { via = `train ${train}`; continue; }
121 if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
122 return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false };
123 }
124 const red = runs.filter(c => c.status === 'completed' && RED.has(c.conclusion ?? ''));
125 if (red.length > 0) return { green: false, why: `red check runs: ${[...new Set(red.map(c => c.name))].join(', ')}`, pending: false };
126 const running = runs.filter(c => c.status !== 'completed');
127 if (running.length > 0) return { green: false, why: `check runs still going: ${[...new Set(running.map(c => c.name))].join(', ')}`, pending: true };
128 return { green: true, via };
131export interface TrainNotes { error: string | null; freeze: string | null; evidence: TrainEvidence }
132interface NoteRecord { kind?: string; state?: string; nonce?: string; merged?: Array<{ sha?: string; tree?: string }>; cleared_at?: string | null; reason?: string; train?: string }
134/**
135 * One read of the trains notes ref (fetched from origin): the active freeze
136 * record (kind freeze, cleared_at null) and the train evidence (lib/
137 * ship-queue-train-record.ts TrainRecord: kind train, state green, merging or
138 * merged, merged[].sha and .tree). A fetch failure other than a missing ref is
139 * an error; a missing ref holds nothing.
140 */
141export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
142 const notes: TrainNotes = { error: null, freeze: null, evidence: { bySha: new Map(), byTree: new Map() } };
143 const f = git(['fetch', '--no-tags', '-q', 'origin', `+${notesRef}:${NOTES_LOCAL}`]);
144 if (!f.ok) {
145 if (!/couldn't find remote ref|no such ref/i.test(f.out)) notes.error = `cannot fetch ${notesRef}: ${f.out.slice(0, 160)}`;
146 return notes;
147 }
148 const listed = git(['notes', `--ref=${NOTES_LOCAL}`, 'list']);
149 if (!listed.ok || !listed.out.trim()) return notes;
150 for (const line of listed.out.split('\n')) {
151 const blob = line.split(' ')[0];
152 if (!blob) continue;
153 let parsed: unknown;
154 try { parsed = JSON.parse(git(['cat-file', 'blob', blob]).out); } catch { continue; }
155 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
156 const r = parsed as NoteRecord;
157 if (r.kind === 'freeze' && !r.cleared_at) notes.freeze = `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`;
158 if (r.kind !== 'train' || typeof r.nonce !== 'string' || !r.nonce || !['green', 'merging', 'merged'].includes(r.state ?? '')) continue;
159 for (const m of Array.isArray(r.merged) ? r.merged : []) {
160 if (!m || typeof m !== 'object') continue;
161 if (typeof m.sha === 'string' && m.sha) notes.evidence.bySha.set(m.sha, r.nonce);
162 if (typeof m.tree === 'string' && m.tree) notes.evidence.byTree.set(m.tree, r.nonce);
163 }
164 }
165 return notes;
168export interface ChangelogEntry { version: string; date: string; headline: string }
170/** Every "## [X.Y.Z.W] - date" entry of a CHANGELOG with its first prose line, markdown stripped. */
171export function changelogEntries(text: string): ChangelogEntry[] {
172 const out: ChangelogEntry[] = [];
173 const lines = text.split('\n');
174 for (let i = 0; i < lines.length; i++) {
175 const m = /^## \[(\d+(?:\.\d+)+)\](?: - (\S+))?/.exec(lines[i]!);
176 if (!m) continue;
177 let headline = '';
178 for (let j = i + 1; j < lines.length && !lines[j]!.startsWith('## '); j++) {
179 const l = lines[j]!.trim();
180 if (!l || l.startsWith('#') || l.startsWith('|') || l.startsWith('-') || l.startsWith('```')) continue;
181 const bold = /^\*\*(.+?)\*\*/.exec(l);
182 headline = (bold ? bold[1]! : l.replace(/\*\*/g, '')).replace(/\s+/g, ' ');
183 break;
184 }
185 out.push({ version: m[1]!, date: m[2] ?? '', headline });
186 }
187 return out;
190export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; exception?: Exception | null }
192/** A verified floor exception (a security or a regression cut): the sanitized reason and what tied it to the released commits. */
193export interface Exception { kind: 'security' | 'regression'; reason: string; evidence: string }
195const KIND_LABEL = { security: 'Security', regression: 'Regression' } as const;
197export function releaseKind(hotfix: boolean, exception: Exception | null | undefined): 'security' | 'regression' | 'hotfix' | 'regular' {
198 return exception ? exception.kind : hotfix ? 'hotfix' : 'regular';
201/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
202export function releaseNotes(n: NotesInput): string {
203 const span = n.entries.filter(e => compareVersions(e.version, n.version) <= 0 && (!n.prevVersion || compareVersions(e.version, n.prevVersion) > 0))
204 .sort((a, b) => compareVersions(b.version, a.version));
205 const plural = span.length === 1 ? '' : 's';
206 const changelogUrl = 'https://github.com/' + n.repo + '/blob/v' + n.version + '/CHANGELOG.md';
207 const compare = n.prevTag ? '[Compare](https://github.com/' + n.repo + '/compare/' + n.prevTag + '...v' + n.version + ') or read ' : 'Read ';
208 const head = [
209 '## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.exception) + ' release)',
210 '',
211 ...(n.exception ? ['**' + KIND_LABEL[n.exception.kind] + ' release:** `' + n.exception.reason + '` (verified: ' + n.exception.evidence + '). Every version since the previous release ships with it.', ''] : []),
212 n.first || !n.prevVersion
213 ? 'First tagged release: the ' + span.length + ' version' + plural + (n.prevVersion ? ' since v' + n.prevVersion + ' (the week before it)' : '') + ', one line each; the [full changelog](' + changelogUrl + ') has every earlier one.'
214 : span.length + ' version' + plural + ' since v' + n.prevVersion + ', one line each. ' + compare + 'the [full changelog](' + changelogUrl + ').',
215 '',
216 'Team-mode installs follow the stable branch, which now points here; gstack-config set update_channel main follows every merge instead ([docs/releases.md](https://github.com/' + n.repo + '/blob/v' + n.version + '/docs/releases.md)).',
217 '',
218 ].join('\n');
219 const bullets = span.map(e => '- **' + e.version + '**' + (e.date ? ' (' + e.date + ')' : '') + ': ' + (e.headline || 'see CHANGELOG.md'));
220 let body = head + bullets.join('\n') + '\n';
221 if (body.length > NOTES_CAP) {
222 const kept: string[] = [];
223 let size = head.length;
224 for (const b of bullets) { if (size + b.length + 1 > NOTES_CAP - 200) break; kept.push(b); size += b.length + 1; }
225 body = head + kept.join('\n') + '\n- ...and ' + (bullets.length - kept.length) + ' more versions; see CHANGELOG.md\n';
226 }
227 return body;
230const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
232/**
233 * When stable last moved: the annotated v<VERSION> tag a cut made on it (VERSION
234 * read at stable), or that tag's release publish time when later. Any other tag
235 * on that commit is ignored, so a hand-made extra tag cannot date the move or
236 * pin later runs to stable. published is false when the release does not exist
237 * yet (a half-done cut, whose tag may predate the stable push); a release that
238 * cannot be read leaves the move undated.
239 */
240export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): { at: number; tag: string; published: boolean } | { at: null; why: string } {
241 const version = git(['show', stable + ':VERSION']);
242 const tag = 'v' + (version.ok ? version.out.trim() : '<VERSION>');
243 const listed = version.ok ? git(['for-each-ref', '--points-at=' + stable, '--format=%(objecttype)%09%(creatordate:unix)', 'refs/tags/' + tag]) : null;
244 const [type, created] = listed?.ok && listed.out ? listed.out.split('\t') : [];
245 if (type !== 'tag') return { at: null, why: 'no annotated ' + tag + ' tag points at stable ' + stable.slice(0, 12) + ', so its last move cannot be dated' };
246 const at = Number(created) * 1000;
247 const view = gh(['release', 'view', tag, '--repo', repo, '--json', 'publishedAt']);
248 if (view.status !== 0) {
249 if (/not found|HTTP 404/i.test(view.stderr)) return { tag, at, published: false };
250 return { at: null, why: 'cannot read the release for ' + tag + ' (' + (view.stderr.trim().slice(0, 120) || 'gh exit ' + view.status) + '), so its last move cannot be dated' };
251 }
252 const published = Date.parse(parseJson<{ publishedAt?: string }>(view)?.publishedAt ?? '');
253 return { tag, at: Number.isFinite(published) ? Math.max(at, published) : at, published: true };
256/** The floor verdict for moving stable from prevStable to sha. A refusal carries the message and the NEXT_ALLOWED text. */
257export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'regression' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
259/** A regression cut passes only a dated floor; an undatable or unpublished stable stays security-only. */
260export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; exception: Exception['kind'] | null; scheduled: boolean; now: number }): Floor {
261 if (!o.prevStable) return { verdict: 'first', line: 'FLOOR: none (no stable yet; this cut creates it)' };
262 if (o.prevStable === o.sha) return { verdict: 'finishing', line: 'FLOOR: not applied (finishing the cut that already moved stable)' };
263 const moved = stableMovedAt(o.git, o.gh, o.repo, o.prevStable);
264 if (moved.at === null) {
265 if (o.exception === 'security') return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
266 return { verdict: 'unknown', line: 'FLOOR: unknown', message: moved.why + '; only a --security cut can move it until one does', next: 'none until an annotated v<VERSION> tag dates the release stable points at' };
267 }
268 if (!moved.published && o.exception !== 'security') {
269 return { verdict: 'unknown', line: 'FLOOR: unknown', message: 'stable moved to ' + moved.tag + ' but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)', next: 'once the release for ' + moved.tag + ' is published' };
270 }
271 // A tag dated in the future holds the floor from now, not from that date.
272 moved.at = Math.min(moved.at, o.now);
273 const need = FLOOR_HOURS - (o.scheduled ? SCHEDULED_GRACE_HOURS : 0);
274 const since = 'stable moved to ' + moved.tag + ' at ' + iso(moved.at) + ', ' + ((o.now - moved.at) / HOUR).toFixed(1) + ' h ago';
275 const next = iso(moved.at + FLOOR_HOURS * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (FLOOR_HOURS - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
276 if (o.exception) return { verdict: o.exception, line: 'FLOOR: bypassed by the ' + o.exception + ' exception (' + since + ')' };
277 const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + FLOOR_HOURS + ' h less ' + SCHEDULED_GRACE_HOURS + ' h cron grace)' : 'a hand-run cut needs ' + need + ' h';
278 if (o.now - moved.at >= need * HOUR) return { verdict: 'clear', line: 'FLOOR: clear (' + since + '; ' + kind + ')' };
279 return { verdict: 'refused', line: 'FLOOR: refused', message: since + '; ' + kind + '; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>, a regression fix with --regression "#n: worked in v<X>: why" --ref <fix sha>', next };
282/** One line, no control characters or backticks, at most 300 characters: the reason is public in the notes and tag. */
283export function sanitizeReason(reason: string): string {
284 return reason.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/`/g, "'").replace(/\s+/g, ' ').trim().slice(0, 300);
287/**
288 * Ties a security reason to the commits in (from, to]. The reason must name a
289 * GHSA, CVE or #<n>; it is verified when (a) a commit message in the span names
290 * that GHSA/CVE, (b) issue or PR #<n> carries the security label and a commit
291 * in the span references #<n>, or (c) the GitHub Advisory Database entry for
292 * that GHSA/CVE lists a package whose line changes in the span's package.json
293 * or bun.lock. What the fix fixes, and how bad it was, stays attested.
294 */
295export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string }): { ok: true; evidence: string } | { ok: false; why: string } {
296 const advisories = [...new Set([...o.reason.matchAll(/\bGHSA(?:-[2-9cfghjmpqrvwx]{4}){3}\b/gi)].map(m => 'GHSA' + m[0].slice(4).toLowerCase())
297 .concat([...o.reason.matchAll(/\bCVE-\d{4}-\d{4,}\b/gi)].map(m => m[0].toUpperCase())))];
298 const issues = issueNumbers(o.reason);
299 if (advisories.length === 0 && issues.length === 0) return { ok: false, why: 'the reason names no GHSA-…, CVE-… or #<n> to verify' };
300 const commits = spanCommits(o.git, o.from, o.to);
301 // Package names whose own entry changed: a package.json dependency line or a bun.lock package key, never a name listed inside another package's entry.
302 const changed = new Set((o.from ? o.git(['diff', '--unified=0', o.from, o.to, '--', 'package.json', 'bun.lock']).out.split('\n') : [])
303 .flatMap(l => { const m = /^[+-](?![+-])\s*"([^"]+)":\s*(?:"[^"]*"|\[\s*"([^"]+)@)/.exec(l); return m && (!m[2] || m[2] === m[1]) ? [m[1]!] : []; }));
304 const why: string[] = [];
305 for (const id of advisories) {
306 const named = commits.find(c => c.body.toUpperCase().includes(id.toUpperCase()));
307 if (named) return { ok: true, evidence: 'commit ' + named.sha + ' names ' + id };
308 const read = parseJson<unknown>(o.gh(['api', id.startsWith('GHSA') ? 'advisories/' + id : 'advisories?cve_id=' + id]));
309 const entries = (Array.isArray(read) ? read : read ? [read] : []) as Array<{ vulnerabilities?: Array<{ package?: { name?: string; ecosystem?: string } }> }>;
310 const packages = [...new Set(entries.flatMap(e => e.vulnerabilities ?? []).filter(v => v.package?.ecosystem?.toLowerCase() === 'npm').map(v => v.package?.name).filter((n): n is string => Boolean(n)))];
311 const bumped = packages.find(p => changed.has(p));
312 if (bumped) return { ok: true, evidence: 'advisory ' + id + ' lists ' + bumped + ', which changes in package.json/bun.lock' };
313 why.push(id + ': no commit names it' + (read ? ' and none of its ' + packages.length + ' npm advisory packages changes in package.json/bun.lock' : ' and the advisory database has no entry for it'));
314 }
315 const labelled = labelledReference(o.gh, o.repo, commits, issues, 'security');
316 if (labelled.ok) return labelled;
317 return { ok: false, why: 'nothing in ' + spanName(o.from, o.to) + ' ties the reason to the release: ' + [...why, ...labelled.why].join('; ') };
320/**
321 * Ties a regression reason to the commits in (from, to]. A regression is a fix
322 * for behavior that worked in an earlier stable release. The reason names
323 * exactly one release v<X> and at least one #<n>; it is verified when v<X> is
324 * an annotated release tag strictly behind the current stable with a published
325 * GitHub Release (so team installs ran it and have since moved past it; a tag a
326 * half-done cut left behind does not count), and issue or PR #<n> carries the
327 * regression label (a maintainer's declaration: labelling needs triage rights)
328 * and a commit in the span references #<n>. That the behavior really worked in
329 * v<X> stays attested, in public, by the label and the reason.
330 */
331export function verifyRegression(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string; stable: string | null }): { ok: true; evidence: string } | { ok: false; why: string } {
332 const releases = [...new Set([...o.reason.matchAll(/(?<![\w.])v(\d+(?:\.\d+)+)(?!\w|\.\d)/g)].map(m => 'v' + m[1]!))];
333 const issues = issueNumbers(o.reason);
334 if (releases.length !== 1 || issues.length === 0) return { ok: false, why: 'the reason must name exactly one earlier stable release v<X> the behavior worked in and the regression-labelled #<n> it fixes (named ' + releases.length + ' release' + (releases.length === 1 ? '' : 's') + ' and ' + issues.length + ' #<n>)' };
335 if (!o.stable) return { ok: false, why: 'there is no stable release yet, so nothing earlier can have worked' };
336 const tag = releases[0]!;
337 const type = o.git(['for-each-ref', '--format=%(objecttype)', 'refs/tags/' + tag]);
338 const worked = o.git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
339 if (!type.ok || type.out !== 'tag' || !worked.ok) return { ok: false, why: tag + ' is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in' };
340 if (worked.out === o.stable || !o.git(['merge-base', '--is-ancestor', worked.out, o.stable]).ok) return { ok: false, why: tag + ' is not an earlier stable release: it must sit strictly behind the current stable ' + o.stable.slice(0, 12) + ', since a fix for something the current stable still does right needs no exception' };
341 const release = o.gh(['release', 'view', tag, '--repo', o.repo, '--json', 'isDraft']);
342 const published = release.status === 0 ? parseJson<{ isDraft?: boolean }>(release) : null;
343 if (!published || published.isDraft === true) return { ok: false, why: tag + ' has no published release, so team installs never ran it as stable' };
344 const labelled = labelledReference(o.gh, o.repo, spanCommits(o.git, o.from, o.to), issues, 'regression');
345 if (!labelled.ok) return { ok: false, why: 'nothing in ' + spanName(o.from, o.to) + ' ties the reason to the release: ' + labelled.why.join('; ') };
346 return { ok: true, evidence: labelled.evidence + '; ' + tag + ' (' + worked.out.slice(0, 12) + ') is an earlier stable release' };
349const issueNumbers = (reason: string) => [...new Set([...reason.matchAll(/(?<![\w&])#(\d+)\b/g)].map(m => m[1]!))];
350const spanName = (from: string | null, to: string) => (from ? from.slice(0, 12) + '..' : 'up to ') + to.slice(0, 12);
352function spanCommits(git: Git, from: string | null, to: string): Array<{ sha: string; body: string }> {
353 const log = git(['log', '--format=%H%x1f%B%x1e', ...(from ? [from + '..' + to] : ['--max-count=' + WINDOW_COMMITS, to])]);
354 return (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
357/** The first #<n> carrying the label that a span commit references; each miss says why. */
358function labelledReference(gh: Gh, repo: string, commits: Array<{ sha: string; body: string }>, issues: string[], label: string): { ok: true; evidence: string } | { ok: false; why: string[] } {
359 const why: string[] = [];
360 for (const n of issues) {
361 const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
362 const issue = parseJson<{ labels?: Array<{ name?: string }> }>(gh(['api', 'repos/' + repo + '/issues/' + n]));
363 const labelled = Boolean(issue?.labels?.some(l => l.name?.toLowerCase() === label));
364 if (ref && labelled) return { ok: true, evidence: '#' + n + ' carries the ' + label + ' label and commit ' + ref.sha + ' references it' };
365 why.push('#' + n + ': ' + [!issue ? 'cannot read it' : !labelled ? 'no ' + label + ' label' : '', !ref ? 'no commit in the span references it' : ''].filter(Boolean).join(', '));
366 }
367 return { ok: false, why };
370/** The exception reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
371export function exceptionFromTag(git: Git, tag: string): Exception | null {
372 const body = git(['for-each-ref', '--format=%(contents)', 'refs/tags/' + tag]);
373 const reason = body.ok ? /^(Security|Regression): (.+)$/m.exec(body.out) : null;
374 if (!reason) return null;
375 return { kind: reason[1] === 'Security' ? 'security' : 'regression', reason: reason[2]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
378export interface Plan {
379 sha: string; version: string; tag: string; date: string; hotfix: boolean; via: string;
380 prevStable: string | null; prevVersion: string | null; prevTag: string | null;
381 tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
382 exception: Exception | null; floor: Floor; ridingAlong: number;
384export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY' | 'REFUSED_REGRESSION';
385export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
387export interface PlanOptions {
388 repo: string; ref?: string; git: Git; gh: Gh; now?: () => number; windowDays?: number; windowCommits?: number;
389 requiredCheck?: string; backstopCheck?: string; notesRef?: string;
390 /** A scheduled run (GITHUB_EVENT_NAME=schedule): the floor allows SCHEDULED_GRACE_HOURS of cron jitter. */
391 scheduled?: boolean;
392 /** --security <reason>: bypass the floor for a verified security fix; needs ref. */
393 security?: string;
394 /** --regression <reason>: bypass a dated floor for a verified regression fix; needs ref. */
395 regression?: string;
398const refusalFor = (kind: Exception['kind']): RefusalCode => (kind === 'security' ? 'REFUSED_SECURITY' : 'REFUSED_REGRESSION');
400export function planRelease(o: PlanOptions): PlanResult {
401 const { git, gh, repo } = o;
402 const windowDays = o.windowDays ?? WINDOW_DAYS;
403 const skipped: string[] = [];
404 if (o.security !== undefined && o.regression !== undefined) return { ok: false, code: 'REFUSED_REGRESSION', message: '--security and --regression together: a cut carries one exception, so its notes make one claim' };
405 const asked: Exception['kind'] | null = o.security !== undefined ? 'security' : o.regression !== undefined ? 'regression' : null;
406 if (asked && !o.ref) return { ok: false, code: refusalFor(asked), message: '--' + asked + ' needs --ref <the fix commit>: a ' + asked + ' cut releases up to the fix, never whatever is newest' };
407 const mainHead = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}']);
408 if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out };
409 const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
410 const prevStable = stable.ok ? stable.out : null;
411 const trains = readTrainNotes(git, o.notesRef);
412 const frozen = trains.error ?? trains.freeze;
413 if (frozen) return { ok: false, code: 'REFUSED', message: frozen };
414 const gate: GateOptions = {
415 requiredCheck: o.requiredCheck, backstopCheck: o.backstopCheck, evidence: trains.evidence,
416 treeOf: (sha) => { const t = git(['rev-parse', sha + '^{tree}']); return t.ok ? t.out : null; },
417 };
419 let candidates: string[];
420 if (o.ref) {
421 const r = git(['rev-parse', '--verify', o.ref + '^{commit}']);
422 if (!r.ok) return { ok: false, code: 'REFUSED', message: '--ref ' + o.ref + ' is not a commit here: ' + r.out };
423 const fp = git(['rev-list', '--first-parent', 'refs/remotes/origin/main']);
424 if (!fp.out.split('\n').includes(r.out)) return { ok: false, code: 'REFUSED', message: '--ref ' + r.out.slice(0, 12) + " is not on main's first-parent history; a release is always a merge commit of main" };
425 candidates = [r.out];
426 } else {
427 const since = new Date((o.now?.() ?? Date.now()) - windowDays * 86_400_000).toISOString();
428 const list = git(['rev-list', '--first-parent', '--max-count=' + (o.windowCommits ?? WINDOW_COMMITS), '--since=' + since, 'refs/remotes/origin/main']);
429 candidates = list.ok && list.out ? list.out.split('\n') : [];
430 if (candidates.length === 0) return { ok: false, code: 'NOTHING_NEW', message: 'no commit on origin/main in the last ' + windowDays + ' days' };
431 // A cut that moved stable but never published its release is finished before stable moves again.
432 const moved = prevStable ? stableMovedAt(git, gh, repo, prevStable) : null;
433 if (prevStable && moved && moved.at !== null && !moved.published) candidates = [prevStable];
434 }
436 // stable itself stays a candidate so a cut that stopped half-way (tag and
437 // stable pushed, release missing) is finished; nothing older is looked at.
438 let sha: string | null = null;
439 let via = '';
440 for (const c of candidates) {
441 const v = gateVerdict(gh, repo, c, gate);
442 if (v.green) { sha = c; via = v.via; break; }
443 skipped.push(c.slice(0, 12) + ': ' + v.why);
444 if (prevStable && c === prevStable) { skipped.push(c.slice(0, 12) + ': already stable; nothing newer is green'); break; }
445 }
446 if (!sha) {
447 if (o.ref) return { ok: false, code: 'REFUSED', message: '--ref ' + candidates[0]!.slice(0, 12) + ' is not green: ' + (skipped[0] ?? '').split(': ').slice(1).join(': '), skipped };
448 return { ok: false, code: 'NO_GREEN_COMMIT', message: 'no green commit among ' + candidates.length + ' on origin/main; see the skipped list', skipped };
449 }
450 if (prevStable && sha !== prevStable && !git(['merge-base', '--is-ancestor', prevStable, sha]).ok) {
451 return { ok: false, code: 'REFUSED', message: 'non-fast-forward: stable ' + prevStable.slice(0, 12) + ' is not an ancestor of ' + sha.slice(0, 12) + '; stable only moves forward along main' };
452 }
454 const version = git(['show', sha + ':VERSION']).out.trim();
455 if (!/^\d+(\.\d+)+$/.test(version)) return { ok: false, code: 'REFUSED', message: 'VERSION at ' + sha.slice(0, 12) + " is not a version: '" + version + "'" };
456 const tag = 'v' + version;
457 const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
458 const tagExists: Plan['tagExists'] = !existing.ok ? 'no' : existing.out === sha ? 'same' : 'other';
459 if (tagExists === 'other') return { ok: false, code: 'REFUSED', message: 'tag ' + tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + sha.slice(0, 12) + '; a version is stamped once per merge, so this needs a look' };
460 if (prevStable === sha && tagExists === 'same' && gh(['release', 'view', tag, '--repo', repo, '--json', 'tagName']).status === 0) {
461 return { ok: false, code: 'NOTHING_NEW', message: 'origin/main ' + sha.slice(0, 12) + ' is already released as ' + tag + '; nothing newer is green' };
462 }
464 // The release this one follows: stable, or when stable already sits here
465 // (a cut that stopped half-way), the newest release tag behind it.
466 let before = prevStable;
467 if (prevStable === sha) {
468 const prior = git(['describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', '--first-parent', sha + '^']);
469 const resolved = prior.ok && prior.out ? git(['rev-parse', '--verify', '-q', prior.out + '^{commit}']) : null;
470 before = resolved?.ok ? resolved.out : null;
471 }
472 let prevVersion: string | null = null, prevTag: string | null = null;
473 if (before) {
474 const shown = git(['show', before + ':VERSION']);
475 prevVersion = shown.ok ? shown.out.trim() || null : null;
476 if (prevVersion && git(['rev-parse', '--verify', '-q', 'refs/tags/v' + prevVersion + '^{commit}']).ok) prevTag = 'v' + prevVersion;
477 } else {
478 const date = git(['show', '-s', '--format=%cI', sha]).out;
479 const before = new Date(new Date(date).getTime() - windowDays * 86_400_000).toISOString();
480 const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]);
481 if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null;
482 }
483 // Finishing an exception cut keeps the reason and evidence its tag recorded; a
484 // tag that recorded one but never moved stable needs that same kind verified again.
485 const recorded = tagExists === 'same' ? exceptionFromTag(git, tag) : null;
486 let exception: Exception | null = recorded && prevStable === sha ? recorded : null;
487 if (recorded && !exception && asked !== recorded.kind) return { ok: false, code: refusalFor(recorded.kind), message: 'tag ' + tag + ' records a ' + recorded.kind + ' cut that has not moved stable; run it again with --' + recorded.kind + ' and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
488 if (!exception && asked) {
489 const reason = sanitizeReason((asked === 'security' ? o.security : o.regression)!);
490 if (recorded && recorded.reason !== reason) return { ok: false, code: refusalFor(asked), message: 'tag ' + tag + ' records the ' + asked + ' reason "' + recorded.reason + '"; run it again with that same reason so the tag and the notes agree' };
491 const verified = asked === 'security' ? verifySecurity({ reason, git, gh, repo, from: before, to: sha }) : verifyRegression({ reason, git, gh, repo, from: before, to: sha, stable: prevStable });
492 if (!verified.ok) return { ok: false, code: refusalFor(asked), message: verified.why };
493 exception = { kind: asked, reason, evidence: verified.evidence };
494 }
495 const changelog = git(['show', sha + ':CHANGELOG.md']);
496 const date = git(['show', '-s', '--format=%cs', sha]).out;
497 const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, exception });
498 const floor = floorStatus({ git, gh, repo, prevStable, sha, exception: exception?.kind ?? null, scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
499 const ridingAlong = Math.max(0, notes.split('\n').filter(l => l.startsWith('- **')).length - 1);
500 const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, exception, floor, ridingAlong };
501 if (floor.verdict === 'refused' || floor.verdict === 'unknown') return { ok: false, code: floor.verdict === 'refused' ? 'REFUSED_FLOOR' : 'FLOOR_UNKNOWN', message: floor.message!, next: floor.next, plan };
502 return { ok: true, plan };
505export interface CutResult { ok: boolean; lines: string[] }
507/** Tag, push stable and create the release for a plan; each step is skipped when already done. */
508export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDir?: string }): CutResult {
509 const { git, gh } = o;
510 const lines: string[] = [];
511 const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + p.tag + '^{commit}']);
512 if (existing.ok && existing.out !== p.sha) return { ok: false, lines: ['TAG: ' + p.tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + p.sha.slice(0, 12)] };
513 if (!existing.ok) {
514 const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.exception) + ' release)' + (p.exception ? '\n\n' + KIND_LABEL[p.exception.kind] + ': ' + p.exception.reason + '\nVerified: ' + p.exception.evidence : '');
515 const t = git(['tag', '-a', p.tag, p.sha, '-m', message]);
516 if (!t.ok) return { ok: false, lines: [...lines, 'TAG: failed: ' + t.out] };
517 const push = git(['push', 'origin', 'refs/tags/' + p.tag]);
518 if (!push.ok) return { ok: false, lines: [...lines, 'TAG: push failed: ' + push.out] };
519 lines.push('TAG: ' + p.tag + ' -> ' + p.sha.slice(0, 12) + ' pushed');
520 } else {
521 lines.push('TAG: ' + p.tag + ' already names ' + p.sha.slice(0, 12));
522 }
523 if (!p.stableAt) {
524 if (p.prevStable && !git(['merge-base', '--is-ancestor', p.prevStable, p.sha]).ok) return { ok: false, lines: [...lines, 'STABLE: refused: ' + p.sha.slice(0, 12) + ' does not descend from stable ' + p.prevStable.slice(0, 12)] };
525 // The lease pins origin's stable to the commit the floor and the ancestry check read; a move in between fails the push.
526 const push = git(['push', '--force-with-lease=refs/heads/stable:' + (p.prevStable ?? ''), 'origin', p.sha + ':refs/heads/stable']);
527 if (!push.ok) return { ok: false, lines: [...lines, 'STABLE: push failed (stable moved since this plan read it, or the push was rejected): ' + push.out] };
528 lines.push('STABLE: ' + (p.prevStable ? p.prevStable.slice(0, 12) : 'none') + ' -> ' + p.sha.slice(0, 12));
529 } else {
530 lines.push('STABLE: already at ' + p.sha.slice(0, 12));
531 }
532 const view = gh(['release', 'view', p.tag, '--repo', o.repo, '--json', 'tagName']);
533 if (view.status === 0) {
534 lines.push('RELEASE: ' + p.tag + ' already published');
535 } else {
536 const dir = o.notesDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-'));
537 const file = path.join(dir, p.tag + '.md');
538 fs.writeFileSync(file, p.notes);
539 const created = gh(['release', 'create', p.tag, '--repo', o.repo, '--verify-tag', '--title', 'gstack ' + p.tag, '--notes-file', file]);
540 if (created.status !== 0) return { ok: false, lines: [...lines, 'RELEASE: create failed: ' + created.stderr.trim().slice(0, 300)] };
541 lines.push(('RELEASE: ' + p.tag + ' published ' + created.stdout.trim()).trim());
542 }
543 return { ok: true, lines };
546export function describePlan(p: Plan, dryRun: boolean): string[] {
547 const lines = [
548 'CANDIDATE: ' + p.sha + ' (' + p.date + ')' + (p.hotfix ? ' [hotfix: --ref]' : '') + ' gated by ' + p.via,
549 'VERSION: ' + p.version + ' -> tag ' + p.tag + (p.tagExists === 'same' ? ' (exists at this commit)' : ''),
550 'STABLE: ' + (p.stableAt ? 'already at ' + p.sha.slice(0, 12) + ' (finishing an earlier cut)' : (p.prevStable ? p.prevStable.slice(0, 12) + ' (v' + (p.prevVersion ?? '?') + ')' : 'none yet') + ' -> ' + p.sha.slice(0, 12)),
551 'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
552 p.floor.line,
553 ];
554 if (p.exception) lines.push(p.exception.kind.toUpperCase() + ': `' + p.exception.reason + '` (verified: ' + p.exception.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
555 for (const s of p.skipped) lines.push('SKIPPED: ' + s);
556 if (dryRun) {
557 lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
558 ' git tag -a ' + p.tag + ' ' + p.sha + ' && git push origin refs/tags/' + p.tag,
559 ' git push --force-with-lease=refs/heads/stable:' + (p.prevStable ?? '') + ' origin ' + p.sha + ':refs/heads/stable',
560 ' gh release create ' + p.tag + ' --verify-tag --title "gstack ' + p.tag + '" --notes-file <notes>');
561 }
562 return lines;
565function arg(name: string): string | undefined {
566 const i = process.argv.indexOf(name);
567 return i >= 0 ? process.argv[i + 1] : undefined;
570if (import.meta.main) {
571 const repo = arg('--repo');
572 const dryRun = process.argv.includes('--dry-run');
573 const summary = arg('--summary');
574 const security = arg('--security');
575 const regression = arg('--regression');
576 const empty = (flag: string, value: string | undefined) => process.argv.includes(flag) && (!value || value.startsWith('--'));
577 if (!repo || empty('--security', security) || empty('--regression', regression)) {
578 console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n> | --regression <reason naming #n and the v<X> it worked in>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
579 process.exit(2);
580 }
581 const scheduled = process.env.GITHUB_EVENT_NAME === 'schedule';
582 const git = gitIn(process.cwd());
583 const emit = (lines: string[]) => {
584 for (const l of lines) console.log(l);
585 if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
586 };
587 const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security, regression });
588 if (!planned.ok) {
589 emit([planned.code + ': ' + planned.message, ...(planned.next ? ['NEXT_ALLOWED: ' + planned.next] : []), ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s), ...(planned.plan ? describePlan(planned.plan, false) : [])]);
590 // A dry run that finds nothing green has done its job (the skips say why),
591 // and so has a floor hold on a dry run or on the schedule: the policy
592 // working, not a fault. Every other refusal goes red so someone looks.
593 const hold = planned.code === 'REFUSED_FLOOR' && (dryRun || scheduled);
594 if (process.env.GITHUB_ACTIONS === 'true' && planned.code !== 'NOTHING_NEW' && planned.code !== 'NO_GREEN_COMMIT') {
595 const text = planned.code + ': ' + planned.message + (planned.next ? ' NEXT_ALLOWED: ' + planned.next : '');
596 console.log('::' + (hold ? 'notice' : 'error') + ' title=release-cut ' + planned.code + '::' + text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'));
597 }
598 process.exit(planned.code === 'NOTHING_NEW' || (dryRun && planned.code === 'NO_GREEN_COMMIT') || hold ? 0 : 1);
599 }
600 emit(describePlan(planned.plan, dryRun));
601 if (summary) fs.appendFileSync(summary, '\n<details><summary>Release notes</summary>\n\n' + planned.plan.notes + '\n</details>\n');
602 else console.log('--- notes ---\n' + planned.plan.notes);
603 if (dryRun) process.exit(0);
604 const cut = cutRelease(planned.plan, { repo, git, gh: runGh });
605 emit(cut.lines);
606 process.exit(cut.ok ? 0 : 1);