Skip to content

Commit 9b207e0

Browse files
committed
ci: add an always-run roll-up job named CI so the queue's required check matches a real check run (#9318)
Branch protection matches a required check by CHECK-RUN name, and a job's check-run name is its `name:` value. Registering `CI` — ci.yml's WORKFLOW name — matched no check run at all: the context sat permanently "Expected", green merge groups timed out and nothing merged for ~1h45m on 2026-08-17. This job makes the name real, implementing #9283's Option A ruling in the durable form: one stable required context that rolls up every gate job, so adding a gate extends the queue's protection with no settings change. - `ci-rollup` / `name: CI`, `if: always()`, `needs:` filter + the six gate jobs (test-gate, dogfood-gate, temporal-conformance, build-core, build-docs, console-pin). The sharded matrices are deliberately absent: their verdicts arrive through the aggregates, which count per-shard attestations instead of reading the one aggregate word #6082 measured going wrong both ways. - The verdict is computed from `needs.*.result` explicitly, because default propagation would SKIP this job on an upstream failure and a skipped required context is the #3622 deadlock. success/skipped pass; failure/cancelled fail; an unrecognised runner lifecycle value (`abandoned`, #6082) fails closed; an empty `needs` context fails rather than ticking (#4690). - Pins the literal in `check-required-contexts.mjs` (#6865), so a rename of the roll-up fails in the ESLint job instead of silently detaching the gate — the one name in the repo where that protection matters most. Fixes #9318 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYgmGheCzM6NrHZN436Cxf
1 parent 0ccea4a commit 9b207e0

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,3 +1444,161 @@ jobs:
14441444
with:
14451445
path: packages/console/dist
14461446
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
1447+
1448+
# ── The queue's single required context (#9283 / #9318) ───────────────────
1449+
#
1450+
# THE NAME IS THE CONTRACT. Branch protection and the merge queue match a
1451+
# required check BY CHECK-RUN NAME, and a job's check-run name is its `name:`
1452+
# value. `name: CI` here is therefore the required-context literal, not a
1453+
# label: renaming this job silently detaches whatever branch protection lists
1454+
# — the identical trap `temporal-conformance` records above ("the name IS the
1455+
# required check, so renaming it would silently drop the gate"). Since #6865
1456+
# that half is asserted rather than only written down: this literal is pinned
1457+
# by `check:required-contexts`, so a rename fails in the ESLint job instead of
1458+
# in the queue.
1459+
#
1460+
# ── Why this job exists: two incidents, opposite failure modes ─────────────
1461+
#
1462+
# - #9283: `Temporal Conformance (live PG + MySQL)` failed and the PR merged
1463+
# anyway, because that job's name was not in the required set. The
1464+
# maintainer's ruling was Option A — the full suite SHOULD block the merge
1465+
# queue — and the required set then had to name every gate job one by one,
1466+
# a list that drifts every time a gate is added or renamed.
1467+
# - The first fix attempt registered the required check as `CI`, which is
1468+
# this WORKFLOW's name (line 1) and matched no check run at all. A
1469+
# workflow name is not a check-run name. The context sat permanently
1470+
# "Expected", green merge groups timed out, and NOTHING merged for ~1h45m
1471+
# on 2026-08-17 (11:29Z until the maintainer removed the entry ~13:15Z).
1472+
# The failure mode had flipped from "red merges anyway" to "green cannot
1473+
# merge".
1474+
#
1475+
# This job makes that name real. One context — `CI` — covers every gate below
1476+
# it, so the required set stops being a hand-maintained roster of job names:
1477+
# adding a gate to `needs:` here extends the queue's protection with no
1478+
# settings change, which is the durable form of the #9283 ruling.
1479+
#
1480+
# ⛔ Sequencing, recorded because it is what the second incident cost: this
1481+
# job must be LIVE ON `main` before `CI` goes back into the required set, and
1482+
# only the maintainer can make that settings change (no agent seat can read or
1483+
# write Settings → Rulesets — the API answers 403; see
1484+
# scripts/check-required-contexts.mjs).
1485+
#
1486+
# ── What it needs, and what it deliberately does not ──────────────────────
1487+
#
1488+
# `needs:` lists the jobs that carry a GATE VERDICT, plus `filter`:
1489+
#
1490+
# test-gate · dogfood-gate · temporal-conformance · build-core ·
1491+
# build-docs · console-pin
1492+
#
1493+
# `filter` is included because it is not merely plumbing here: if `filter`
1494+
# itself fails, every downstream `if:` evaluates against EMPTY outputs (see
1495+
# THE FILTER CONTRACT, #4928), so no scheduling decision in the run was
1496+
# actually made and a green roll-up would certify a run whose shape nobody
1497+
# chose. `test-gate` and `dogfood-gate` already read `needs.filter.result` for
1498+
# that reason; this makes it true for the run as a whole.
1499+
#
1500+
# The sharded matrices (`test`, `dogfood`, `dogfood-verify`) are deliberately
1501+
# ABSENT. Their verdicts reach this job through their aggregate gates, which
1502+
# COUNT per-shard attestations instead of reading one aggregate word — and
1503+
# #6082 measured both directions of what reading that word does: run
1504+
# 31120902911 painted a false red off the undocumented `abandoned`, and run
1505+
# 31114735713 had that same value SWALLOW a shard's real `failure`. Listing
1506+
# the matrices here would reintroduce exactly that read, one level up.
1507+
#
1508+
# ── Why the results are read explicitly ───────────────────────────────────
1509+
#
1510+
# `if: always()` is load-bearing twice over. Without it, a needed job that
1511+
# fails makes this job SKIP, and a skipped required context publishes nothing
1512+
# (permanent pending — the #3622 deadlock) or reads as green; and a docs-only
1513+
# PR, where every core gate is legitimately `skipped` by `filter`, must still
1514+
# publish a green `CI` or nothing could ever merge. Default `needs:`
1515+
# propagation cannot express that, which is why the verdict is computed from
1516+
# `needs.*.result` in the step below rather than left to GitHub.
1517+
#
1518+
# The verdict table:
1519+
#
1520+
# success → pass. The gate ran and was green.
1521+
# skipped → pass. `filter` said this run does not touch that surface; the
1522+
# aggregate gates apply #4928's own guard to their skips.
1523+
# failure → FAIL.
1524+
# cancelled → FAIL. This diverges on purpose from the shard gates, which
1525+
# pass `cancelled` for #3668's reason (a superseded push cancels
1526+
# the in-flight matrix, and failing there reds a SHA nobody is
1527+
# merging). At whole-gate level the same word means the gate
1528+
# never reached a verdict at all, and on a `merge_group` build
1529+
# it means the queue build was dropped — neither is something a
1530+
# green `CI` may certify. The superseded-SHA cost is unchanged:
1531+
# a newer run publishes newer contexts on the newer SHA.
1532+
# anything → FAIL, loudly, naming the value. Runners really do produce
1533+
# else undocumented lifecycle values (`abandoned`, #6082), and the
1534+
# maintainer REJECTED whitelisting one as a pass on 2026-08-07:
1535+
# a discarded job is not moot while the queue is still consuming
1536+
# the run's verdicts, so passing it publishes green over a gate
1537+
# that never ran. A false red costs a re-run; a false green
1538+
# merges unverified code.
1539+
#
1540+
# An empty `needs` context is a failure too, never a pass: a roll-up that read
1541+
# nothing has verified nothing (#4690).
1542+
ci-rollup:
1543+
name: CI
1544+
needs:
1545+
- filter
1546+
- test-gate
1547+
- dogfood-gate
1548+
- temporal-conformance
1549+
- build-core
1550+
- build-docs
1551+
- console-pin
1552+
if: always()
1553+
runs-on: ubuntu-latest
1554+
timeout-minutes: 10
1555+
permissions:
1556+
contents: read
1557+
steps:
1558+
# No checkout: the whole input is the `needs` context, which the runner
1559+
# already has. Cost is a few seconds of one ubuntu-latest runner per run.
1560+
- name: Roll up every gate verdict
1561+
env:
1562+
# The whole context, so this step covers whatever `needs:` above
1563+
# lists — one roster to maintain instead of two that can disagree.
1564+
OS_NEEDS: ${{ toJSON(needs) }}
1565+
run: |
1566+
set -euo pipefail
1567+
1568+
if ! command -v jq >/dev/null 2>&1; then
1569+
echo "::error::jq is unavailable on this runner, so no gate verdict could be read. Refusing to publish a green CI over an unread roll-up (#4690)."
1570+
exit 1
1571+
fi
1572+
1573+
verdicts="$(printf '%s' "$OS_NEEDS" | jq -r 'to_entries[] | "\(.key) \(.value.result // "«absent»")"' | sort)"
1574+
1575+
if [ -z "$verdicts" ]; then
1576+
echo "::error::the needs context is empty — this roll-up read no gate verdicts at all, so it cannot report success (#4690)."
1577+
exit 1
1578+
fi
1579+
1580+
failed=0
1581+
while read -r job result; do
1582+
case "$result" in
1583+
success|skipped)
1584+
printf ' ok %-24s %s\n' "$job" "$result"
1585+
;;
1586+
failure|cancelled)
1587+
printf ' FAIL %-24s %s\n' "$job" "$result"
1588+
echo "::error::gate '$job' concluded '$result' — CI is red."
1589+
failed=1
1590+
;;
1591+
*)
1592+
printf ' FAIL %-24s %s (unrecognised)\n' "$job" "$result"
1593+
echo "::error::gate '$job' concluded '$result', which is not one of success/skipped/failure/cancelled. Treating an unrecognised runner lifecycle value as a PASS would publish a green required context over a gate that never ran (#6082); failing closed instead."
1594+
failed=1
1595+
;;
1596+
esac
1597+
done <<< "$verdicts"
1598+
1599+
if [ "$failed" -ne 0 ]; then
1600+
echo "::error::CI roll-up failed — see the per-gate lines above."
1601+
exit 1
1602+
fi
1603+
1604+
echo "✓ CI: every gate this run scheduled is green (skips counted as pass)."

