Eleven required contexts, and raising the Node floor renames two of them - #803
Merged
Conversation
`main` requires eleven checks. Two of them, `check (22.23.2)` and `check (24)`, are matrix values interpolated into the context name, and the comment on that matrix explains why the first is pinned to a patch: a declared floor must name the exact release CI executes. So raising the floor renames the required context, and no commit can satisfy protection until someone edits the settings. Today `enforce_admins` is false, so that state is survivable -- an owner can push past it. It is exactly the escape that a stronger protection removes, which makes the rename a reason not to strengthen protection rather than a reason to. That is backwards, and it is a CI shape problem rather than a policy one. A fan-in fixes it. `gate` needs every other job in `ci.yml` and fails unless all of them succeeded, so protection can require one stable name that no version bump can rename. Two details carry the weight. `if: always()` is not decoration: without it a failed dependency SKIPS the gate rather than failing it, and a skipped required check is not a failure to GitHub -- the gate would wave through precisely the runs it exists to stop. And each result is compared against `success` rather than against `failure`, because `skipped` and `cancelled` are neither. It covers `ci.yml` alone. `lint` lives in `demo-lint.yml` and cannot be a `needs:` from another workflow, so protection wants two contexts rather than one: `gate` and `lint`. Neither carries a matrix value, so neither can be renamed by a version bump, which is the property that was missing. The test asserts the gate fans in from every job in the file. A job added later and left out of `needs:` would be silently outside protection -- the same shape as a results file left off a declared list and silently ungated, which is what `bench/verify.mjs` was rewritten to prevent. That scope is default-in and this one cannot be, so it is asserted instead of assumed. Record-Id: r-cifaningate Provenance: authored Certainty: firm Blast: local Undo: easy Ruled-out: keeping eleven required contexts and pinning the Node floor forever | it trades a routine upgrade for a frozen branch, and the pin exists so the floor is checkable, not so it never moves Ruled-out: `if: success()` or omitting the condition | a failed dependency then skips the gate instead of failing it, and GitHub does not treat a skipped required check as a failure Ruled-out: comparing each result against `failure` | `skipped` and `cancelled` are not `failure` and are not success either Ruled-out: moving `lint` into `ci.yml` to reach a single context | it runs on a different trigger, and two stable names solve the rename problem as completely as one Limit: this makes a single stable context possible. It does not change branch protection -- `enforce_admins` is still false and a pull request is still not required, so a green SHA can still be pushed straight to `main` by anyone with admin rights. Flipping those two bits is a settings change this repository's tooling does not make Verified: the gate's coverage was checked by parsing the workflow rather than by reading it -- every job id in the file appears in `needs`, and no `needs` entry names a job that is absent. Two negative controls, each observed to fail: dropping one job from `needs` fails the coverage assertion, and removing `if: always()` fails the skip assertion. Both restored with the file byte-identical CommitLore-Version: 2.0.0
CommitLore — record lintTrailers: clean — 3 commits in Active constraints for the paths this PR touchesLimits (57)
Ruled out (112)
Warnings (15)
Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR. |
Adding a `gate` job to `ci.yml` broke CI in three places, and none of the three
was a false alarm. They are worth naming, because between them they are the
reason a change to this repository's CI cannot be quiet.
`check-exact-head-ci.mjs` reports any job it does not know about as an
unexpected job, so `gate` failed the release gate the moment it existed. `gate`
is now in `REQUIRED_CHECKS` -- listed there as well as fanning in from the other
ten, not instead of them, because that gate reads the API's job list and a name
missing from it is an error either way.
The workflow body is pinned by SHA-256, so any edit to `ci.yml` fails until the
digest is updated deliberately. That lock exists because the Actions API can
attest that a job ran and cannot attest to what its shell did: without it,
replacing every job body with `true` would still look like a successful run. It
did exactly its job here.
And `release-publish-prerequisites` refuses a release-required job that carries
an `if:`, on the grounds that it may not be skipped. That one needed a real
answer rather than an exemption, because the fan-in gate cannot work without
`if: always()`.
The rule is now what it always meant: a release-required job may not be
SKIPPABLE. `always()` is the one expression that makes a job unskippable -- it
runs whatever its dependencies did. Every other condition can evaluate false,
and a required job that never reported is indistinguishable to that gate from
one that was never run. The fan-in needs the same guarantee from the other
direction: without `always()` a failed dependency skips it rather than failing
it, and GitHub does not treat a skipped required check as a failure.
Record-Id: r-cifaningateguards
Provenance: authored
Certainty: firm
Blast: local
Undo: easy
Ruled-out: exempting `gate` from the unconditional rule by name | the next job with a condition would be exempted by the next person for the same reason, and the rule would become a list of names rather than a property
Ruled-out: dropping `if: always()` so the existing rule passes unchanged | that is the defect the gate exists to prevent, adopted to keep a test green
Ruled-out: replacing the ten entries in REQUIRED_CHECKS with `gate` alone | the release gate would then accept a run where the ten never reported, since it verifies the names it is given rather than what `gate` transitively required
Limit: the refined rule accepts exactly the string `always()`. A semantically equivalent expression -- `${{ always() }}`, or `always() && true` -- is refused, which is stricter than the property being asserted and will read as arbitrary to whoever writes one
Verified: three negative controls, each observed to fail and then restored. A skippable condition (`if: github.event_name == 'push'`) is refused; an unpinned edit to `ci.yml` fails nineteen assertions; removing `gate` from `REQUIRED_CHECKS` fails two. The digest was recomputed from the file rather than copied from the CI error message. Full suite 3410 passed, tsc clean
CommitLore-Version: 2.0.0
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A fan-in job so branch protection can require a stable context.
The problem
mainrequires eleven checks. Two —check (22.23.2)andcheck (24)— are matrix values interpolated into the context name, andci.yml's own comment explains why the first is pinned to a patch: "a declared floor must name the exact release CI executes."Today
enforce_adminsis false, so an owner can push past that. That escape is exactly what stronger protection removes — which makes the rename a reason not to strengthen protection rather than a reason to. That is backwards, and it is a CI shape problem, not a policy one.The change
gateneeds every other job inci.ymland fails unless all of them succeeded.Two details carry the weight:
if: always()is not decoration. Without it a failed dependency skips the gate rather than failing it, and a skipped required check is not a failure to GitHub — the gate would wave through precisely the runs it exists to stop.success, not againstfailure.skippedandcancelledare neither.Two contexts, not one
lintlives indemo-lint.ymland cannot be aneeds:from another workflow. So protection wantsgate+lint— down from eleven. Neither carries a matrix value, so neither can be renamed by a version bump, which is the property that was missing.The test
Asserts the gate fans in from every job in the file. A job added later and left out of
needs:would be silently outside protection — the same shape as a results file left off a declared list and silently ungated, which is whatbench/verify.mjswas rewritten to prevent. That scope is default-in; this one cannot be, so it is asserted instead of assumed.Coverage is checked by parsing the workflow, not reading it: every job id appears in
needs, and noneedsentry names an absent job.Negative controls
Both observed to fail, both restored byte-identical:
needsfails the coverage assertionif: always()fails the skip assertionWhat this does not do
It makes a stable required context possible. It does not change branch protection.
enforce_adminsis still false and a pull request is still not required, so a green SHA can still be pushed straight tomainby anyone with admin rights. Flipping those two bits is a settings change, and it should follow this landing rather than precede it — the other order freezes the branch.