Skip to content

ci(gate): record the backlog-hygiene promotion (12 → 13 required contexts) - #59

Merged
wshallwshall merged 6 commits into
mainfrom
claude/required-contexts-13
Jul 30, 2026
Merged

ci(gate): record the backlog-hygiene promotion (12 → 13 required contexts)#59
wshallwshall merged 6 commits into
mainfrom
claude/required-contexts-13

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Branch protection now requires 13 contexts — the 12 plus backlog-hygiene, promoted 2026-07-29. .github/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 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 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 pulled 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:

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 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.
  • shell comments — the rationale comments in these workflows quote the idiom being prohibited, so a whole-body scan reports the explanation as the offence. A detector counting itself; third time that shape has appeared in this work.

A standalone gating_cmd || true — the actual hazard — survives both strips.

Proven, not asserted. All three shapes injected into a required job:

injected result expected
standalone || true on the gating command CAUGHT caught
|| true inside a command substitution not caught not caught
|| true inside a comment not caught 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.

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.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. 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_exists if any context in the request is already required, and adds none of them.

…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.
…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.
@wshallwshall
wshallwshall enabled auto-merge (squash) July 30, 2026 03:34
@wshallwshall
wshallwshall merged commit 9b3606e into main Jul 30, 2026
45 of 46 checks passed
@wshallwshall
wshallwshall deleted the claude/required-contexts-13 branch July 30, 2026 04:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant