mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
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:
1 parent
526f604f91
commit
69f0fe181a
6 files changed
+66
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user