Skip to content

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

Open

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

  1. added
    bugSomething is broken or behaves incorrectly
    cicdCI/CD: workflows, actions, lockfiles, pins, runners, release gates
    on Sep 21, 2026
  2. changed the title [-]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[/+] on Sep 21, 2026
  3. hyperpolymath commented on Sep 21, 2026

    @hyperpolymath
    OwnerAuthor

    #99 merged as 9846169c, so the exit-code half of the original filing is
    closed 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:

    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 ruleset-gated or genuinely unprotected branch that is true. On a
    classically-protected branch it is false, stated confidently, in a green
    check — because fetch still asks only rules/branches/{branch} and never
    branches/{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-WebUI
    and the fork are both genuinely unprotected by both mechanisms, re-verified
    (0 rulesets, 404 Branch not protected). This is a latent defect waiting for
    the first classically-protected consumer, not an active misreport today.

  4. added a commit that references this issue on Sep 27, 2026
    65c3d21
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 is broken or behaves incorrectlycicdCI/CD: workflows, actions, lockfiles, pins, runners, release gatespriority:p2Normal - queue itscope:repoConfined to this repositorystatus:readyFully specified and ready to be picked up

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions