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
16 changes: 15 additions & 1 deletion .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,21 @@ concurrency:
jobs:

lint:
name: ESLint
# ⚠️ This `name:` IS the required-status-check context in repository
# Settings → Rulesets (a job's check-run name is its `name:`), so it is
# pinned as contract by `scripts/check-required-contexts.mjs` and may only
# be changed together with that registry AND the Settings entry, in one
# maintainer-present sitting (#9325 ruling 2026-08-17). Either half alone
# is an outage: rename-first leaves the old context permanently pending,
# which wedges every open PR and the merge queue; settings-first drops the
# whole gate family to advisory with no signal anywhere, which is #5617
# verbatim.
#
# It is not called `ESLint` any more because it never was only that: the
# `pnpm lint` step below is one of ~70 sequential gate steps, so a red here
# was routinely read as "a lint problem" when it was a repo gate — three
# mis-routed diagnoses on 2026-08-17 alone (#9258, PRs #9256/#9291).
name: Lint & Repo Gates
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,10 +333,13 @@ Even inside your own worktree, operate defensively:
yet"; `in_progress` is not a pass. Arming a red PR does not queue it, it hides it:
every poll then misreads "not on `main` yet" as "queued". Always read *two* things
when checking a landing: the queue branch **and** `origin/main`. And **the queue
enforces only the required set** — **ESLint** and **TypeScript Type Check** block by
maintainer decision (2026-08-07); everything else is advisory and rides through, and
an advisory red that lands rides `main`'s merge ref into every following PR until
stanched. That is why "arm only on green" is a rule, not a formality.
enforces only the required set** — **Lint & Repo Gates** (the whole `check:*` gate
family, formerly published under a name that described only one of its steps) and
**TypeScript Type Check** block by maintainer decision (2026-08-07); everything else
is advisory and rides through, and an advisory red that lands rides `main`'s merge ref
into every following PR until stanched. A required context is matched by check-run
name, so a job rename detaches its gate silently — treat those names as contract.
That is why "arm only on green" is a rule, not a formality.

