Skip to content

fetch: classic branch protection is never read, so a protected branch is reported as having no gate #100

Description

@hyperpolymath

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, exit 3, 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

  1. 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.
  2. 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.
  3. Classic branch protection is read. When rules/branches/{branch} yields
    no required_status_checks rule, fall back to branches/{branch}/protection
    and honour required_status_checks.contexts. A branch protected either
    way is reported as gated, and its contexts populate the gate identically.
  4. diagnose refuses a vacuous input. fix(fetch): give "no gate" its own exit code so callers can tell it apart #99.
  5. 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 exit 0 and the
    resulting contexts; asserting merely "not 3" does not close this.
  6. The NoGate message stops carrying the caveat. Once both APIs are read,
    no required_status_checks ruleset rule applies can 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_unprotected test 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingci/cdContinuous integration and deployment

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions