ci(gate): record the backlog-hygiene promotion (12 → 13 required contexts) - #59
Merged
Conversation
…g detector flagging a correct guard
Branch protection now requires 13 contexts -- the 12 plus backlog-hygiene, promoted
2026-07-29. required-contexts.txt mirrors the LIVE server, so leaving it at 12 made
the file wrong in the one direction it exists to prevent.
The drift test did its job: it named all four claims that had to move with the count
(the pin in test_required_contexts.py, docs/CI.md's list, and the "12 status checks"
prose in tests/test_push_guard.py and scripts/hooks/push_guard.py). Verified after:
the file parses to 13 and is IDENTICAL to what the API returns.
PROMOTION BROUGHT A NEW JOB UNDER THE BLOCKING-POSTURE RULES, WHICH IS THE POINT.
test_security_posture.py resolves each required context to a job, so promoting one
subjects that job to every rule in the module for the first time. It immediately
flagged backlog-hygiene's gating step for `|| true`.
That one is a FALSE POSITIVE, and worth the detail because "fixing" it would have
deleted a correct guard. The occurrence is:
claim="$(... | grep -oiE 'BACKLOG #[0-9]+' | head -1 || true)"
`grep` exits 1 on no-match, and the script runs under `set -euo pipefail`, so without
`|| true` it aborts on the ordinary "this PR claims no backlog item" path. Inside a
command substitution it sets the SUBSTITUTION's status, which the assignment consumes
-- it cannot reach the step. The gating decision is the explicit `exit 0` / `exit 1`
further down. The old assertion message suggested "confine it to its own step", which
is impossible for a variable assignment.
So the detector is now precise instead of allowlisted. It scans PER LINE and blanks
two things first:
* `$( ... )` substitutions -- what is inside cannot set the step's exit status.
* shell comments -- the rationale comments in these workflows QUOTE the idiom being
prohibited, so a raw whole-body scan reports the explanation as the offence. That
is a detector counting itself, and it is the third time that shape has appeared in
this work.
A standalone `gating_cmd || true`, the actual hazard, survives both strips.
Proven rather than asserted, by injecting all three shapes into a required job:
standalone `|| true` on the gating command -> CAUGHT (expected caught)
`|| true` inside a command substitution -> not caught (expected not caught)
`|| true` inside a comment -> not caught (expected not caught)
security.yml verified byte-identical to HEAD afterwards.
The job-count pin moves 10 -> 11 (13 contexts collapse to 11 jobs; the three `test`
legs share one matrix job). Still pinned rather than derived, so a matrix split or a
context that quietly stops resolving forces a look here instead of passing on a
self-consistent count.
Also recorded in required-contexts.txt, because it is the incident that proves the
file's purpose: on 2026-07-29 protection was briefly reduced to a SINGLE required
context, dropping all twelve gates while auto-merge stayed armed and approvals stayed
at 0, and several PRs merged in that window. What surfaced it was reconciling a CI
wait against `required_status_checks.contexts` and seeing the count come back as 1 --
a monitor waiting for "nothing pending" would have reported green without ever waiting
for a test leg, because with one required context there was nothing to wait for. The
header now also records that the REST endpoint is all-or-nothing: it 422s with
`already_exists` if any context in the request is already required, and adds none.
…o claude/required-contexts-13
…ygiene in)
Branch protection moved five times on 2026-07-29 (13 -> 1 -> 13 -> 12 -> 15 -> 12).
This records the state it settled on, read from the API and unchanged for six minutes
before being written down -- not a guess at where it was heading.
Net against the previous commit on this branch: `CI gate` is OUT, backlog-hygiene is
IN, so the count returns to 12 and the job collapse to 10 (the three `test` legs share
one matrix job).
`CI gate` leaving the required set is recorded as a FACT with its consequence, not as
an intent, because the reason is not mine to state: that roll-up is what gated ci.yml's
conditional/matrix legs, so what no longer blocks a merge is everything it covered
beyond the three `test` legs, which are required in their own right. If the removal was
incidental it is one POST plus a line here to restore.
Two non-context settings moved with it and are now recorded, because both change merge
semantics and neither was written down anywhere:
strict = TRUE. A PR must be up to date with `main` to merge. This closes the
stale-green hole -- previously a PR validated against an older base could merge with
no revalidation, so a semantic conflict with a newer main (a test that reads a file
another PR changed) could land red. It bit this branch: the merge was refused for
being behind, which is the setting working.
enforce_admins = FALSE. Enabled 2026-07-28, disabled again 2026-07-29 via the escape
hatch documented in push_guard.py's own HISTORY note ("a required check that goes
permanently red blocks every merge until it is fixed or protection is relaxed"). The
hook said "enforce_admins is now TRUE ... this hook is therefore DEFENCE-IN-DEPTH
rather than the only guard" -- which had become false in the direction that matters:
with admins able to bypass again, the pre-push hook IS the only guard against a direct
push to main. Corrected in both the hook and its test.
Verified after: the file parses to 12 and is IDENTICAL to what the API returns; 61
tests pass across the required-set, posture, push-guard, ledger and backlog suites.
…RORG/MessageFoundry into claude/required-contexts-13
…o claude/required-contexts-13
wshallwshall
enabled auto-merge (squash)
July 30, 2026 03:34
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.
Branch protection now requires 13 contexts — the 12 plus
backlog-hygiene, promoted 2026-07-29..github/required-contexts.txtmirrors the live server, so leaving it at 12 made the file wrong in the one direction it exists to prevent.The drift test did its job and named all four claims that move with the count: the pin in
test_required_contexts.py,docs/CI.md's list, and the "12 status checks" prose intests/test_push_guard.pyandscripts/hooks/push_guard.py. Verified after: the file parses to 13 and is identical to what the API returns.Promotion pulled a new job under the blocking-posture rules — which is the point
test_security_posture.pyresolves each required context to a job, so promoting one subjects that job to every rule in the module for the first time. It immediately flaggedbacklog-hygiene's gating step for|| true.That one is a false positive, and worth the detail because "fixing" it would have deleted a correct guard:
claim="$(... | grep -oiE 'BACKLOG #[0-9]+' | head -1 || true)"grepexits 1 on no-match and the script runs underset -euo pipefail, so without|| trueit aborts on the ordinary "this PR claims no backlog item" path. Inside a command substitution it sets the substitution's status, which the assignment consumes — it cannot reach the step. The gating decision is the explicitexit 0/exit 1further down. The old message suggested "confine it to its own step", which is impossible for a variable assignment.So the detector is now precise rather than allowlisted. It scans per line and blanks two things first:
$( … )substitutions — what is inside cannot set the step's exit status.A standalone
gating_cmd || true— the actual hazard — survives both strips.Proven, not asserted. All three shapes injected into a required job:
|| trueon the gating command|| trueinside a command substitution|| trueinside a commentsecurity.ymlverified byte-identical to HEAD afterwards.The job-count pin moves 10 → 11 (13 contexts collapse to 11 jobs; the three
testlegs share one matrix job). Still pinned rather than derived, so a matrix split or a context that quietly stops resolving forces a look here instead of passing on a self-consistent count.The incident this file exists for
Recorded in the header, because it happened within hours of the file landing: on 2026-07-29 protection was briefly reduced to a single required context, dropping all twelve gates while auto-merge stayed armed and approvals stayed at 0. Several PRs merged in that window.
What surfaced it was reconciling a CI wait against
required_status_checks.contextsand seeing the count come back as 1 — a monitor waiting for "nothing pending" would have reported green without ever waiting for a test leg, because with one required context there was nothing to wait for. Count what should gate the merge, not what does.The header also now records that the REST endpoint is all-or-nothing: it 422s with
already_existsif any context in the request is already required, and adds none of them.