‎scripts/check-required-contexts.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,25 @@ export const REQUIRED_CONTEXTS = [
210210
authorized: '#5617 closing ruling 2026-08-09, second batch',
211211
carries: 'the live-server datetime conformance axis (#3912/#3942)',
212212
},
213+
{
214+
workflow: 'ci.yml',
215+
job: 'ci-rollup',
216+
context: 'CI',
217+
// Enrolled AHEAD of the settings change, deliberately, and this is the one
218+
// row where that ordering is the point. #9283's ruling (Option A — the full
219+
// suite blocks the queue) was first applied by registering `CI`, which is
220+
// ci.yml's WORKFLOW name and matched no check run: the context sat
221+
// permanently "Expected" and nothing merged for ~1h45m on 2026-08-17. The
222+
// roll-up job makes the name real, and the maintainer re-adds `CI` to the
223+
// required set only AFTER this lands. Until then the row is inert, which
224+
// this registry's header already licenses ("adding a row here does NOT make
225+
// a context required … a pin that is merely inert, never wrong") — while
226+
// the pin it buys is live from day one: the whole point of the roll-up is
227+
// that one stable string survives every job rename beneath it, so that
228+
// string is exactly what must not move unnoticed.
229+
authorized: '#9283 maintainer ruling (Option A, 2026-08-17) implemented by #9318; the settings entry is the maintainer step that follows this merge',
230+
carries: 'every gate job in ci.yml, rolled up — the single required context that replaces the per-job roster the queue used to need',
231+
},
213232
{
214233
workflow: 'adr-merge-approval.yml',
215234
job: 'adr-merge-approval',

0 commit comments

Comments
 (0)