diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b00d57ad9..f369fa7a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,7 +300,12 @@ jobs: # using main-red-alert.yml. One trigger per path, one shared policy script. post-main: name: Main Red Alert (dispatched) - needs: [quality, auth-smoke, inventory-smoke, e2e-local, security] + # MUST list every other job in this file. The alarm's verdict has to mean + # the same thing as the merge gate's, and the gate reads the RUN's + # conclusion — which every job contributes to. Enforced by + # src/__tests__/ci/main-red-verdict.test.ts, because this list has already + # drifted once (see the commit that added this comment). + needs: [quality, auth-smoke, inventory-smoke, migrations, e2e-local, test, security] if: always() && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 5 @@ -319,15 +324,29 @@ jobs: R_QUALITY: ${{ needs.quality.result }} R_AUTH: ${{ needs.auth-smoke.result }} R_INVENTORY: ${{ needs.inventory-smoke.result }} + R_MIGRATIONS: ${{ needs.migrations.result }} R_E2E: ${{ needs.e2e-local.result }} + R_TEST: ${{ needs.test.result }} R_SECURITY: ${{ needs.security.result }} run: | set -euo pipefail - # `skipped` is not a failure — jobs here are conditional on event type. - results="$R_QUALITY $R_AUTH $R_INVENTORY $R_E2E $R_SECURITY" + # The question this answers is NOT "did the code break?" but "is main + # blocking the merge queue?" — because that is what the auto-merge + # green-base guard asks, and the two must agree. + # + # So anything that is not success-or-skipped counts as red, including + # `cancelled`. A cancelled job on main blocks every open PR exactly as + # hard as a failing one; treating it as a non-event is what let main + # sit red and SILENT for ~14h on 2026-08-07 while 11 PRs waited. + # (`skipped` stays green: jobs here are conditional on event type.) + results="$R_QUALITY $R_AUTH $R_INVENTORY $R_MIGRATIONS $R_E2E $R_TEST $R_SECURITY" conclusion=success - case "$results" in *cancelled*) conclusion=cancelled ;; esac - case "$results" in *failure*) conclusion=failure ;; esac + for result in $results; do + case "$result" in + success|skipped) ;; + *) conclusion=failure ;; + esac + done echo "conclusion=$conclusion" >> "$GITHUB_OUTPUT" echo "verdict: $conclusion (from: $results)" diff --git a/src/__tests__/ci/main-red-verdict.test.ts b/src/__tests__/ci/main-red-verdict.test.ts new file mode 100644 index 000000000..7b6e63f52 --- /dev/null +++ b/src/__tests__/ci/main-red-verdict.test.ts @@ -0,0 +1,99 @@ +/** + * @jest-environment node + * + * The main-red alarm and the auto-merge green-base guard must mean the same + * thing by "main is green". + * + * They did not. The guard reads the RUN's conclusion — every job contributes. + * The alarm recomputed its own verdict from a hand-written `needs:` list that + * omitted two jobs. On 2026-08-07 an Actions incident cancelled Migration + * Drift on main: the run went `failure`, auto-merge refused every merge for + * ~14h, and the alarm — blind to that job — concluded `success` and filed + * nothing. The queue was stopped and the thing built to say so stayed quiet. + * + * A hand-maintained list will drift again, so this asserts it cannot. + */ +import { readFileSync } from 'fs' +import { resolve } from 'path' + +const CI_YML = resolve(__dirname, '../../../.github/workflows/ci.yml') +const ALARM_JOB = 'post-main' + +const workflow = readFileSync(CI_YML, 'utf8') + +/** + * Top-level job ids, by indentation. + * + * Deliberately not a YAML library: neither `yaml` nor `js-yaml` is a declared + * dependency of this repo (both are only transitively hoisted), and a test + * that guards CI should not rest on a package that a lockfile change could + * remove. The shape being parsed is one we own and is two levels deep. + */ +function jobIds(src: string): string[] { + const lines = src.split('\n') + const start = lines.findIndex((l) => /^jobs:\s*$/.test(l)) + if (start === -1) throw new Error('ci.yml has no top-level `jobs:` key') + + const ids: string[] = [] + for (const line of lines.slice(start + 1)) { + if (/^\S/.test(line)) break // dedented back to a top-level key + const match = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line) + if (match) ids.push(match[1]) + } + return ids +} + +/** The `needs: [...]` list of a given job. */ +function needsOf(src: string, jobId: string): string[] { + const section = src.split(new RegExp(`^ {2}${jobId}:\\s*$`, 'm'))[1] + if (section === undefined) throw new Error(`ci.yml has no job \`${jobId}\``) + const match = /^ {4}needs:\s*\[([^\]]*)\]/m.exec(section) + if (!match) throw new Error(`job \`${jobId}\` declares no inline needs: [...]`) + return match[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} + +describe('main-red alarm verdict', () => { + const ids = jobIds(workflow) + + it('parses the workflow it is guarding', () => { + // Guards the parser itself: a regex that silently matched nothing would + // make every assertion below trivially true. + expect(ids).toContain(ALARM_JOB) + expect(ids.length).toBeGreaterThan(3) + }) + + it('waits for EVERY other job before deciding main is green', () => { + const expected = ids.filter((id) => id !== ALARM_JOB).sort() + const actual = needsOf(workflow, ALARM_JOB).sort() + + // If this fails you added a job and did not add it here. Add it to both + // `needs:` and the R_* env block — otherwise that job can fail on main, + // block the whole merge queue, and never raise the alarm. + expect(actual).toEqual(expected) + }) + + it('feeds every needed job into the verdict', () => { + const section = workflow.split(/^ {2}post-main:\s*$/m)[1] + const referenced = new Set( + [...section.matchAll(/needs\.([A-Za-z0-9_-]+)\.result/g)].map((m) => m[1]), + ) + + for (const id of needsOf(workflow, ALARM_JOB)) { + // A job can be in `needs:` and still be missing from the R_* env block, + // which reintroduces exactly the blind spot this file exists to prevent. + expect([...referenced]).toContain(id) + } + }) + + it('treats anything that is not success or skipped as red', () => { + const section = workflow.split(/^ {2}post-main:\s*$/m)[1] + + // `cancelled` must NOT be carved out as its own benign conclusion: a + // cancelled job on main blocks the queue just as hard as a failing one. + expect(section).toMatch(/success\|skipped\)/) + expect(section).not.toMatch(/conclusion=cancelled/) + }) +})