mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
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:
1 parent
923fc0a6b8
commit
2dcbe9dbbd
20 files changed
+1415
-30
No files matched your search
+26
-7
@@ -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
@@ -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')];
|
||||
|
||||
Reference in new issue
Block a user