**Re-arm awareness** — none of these is a reason to avoid the queue; all are reasons
to confirm a PR is still *in* it: a red queue build **ejects** your entry and drops
Expand Down
42 changes: 32 additions & 10 deletions scripts/check-required-contexts.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,8 +157,19 @@ export const REQUIRED_CONTEXTS = [
{
workflow: 'lint.yml',
job: 'lint',
context: 'ESLint',
authorized: '#5617 maintainer ruling 2026-08-07 — applied to the settings the same day',
// Renamed from 'ESLint' under the #9325 ruling: the old name described one
// of the job's steps and mis-routed the diagnosis of every other gate it
// carries. The rename lands in two halves that CANNOT be atomic — this
// registry + the workflow `name:` here, and the required-context entry in
// repository Settings → Rulesets — so it is done in one maintainer-present
// sitting: merge, then swap the Settings entry immediately. Either half
// alone is an outage (permanently-pending, or advisory-with-no-signal =
// #5617). If you are reading this while a required context named 'ESLint'
// is still live in Settings, that swap has not happened yet.
context: 'Lint & Repo Gates',
authorized:
'#5617 maintainer ruling 2026-08-07 (enrolment, applied to the settings the same day); ' +
'renamed from `ESLint` by the #9325 maintainer ruling 2026-08-17, swapped in the settings in the same sitting as the merge',
carries: 'the whole check:* gate family — the job whose red did not block #5584',
},
{
Expand DownExpand Up@@ -540,12 +551,21 @@ async function selfTest() {
// Predicted direction: RED, naming the job, the new name and the required
// context. A rename is the whole defect #6865 is about, so a green here would
// mean the gate is decorative.
const renamedEslint = fixture('rename ESLint', 'lint.yml', (s) => s.replace(' name: ESLint\n', ' name: ESLint (fast)\n'));
// The anchor is the CURRENT name of the gate-family job (renamed away from
// `ESLint` by #9325); the mutation restores the OLD spelling on purpose, so
// this fixture doubles as the regression test for that rename: reverting
// lint.yml alone, without this registry, is exactly the half-landed state
// the #9325 sequencing exists to prevent, and it must be red.
const renamedGateJob = fixture('rename the gate-family job back to ESLint', 'lint.yml', (s) =>
s.replace(' name: Lint & Repo Gates\n', ' name: ESLint\n'),
);
assert(
renamedEslint.problems.some((p) => p.includes("job 'lint'") && p.includes('"ESLint (fast)"') && p.includes("'ESLint'") && p.includes('The name IS the contract')),
"renaming lint.yml's ESLint job ⇒ red, naming the job, the new name and the required context",
renamedGateJob.problems.some(
(p) => p.includes("job 'lint'") && p.includes('"ESLint"') && p.includes("'Lint & Repo Gates'") && p.includes('The name IS the contract'),
),
"renaming lint.yml's gate-family job ⇒ red, naming the job, the new name and the required context",
);
assert(renamedEslint.problems.length === 1, `renaming ESLint produces exactly the one finding — got ${JSON.stringify(renamedEslint.problems)}`);
assert(renamedGateJob.problems.length === 1, `renaming the gate-family job produces exactly the one finding — got ${JSON.stringify(renamedGateJob.problems)}`);

// The second-batch half: a name the maintainer approved on 2026-08-09, which
// had no assertion of any kind before this script.
Expand DownExpand Up@@ -609,7 +629,7 @@ async function selfTest() {
// ── (6) the merge_group trigger ──────────────────────────────────────────
const noQueue = fixture('drop merge_group from lint.yml', 'lint.yml', (s) => s.replace('\n merge_group:\n', '\n'));
assert(
noQueue.problems.some((p) => p.includes('merge_group') && p.includes('ESLint') && p.includes('TypeScript Type Check')),
noQueue.problems.some((p) => p.includes('merge_group') && p.includes('Lint & Repo Gates') && p.includes('TypeScript Type Check')),
'a required-context workflow without merge_group ⇒ red, naming every context it would strand',
);
assert(
Expand DownExpand Up@@ -782,7 +802,7 @@ async function selfTest() {
const lintJob = uncommented(lintJobStart === -1 ? '' : sources['lint.yml'].slice(lintJobStart, lintJobEnd === -1 ? undefined : lintJobEnd));
assert(
/run: pnpm check:required-contexts\b/.test(lintJob),
'wiring: lint.yml\'s ESLint job must run `pnpm check:required-contexts` — an unwired pin verifies nothing (#4690)',
'wiring: lint.yml\'s `lint` job (the "Lint & Repo Gates" context) must run `pnpm check:required-contexts` — an unwired pin verifies nothing (#4690)',
);
const step = lintJob.split(/\n(?= - name: )/).find((s) => /run: pnpm check:required-contexts\b/.test(s)) ?? '';
assert(
Expand All@@ -794,8 +814,10 @@ async function selfTest() {
assert(/check-required-contexts\.mjs --self-test/.test(wiring), 'wiring: `check:required-contexts` must run this file\'s --self-test first');
assert(/check-required-contexts\.mjs(?! --self-test)/.test(wiring), 'wiring: `check:required-contexts` must also run the real pin, not only the self-test');
// The pin lives in a job it also pins. That is deliberate and worth stating:
// renaming the ESLint job turns this gate red under the NEW name, while the
// old required context stops reporting — the PR is blocked from both sides.
// renaming the gate-family job turns this gate red under the NEW name, while
// the old required context stops reporting — the PR is blocked from both
// sides. That is also why the #9325 rename could not be self-serve: the
// repo-side half is checkable here, the Settings half is not reachable at all.
assert(
REQUIRED_CONTEXTS.some((entry) => entry.workflow === 'lint.yml' && entry.job === 'lint'),
'wiring: the job this gate runs in is itself registered, so a rename of it cannot be the one rename nothing notices',
Expand Down
Loading