Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)"

Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/ci/main-red-verdict.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
Loading