fix(release-gate): a missing report artifact or a sha-less failure fails closed (GST-1263) (#3982)

* fix(release-gate): a missing report artifact or a sha-less failure fails closed (GST-1263)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-04 02:58:09 +00:00
1 parent 526f604f91
commit 69f0fe181a
6 files changed
+66 -11

No files matched your search

+30 -3
View File
@@ -59,8 +59,8 @@ const TRAIN = { gate_workflow: 'evals.yml', record_artifact: 'gstack-train-recor
describe('release-gate.yml', () => {
// Value: protects=the schedule fires every RELEASE_GATE_SCHEDULE_HOURS (the cadence floor the policy validates), one gate at a time, only from the
// default branch, with contents and actions write but no checks write, and runs the bin without --sha (main's head from the remote, so an open gate on an older commit
// is finished first) with its outputs wired, and a held or failed verdict leaves the job green (only a crash fails it, so main's head never carries a
// failed native check release-cut would read as red); the policy step
// is finished first) with its outputs wired, and a held or failed verdict on a gated commit leaves the job green (so main's head never carries a
// failed native check release-cut would read as red; the exit is exercised below); the policy step
// uses git and jq only and is inert without a release_gate block; fails_when=the cron and the cadence drift apart, two gates overlap, the job that
// runs repository code can write check runs, or an inert policy reaches bun; why_new=new workflow; seam=none; no_claim=GitHub's cron jitter
test('schedule, concurrency, permissions and the gate step', () => {
@@ -84,11 +84,38 @@ describe('release-gate.yml', () => {
expect(gate.run).toContain('./bin/gstack-release-gate run --repo "$GITHUB_REPOSITORY" --budget-minutes');
expect(gate.run).not.toContain('--sha');
expect(gate.run).toContain("|| rc=$?");
expect(gate.run).toContain(`if [ "$rc" != 0 ] && ! grep -qE '^action=(held|failed)$' "$GITHUB_OUTPUT"; then exit "$rc"; fi`);
expect(gate.run).toContain(`if [ "$rc" != 0 ] && ! { grep -qE '^action=(held|failed)$' "$GITHUB_OUTPUT" && grep -qE '^sha=[0-9a-f]{40}$' "$GITHUB_OUTPUT"; }; then exit "$rc"; fi`);
expect(gate.run).toContain('--outputs "$GITHUB_OUTPUT"');
expect(job.outputs).toEqual({ action: '${{ steps.gate.outputs.action }}', sha: '${{ steps.gate.outputs.sha }}', check: '${{ steps.gate.outputs.check }}' });
});
// Value: protects=the gate step's exit: a held or failed verdict on a gated commit leaves the job green (release-check publishes its check run), but a
// failed verdict with no commit (a refused policy, unreadable notes, an unresolved branch) or no recorded action fails the job, since no check run
// follows it; fails_when=a sha-less failure leaves the job green while the backstop stands down (GST-1263); why_new=reader note on #3917;
// seam=a stub bin in place of bin/gstack-release-gate; no_claim=a live scheduled run
test('the gate step fails the job when the bin fails without a gated commit', () => {
const gate = release.jobs['release-gate']!.steps.find(s => s.id === 'gate')!;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'release-gate-step-'));
scratch.push(dir);
const sha = 'a'.repeat(40);
const exit = (written: string, code: number) => {
const stub = path.join(dir, 'stub');
fs.writeFileSync(stub, `#!/usr/bin/env bash\nprintf '%s' '${written}' >> "$GITHUB_OUTPUT"\nexit ${code}\n`, { mode: 0o755 });
const out = path.join(dir, 'out');
fs.writeFileSync(out, '');
const r = spawnSync('bash', ['-e', '-c', gate.run!.replace('./bin/gstack-release-gate', stub)], {
cwd: dir, encoding: 'utf8', timeout: 30_000,
env: { ...process.env, GITHUB_OUTPUT: out, GITHUB_STEP_SUMMARY: path.join(dir, 'summary'), GITHUB_REPOSITORY: 'o/r', DRY_RUN: '', IGNORE_CADENCE: '' },
});
return r.status;
};
expect(exit(`action=gated\nsha=${sha}\ncheck=main-backstop\n`, 0)).toBe(0);
expect(exit(`action=held\nsha=${sha}\ncheck=main-backstop\n`, 1)).toBe(0);
expect(exit(`action=failed\nsha=${sha}\ncheck=main-backstop\n`, 1)).toBe(0);
expect(exit('action=failed\nsha=\ncheck=\n', 1)).toBe(1);
expect(exit('', 1)).toBe(1);
});
// Value: protects=the gated commit's check run (what release-cut reads) comes from a job with checks: write and no checkout or action, only for a
// gate that ran (success for gated, failure for held, neutral for failed so a later green on the same sha stays releasable), on a validated sha
// and check name; fails_when=repository code runs with checks: write, a skipped or inert run publishes a check, a red gate publishes success, or
+19
View File
@@ -174,6 +174,18 @@ describe('release gate: run', () => {
expect(activeHold(records(f), () => true)).toBeNull();
});
// Value: protects=a successful gate run whose report-verdict artifact is missing records INCOMPLETE, prints no green line and publishes no gated
// action, so a lost artifact never reads as a green main; fails_when=run success alone records GREEN (GST-1263); why_new=reader note on #3917;
// seam=fake gh; no_claim=a real artifact upload failure
test('a successful run with no report artifact is incomplete, never gated', () => {
const f = fixture();
const fake = fakeGh(() => ({ conclusion: 'success', artifacts: {} }));
const out = runGate(envFor(f, fake.gh), { gate: GATE, label: 'test', dryRun: false, ignoreCadence: true });
expect(out.action, out.lines.join('\n')).toBe('failed');
expect(out.lines.some(l => l.startsWith('RELEASE: green'))).toBe(false);
expect(records(f)[0]).toMatchObject({ sha: f.c[4], verdict: 'INCOMPLETE' });
});
// Value: protects=a gate run still going at the budget stays PENDING with its run, and the next run finishes that verdict on the old commit before
// gating main's new head, so a red verdict still records RED and holds release-cut after main moved; the new head is gated on the run after;
// fails_when=main moving orphans a PENDING record and its red verdict never becomes a hold; why_new=review finding (red-team, l1-p1); seam=fake gh;
@@ -346,6 +358,13 @@ describe('release gate: verdict, range and policy units', () => {
expect(classifyRun({ status: 'in_progress', conclusion: null }, null).verdict).toBe('PENDING');
});
// Value: protects=green needs the run's success and a report that says GREEN; a missing or verdict-less report on a successful run is INCOMPLETE;
// fails_when=a lost or truncated report-verdict artifact records GREEN (GST-1263); why_new=reader note on #3917; seam=none; no_claim=report-verdict's writer
test('classifyRun: a successful run without a GREEN report is incomplete', () => {
expect(classifyRun({ status: 'completed', conclusion: 'success' }, null)).toMatchObject({ verdict: 'INCOMPLETE', detail: 'the run concluded success but its report artifact is missing' });
expect(classifyRun({ status: 'completed', conclusion: 'success' }, { verdict: 'NONE', red: [], other: [], cost: null }).verdict).toBe('INCOMPLETE');
});
test('planPrefixes spreads at most MAX_PREFIXES over the interior; narrow moves good and bad and refuses a non-monotone or missing result', () => {
const shas = Array.from({ length: 20 }, (_, i) => String(i).padStart(40, '0'));
expect(planPrefixes(shas.slice(0, 3))).toEqual(shas.slice(0, 3));