feat(release-gate): gate main every 8 hours and feed the stable release (inert until adopted)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 02:51:14 +00:00
1 parent 923fc0a6b8
commit 2dcbe9dbbd
20 files changed
+1415 -30

No files matched your search

+26 -7
View File
@@ -19,7 +19,7 @@
* the window (7 days, 200 first-parent commits) is the candidate; a commit
* with a check run still going is skipped, not waited for.
*
* Gated means one of two kinds of evidence. Either the backstop check run
* Gated means one of three kinds of evidence. Either the backstop check run
* (--backstop-check, default main-backstop) succeeded: evals-main-backstop.yml
* publishes it, success only when a green train record covers the tree or the
* backstop gated it green, neutral when main moved past the push before it was
@@ -27,8 +27,11 @@
* or merged) lists the commit's sha or its tree in merged[]: those entries are
* the main commits `gstack-ship-queue verify --train` confirmed, so a neutral
* or absent backstop run on such a commit is not held against it, and the
* CANDIDATE line says "gated by train <nonce>". A red backstop run stays red
* either way. A commit with neither is not green.
* CANDIDATE line says "gated by train <nonce>". Or a GREEN release record
* (kind release, lib/release-gate.ts) names the commit: "gated by release gate
* run <id>". A red backstop run stays red either way. A commit with none of
* these is not green. A RED release record with no GREEN one on a later
* commit that contains it holds every cut, like a freeze.
*
* stable moves only forward: a candidate behind the current stable is refused
* (REFUSED non-fast-forward), as is a tag v<VERSION> that already names another
@@ -99,8 +102,8 @@ export function compareVersions(a: string, b: string): number {
export type Verdict = { green: true; via: string } | { green: false; why: string; pending: boolean };
interface CheckRun { name: string; status: string; conclusion: string | null }
/** Main commits a green, merging or merged train record lists in merged[], by sha and by tree, each naming the train. */
export interface TrainEvidence { bySha: Map<string, string>; byTree: Map<string, string> }
/** Main commits a green, merging or merged train record lists in merged[], by sha and by tree, each naming the train; `release` maps a commit a green release gate passed to its run (lib/release-gate.ts). */
export interface TrainEvidence { bySha: Map<string, string>; byTree: Map<string, string>; release?: Map<string, string> }
export interface GateOptions { requiredCheck?: string; backstopCheck?: string; evidence?: TrainEvidence; treeOf?: (sha: string) => string | null }
/** Why a commit is not releasable, or green and through which evidence. */
@@ -118,6 +121,8 @@ export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions =
if (named.some(c => c.conclusion === 'success')) continue;
const train = name === backstop && name !== required && o.evidence ? o.evidence.bySha.get(sha) ?? o.evidence.byTree.get(o.treeOf?.(sha) ?? '') : undefined;
if (train) { via = `train ${train}`; continue; }
const gate = name === backstop && name !== required ? o.evidence?.release?.get(sha) : undefined;
if (gate) { via = `release gate run ${gate}`; continue; }
if (named.length === 0) return { green: false, why: `no ${name} check run`, pending: false };
return { green: false, why: `${name} concluded ${named.map(c => c.conclusion).join(',')}`, pending: false };
}
@@ -129,14 +134,20 @@ export function gateVerdict(gh: Gh, repo: string, sha: string, o: GateOptions =
}
export interface TrainNotes { error: string | null; freeze: string | null; evidence: TrainEvidence }
interface NoteRecord { kind?: string; state?: string; nonce?: string; merged?: Array<{ sha?: string; tree?: string }>; cleared_at?: string | null; reason?: string; train?: string }
interface NoteRecord {
kind?: string; state?: string; nonce?: string; merged?: Array<{ sha?: string; tree?: string }>; cleared_at?: string | null; reason?: string; train?: string;
sha?: string; verdict?: string; run?: { id?: number } | null; red_cases?: string[];
}
/**
* One read of the trains notes ref (fetched from origin): the active freeze
* record (kind freeze, cleared_at null) and the train evidence (lib/
* ship-queue-train-record.ts TrainRecord: kind train, state green, merging or
* merged, merged[].sha and .tree). A fetch failure other than a missing ref is
* an error; a missing ref holds nothing.
* an error; a missing ref holds nothing. Release-gate records (kind release,
* lib/release-gate.ts) add evidence when GREEN, and a RED one with no GREEN
* record on a later commit that contains it is the hold: it refuses the cut
* like a freeze (a train freeze, when both exist, is the one named).
*/
export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
const notes: TrainNotes = { error: null, freeze: null, evidence: { bySha: new Map(), byTree: new Map() } };
@@ -147,6 +158,8 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
}
const listed = git(['notes', `--ref=${NOTES_LOCAL}`, 'list']);
if (!listed.ok || !listed.out.trim()) return notes;
const gates: Array<{ sha: string; verdict: string; red: string[] }> = [];
notes.evidence.release = new Map();
for (const line of listed.out.split('\n')) {
const blob = line.split(' ')[0];
if (!blob) continue;
@@ -155,6 +168,10 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
const r = parsed as NoteRecord;
if (r.kind === 'freeze' && !r.cleared_at) notes.freeze = `queue frozen since train ${r.train ?? '?'}: ${r.reason ?? 'red backstop'}`;
if (r.kind === 'release' && typeof r.sha === 'string' && /^[0-9a-f]{40}$/.test(r.sha)) {
gates.push({ sha: r.sha, verdict: String(r.verdict), red: Array.isArray(r.red_cases) ? r.red_cases.filter(c => typeof c === 'string') : [] });
if (r.verdict === 'GREEN') notes.evidence.release.set(r.sha, String(r.run?.id ?? '?'));
}
if (r.kind !== 'train' || typeof r.nonce !== 'string' || !r.nonce || !['green', 'merging', 'merged'].includes(r.state ?? '')) continue;
for (const m of Array.isArray(r.merged) ? r.merged : []) {
if (!m || typeof m !== 'object') continue;
@@ -162,6 +179,8 @@ export function readTrainNotes(git: Git, notesRef = NOTES_REF): TrainNotes {
if (typeof m.tree === 'string' && m.tree) notes.evidence.byTree.set(m.tree, r.nonce);
}
}
const held = gates.find(red => red.verdict === 'RED' && !gates.some(g => g.verdict === 'GREEN' && g.sha !== red.sha && git(['merge-base', '--is-ancestor', red.sha, g.sha]).ok));
if (held && !notes.freeze) notes.freeze = `release gate red on ${held.sha.slice(0, 12)} (${held.red.join(', ') || 'red cases not recorded'}) and no gate on a later commit is green; land the revert or fix-forward, then run gstack-release-gate run --ignore-cadence`;
return notes;
}
+12 -4
View File
@@ -61,8 +61,14 @@ export type RunMode = 'screen' | 'train' | 'bisect' | 'dispatch';
export interface RouteInput {
nonce: string; refName: string; head: string; expectedSha: string; evalsAll: boolean; trainPolicy: boolean;
cases: string; prefixShas: string; screenPr: string; screenMergeSha: string; screenPolicy: string;
/** The default branch's policy declares a release_gate block (lib/release-gate-policy.ts). */
releasePolicy?: boolean;
}
export interface Route { mode: RunMode; cases: string[]; prefixes: string[] }
/** artifact: the prefix of a release bisect's per-prefix records (a train bisect uses the train block's record_artifact). */
export interface Route { mode: RunMode; cases: string[]; prefixes: string[]; artifact?: string }
/** The release gate's bisect runs on its own ref and names its own prefix records (lib/release-gate.ts RELEASE_REF_PREFIX, RELEASE_BISECT_ARTIFACT). */
export const RELEASE_BISECT_REF = 'release-gate/';
export const RELEASE_BISECT_ARTIFACT = 'gstack-release-record';
const list = (text: string) => text.split(',').map(item => item.trim()).filter(Boolean);
@@ -88,7 +94,8 @@ export function routeDispatch(i: RouteInput, deps: { isAncestor(commit: string,
const cases = list(i.cases);
const prefixes = list(i.prefixShas);
if (scope?.purpose !== 'bisect') return { error: `a bisect needs a bisect nonce (got ${JSON.stringify(i.nonce)})` };
if (!i.refName.startsWith('train/')) return { error: `a bisect runs on train/<nonce>, not ${i.refName}` };
const release = i.refName.startsWith(RELEASE_BISECT_REF);
if (!release && !i.refName.startsWith('train/')) return { error: `a bisect runs on train/<nonce> (or the release gate's ${RELEASE_BISECT_REF}<id>), not ${i.refName}` };
if (i.expectedSha !== i.head) return { error: `expected_sha ${i.expectedSha.slice(0, 12) || '(empty)'} is not the train head ${i.head.slice(0, 12)} checked out from ${i.refName}` };
if (!cases.length || cases.length > MAX_BISECT_CASES || new Set(cases).size !== cases.length) return { error: `cases must name 1 to ${MAX_BISECT_CASES} distinct case ids` };
if (!prefixes.length || prefixes.length > MAX_PREFIXES || new Set(prefixes).size !== prefixes.length) return { error: `prefix_shas must name 1 to ${MAX_PREFIXES} distinct commits` };
@@ -99,6 +106,7 @@ export function routeDispatch(i: RouteInput, deps: { isAncestor(commit: string,
const plan = deps.gateCases();
const unknown = cases.filter(id => !CASE_ID.test(id) || !plan.has(id));
if (unknown.length) return { error: `case(s) not in the full gate's plan: ${unknown.slice(0, 5).join(', ')}` };
if (release) return i.releasePolicy ? { mode: 'bisect', cases, prefixes, artifact: RELEASE_BISECT_ARTIFACT } : { error: "a release bisect needs a release_gate block in the default branch's .gstack/ship-policy.json" };
return trainOnly('bisect') ?? { mode: 'bisect', cases, prefixes };
}
if (scope?.purpose === 'train') {
@@ -359,12 +367,12 @@ if (import.meta.main) {
if (command === 'route') {
const env = process.env;
const route = routeDispatch({ nonce: env.NONCE ?? '', refName: env.GITHUB_REF_NAME ?? '', head: git(['rev-parse', 'HEAD']).out, expectedSha: env.EXPECTED_SHA ?? '',
evalsAll: env.EVALS_ALL === 'true', trainPolicy: env.TRAIN_POLICY === 'true', cases: env.CASES ?? '', prefixShas: env.PREFIX_SHAS ?? '',
evalsAll: env.EVALS_ALL === 'true', trainPolicy: env.TRAIN_POLICY === 'true', releasePolicy: env.RELEASE_POLICY === 'true', cases: env.CASES ?? '', prefixShas: env.PREFIX_SHAS ?? '',
screenPr: env.SCREEN_PR ?? '', screenMergeSha: env.SCREEN_MERGE_SHA ?? '', screenPolicy: env.SCREEN_POLICY ?? '' },
{ isAncestor: (commit, head) => git(['merge-base', '--is-ancestor', commit, head]).ok, gateCases: () => fullGateCases(process.cwd()) });
if ('error' in route) fail(`dispatch refused before any paid work: ${route.error}`);
const ok = route as Route;
output([`mode=${ok.mode}`, `cases=${ok.cases.join(',')}`, `prefixes=${ok.prefixes.join(',')}`]);
output([`mode=${ok.mode}`, `cases=${ok.cases.join(',')}`, `prefixes=${ok.prefixes.join(',')}`, ...(ok.artifact ? [`record_artifact=${ok.artifact}`] : [])]);
console.log(`- Run mode: ${ok.mode}${ok.prefixes.length ? ` (${ok.cases.length} case(s) on ${ok.prefixes.length} prefix(es))` : ''}`);
} else if (command === 'bisect-plan') {
const [cases, prefixes, nonce, out] = [list(flag('cases') ?? ''), list(flag('prefixes') ?? ''), flag('nonce'), flag('out')];