Repository navigation
fetch: classic branch protection is never read, so a protected branch is reported as having no gate #100
Description
Rescoped 2026-09-21. This issue originally also asked for a distinct exit
code for the "no gate" outcome. That is #99, which was already open when this
was filed and implements it properly (FetchError::NoGate, exit3, with
tests asserting that no-gate and failure do not share a code). Those criteria
are struck below. What remains is the defect #99 explicitly scopes out.
The defect
fetch.rs asks exactly one question:
repos/{owner}/{repo}/rules/branches/{branch}
That endpoint returns ruleset rules only. A repository protected the
classic way — repos/{owner}/{repo}/branches/{branch}/protection, with
required_status_checks.contexts — has a real, enforced gate, and squabble
reports that it has none.
#99 is honest about this; its own comment says classic protection
"is invisible to this query", and it has a test
(the_no_gate_message_does_not_claim_the_branch_is_unprotected) whose purpose is
to stop the message overclaiming. That is the right interim behaviour. It is not
a fix.
Why this gets worse after #99, not better
Today the failure mode is a red misdiagnosis: a classically-protected repo
exits 2, the job fails, and somebody investigates.
After #99 plus its consumer, the same repo exits 3, and the consumer
(MetaManifold-WebUI .github/workflows/ci.yml, branch
fix/gate-triage-no-gate) writes a green job with:
This job is green because nothing was triaged, not because a gate passed.
Merges into that branch are gated by no required status check.
On a classically-protected branch that second sentence is false, stated
confidently, in a green check. A wrong answer nobody investigates is worse than
a wrong answer that stops the line — this is the vacuous-gate pattern, arrived at
from the other direction.
So this should land soon after #99, not long after it.
Acceptance criteria
A distinct exit code for "nothing in my domain".fix(fetch): give "no gate" its own exit code so callers can tell it apart #99.A machine-readable form of the no-gate outcome.fix(fetch): give "no gate" its own exit code so callers can tell it apart #99.- Classic branch protection is read. When
rules/branches/{branch}yields
norequired_status_checksrule, fall back tobranches/{branch}/protection
and honourrequired_status_checks.contexts. A branch protected either
way is reported as gated, and its contexts populate the gate identically. fix(fetch): give "no gate" its own exit code so callers can tell it apart #99.diagnoserefuses a vacuous input.- A fixture per protection mechanism. Ruleset-gated, classic-protected,
both-at-once (union the contexts — GitHub enforces both), and genuinely
unprotected. The classic-protected fixture must assert exit0and the
resulting contexts; asserting merely "not 3" does not close this. - The
NoGatemessage stops carrying the caveat. Once both APIs are read,
no required_status_checks ruleset rule appliescan become the unqualified
no required status checks apply, and fix(fetch): give "no gate" its own exit code so callers can tell it apart #99's
the_no_gate_message_does_not_claim_the_branch_is_unprotectedtest is
updated to assert the stronger claim rather than guard against it.
⚠ A 404 from branches/{branch}/protection means not protected; a 403
means not visible to this token and must not be read as "no gate" — that
would reintroduce, at the API layer, exactly the conflation #99 just removed.
Provenance
Found while diagnosing hyperpolymath/MetaManifold-WebUI PR #6, where
Gate triage (cicd-squabbler) was the only failing job in run 35632633129.
The base repo there is genuinely unprotected by both mechanisms (0 rulesets,
404 on /protection), so that PR is not itself an instance of this defect — it
is what exposed the code path.
Activity
[-]fetch: "no gate to squabble over" is indistinguishable from a real failure (both exit 2); classic branch protection is not read[/-][+]fetch: classic branch protection is never read, so a protected branch is reported as having no gate[/+]hyperpolymath commentedon Sep 21, 2026
#99 merged as
9846169c, so the exit-code half of the original filing isclosed and struck above. The consumer side landed as
hyperpolymath/MetaManifold-WebUI#36.
That makes this issue's priority argument concrete rather than hypothetical.
The consumer now renders exit 3 as a green job carrying:
On a ruleset-gated or genuinely unprotected branch that is true. On a
classically-protected branch it is false, stated confidently, in a green
check — because
fetchstill asks onlyrules/branches/{branch}and neverbranches/{branch}/protection.So #99 did not merely leave this defect alone; it changed its failure mode from
red and investigated to green and believed. Removing a conflation
promoted the surviving false negative from noisy to silent. That is the reason
to close criterion 3 soon rather than eventually.
Neither repo in the originating incident is affected —
JoshuaJewell/MetaManifold-WebUIand the fork are both genuinely unprotected by both mechanisms, re-verified
(0 rulesets,
404 Branch not protected). This is a latent defect waiting forthe first classically-protected consumer, not an active misreport today.
fix: green main (#105) + classic-protection reading (fixes #100) + Ac…