Skip to content

Branch Protection

Whether GitHub itself refuses a push to a project’s main branch is a declared project setting. The project manifest says which checks a commit must have passed; bob provision status reports that against what GitHub holds; one command applies it, and only with --apply (#3624).

The promotion guard reads the commands a run types on one machine. A branch protection rule is the only stop that holds whatever made the push.

{
"_meta": {
"project": "bigbrain",
"default_branch": "master",
"branch_protection": { "required_checks": ["ci-passed"] }
}
}
Field Values Meaning
required_checks one or more check names Required. The status checks a commit must have passed before the branch may move to it. A check name is what GitHub reports: a workflow job’s name.
enforce_admins true | false Optional, default true. Whether the rule binds repository administrators.
branch a branch name Optional. The branch to protect. Default: _meta.default_branch, else the checkout’s main branch.

The block lives in the project’s provision manifest, provisions/<project>.json, in the operator repo. There is no account default: which checks a repo reports is the repo’s own. A project without the block is undeclared: GitHub is not read and nothing is written. A malformed block is invalid with the error named, never “none by absence”.

Leave enforce_admins at its default. An unattended run uses the operator’s token, which is an admin token on the operator’s own repos, so a rule admins bypass does not stop it.

The same check names are what merge-on-green waits for: it reads the required checks from the base branch’s protection, and falls back to .github/required-checks.json only when the branch has none (#3625).

reconcile --apply sends one request, PUT repos/<owner>/<repo>/branches/<branch>/protection:

Setting Value
Required status checks the declared checks
Require the branch to be up to date (strict) false: merge-on-green lands a fast-forward, which is up to date by construction
Enforce for admins as declared
Required pull request reviews none
Push restrictions none

That request replaces the whole rule. If GitHub already holds a setting the declaration cannot carry (required reviews, push restrictions, required signatures, linear history, a locked branch, force pushes or deletion allowed), reconcile refuses and names it. Nothing is overwritten.

A rule that stops the wrong push also stops the right one. GitHub’s rule is one sentence: the branch moves only to a commit on which every required check has passed. status and reconcile check each way BoB moves the branch against that, from the workflow files in the checkout. They do not call GitHub for it.

Route Reaches the branch when
A pull request landed once its checks are green (merge-on-green) every required check reports on every pull request to the branch
An attended Level 3 run that merges and pushes the branch itself the rule is not enforced for admins. Listed only where the project’s promotion ceiling lets a Level 3 run merge directly and the rule does not bind admins. Under a rule that binds admins the run lands through a pull request instead (see How an attended run lands), so there is no direct merge to list.
The stable release (bob release) under a rule that binds admins, the same as a pull request: the release lands its commit through one (see How a release lands). Under a rule admins bypass, it pushes and reaches. Listed only where the checkout carries the release engine.
bob warp finalize shipping an integration branch at Level 3 by the same promotion decision as the attended run: under a rule that binds admins the decision says merge_via: merge-on-green and finalize lands through one pull request, so it reaches exactly as a pull request does (see How finalize lands). Under a rule admins bypass, it pushes and reaches. Listed only where the promotion ceiling lets a Level 3 run land work on the branch.

Two facts from GitHub’s documentation decide the table:

  • A commit whose required checks have all passed may be pushed straight to the protected branch. That is what merge-on-green does: it moves the branch to the pull request’s head, the commit CI tested.
  • A required check whose workflow is skipped (by a path filter, a branch filter or a commit message) stays pending and blocks the merge. GitHub’s advice is to avoid requiring workflows that can be skipped.

So a required check must come from a workflow with no paths or paths-ignore filter on pull_request.

bigbrain’s CI used to be split by path: check-and-test skipped docs-only changes and gate ran only on them, so requiring either one would have stopped every pull request the other lane handled. Since #3717, ci.yml runs on every pull request and push with no path filter, and its last job, ci-passed, passes only when every lane that applies to the change passed. The lanes themselves (the full suite, and docs-deploy.yml as a reusable workflow) are still skipped when they do not apply. Require ci-passed, never a lane job: a lane job is skipped on some changes and would leave them pending. See Two lanes under one required check.

The bigbrain manifest declares it (staged as bob-operator PR #34):

"branch_protection": { "required_checks": ["ci-passed"] }

The report names each route. Requiring a lane job instead shows the cut-off:

reach: CUT OFF — a pull request landed once its checks are green (merge-on-green): gate does not report on every pull request: .github/workflows/docs-deploy.yml runs only when a pull request touches its `paths`. GitHub leaves a skipped required check pending, so a pull request it skips can never land
reach: CUT OFF — the stable release (bob release), whose release commit lands through a pull request where the rule binds admins: gate does not report on every pull request: .github/workflows/docs-deploy.yml runs only when a pull request touches its `paths`. GitHub leaves a skipped required check pending, so a pull request it skips can never land
Line Meaning
reaches The route still gets to the branch under the declared rule.
CUT OFF It does not. status exits 3, and reconcile refuses to apply.
cannot tell No workflow in the checkout reports a check by that name (a status from another service, or a workflow written in a form the scan does not read). Reported, never a refusal.
warning: not enforced for admins Every route reaches, because an admin token bypasses the rule. It also does not stop an unattended run that uses one.

reconcile --apply --allow-cut-off applies the rule anyway. That is for an operator who has decided a route may stop working. Nothing else passes it.

The workflow scan reads block-style workflow files (on: and jobs: as indented blocks, the form GitHub’s templates use). A job reports under its name: when it has a plain one, otherwise under its id.

How an attended run lands on a protected branch

Section titled “How an attended run lands on a protected branch”

A Level 3 run merges its own work to the main branch by default. It merges the branch locally and pushes the main branch. Under a rule that binds admins, GitHub refuses that push, because no check has run on the merged commit. So the promotion decision takes the declaration into account (#3718):

Terminal window
node ~/.claude/scripts/promotion/promotion.js decide --json
# {"merge":"pr","merge_via":"merge-on-green","protected_branch":true,…}

When the project’s manifest declares _meta.branch_protection with enforce_admins true (the default), a Level 3 decision is merge: pr, merge_via: merge-on-green. The run then lands the way an unattended run does (farm tick). It pushes the branch, opens a pull request, and runs merge-on-green.sh, which fast-forwards the main branch to the commit CI tested once the required checks are green. The merging phase appends a PROTECTED BRANCH MERGE block with those steps. /finish-work follows the same decision.

promotion.js status prints Protected branch: yes for such a project. --protected and --unprotected override the read for one decide call.

A project that declares no protection, or one whose rule is not enforced for admins, keeps the direct merge. An admin token gets past that rule, so the push is not refused.

bob warp finalize at Level 3 used to merge the integration branch into the main branch and push it (git push origin <main>, or HEAD:<main> from a temporary worktree when the main branch is checked out elsewhere, #3543). A rule that binds admins refuses that push. Finalize now reads the same decision (promotion.js decide --level 3 --attended, #4184). When it says merge_via: merge-on-green, both landing paths take one route instead:

  1. The stream is freshened from the main branch as before (#1179).
  2. The landing commit is built in a temporary detached worktree of origin/<main>: the merge with the merge_strategy flag, and the squash commit for squash. The main branch is never checked out or pushed.
  3. It is pushed to a landing branch: the stream itself for ff-only, finalize/<stream> for merge-commit and squash.
  4. One pull request into the main branch is opened (an open one is reused), and merge-on-green.sh fast-forwards the main branch to it once its required checks are green.

A verdict short of green exits 1. The pull request is left open with merge-on-green’s comment, and the stream and its pin are left intact, so fix it and run bob warp finalize again. On green the landing branch is deleted, the checkout is left on the main branch at the landed commit (detached at origin/<main> when the main branch lives in another worktree), and the pin is cleared. bob warp finalize --dry-run prints this path for such a project. BOB_FINALIZE_MERGE replaces the merge engine and BOB_FINALIZE_MERGE_TIMEOUT sets its wait in minutes (default 30).

A project that declares no protection, or one whose rule admins bypass, keeps today’s push.

bob release and bob release promote used to land the release commit (version bump, CHANGELOG, [skip ci]) by pushing the main branch and the tag in one atomic push. Under a rule that binds admins, GitHub refuses that push, because no check ran on the release commit. So the release engine (scripts/release/release.sh) has a second route (#3719). Every stable and promote run says which one it took and why:

bob-release: lands by: a pull request merge-on-green fast-forwards, then the tag (provisions/<project>.json _meta.branch_protection declares …)

On the pull-request route:

  1. The release commit is built without [skip ci]. A commit carrying it starts no workflow, so its required checks would never report.
  2. It is pushed to release/vX.Y.Z, and a pull request into the main branch is opened.
  3. merge-on-green.sh waits for the required checks (30 minutes by default, BOB_RELEASE_MERGE_TIMEOUT) and fast-forwards the main branch to the commit they ran on.
  4. Only then is vX.Y.Z pushed, and the GitHub Release published.

A promotion past a moved master merges the release commit into master as before. On this route the merge commit also carries no [skip ci], the merge is what the pull request carries, and the tag goes on the release commit (whose parent is the nightly).

A release whose checks fail leaves nothing behind. The pull request is closed, release/vX.Y.Z deleted, the local tag removed and the local main branch reset to origin’s. The refusal (exit 3) names the failed check. Re-run bob release once it is fixed. A re-run whose release commit already landed pushes only the tag.

The route is chosen in this order:

Source Route
BOB_RELEASE_VIA=pr or push in the environment as given
_meta.branch_protection enforced for admins on the main branch pull request
none declared, or a rule admins bypass atomic push (an admin token gets past the rule)

An invalid declaration refuses the release: it cannot tell how to land.

On GitHub Actions (.github/workflows/auto-release.yml): a pull request opened with the workflow’s own GITHUB_TOKEN starts no workflow, so its required checks would never run. The engine therefore refuses the pull-request route on Actions unless BOB_RELEASE_TOKEN holds a different token, and it refuses before writing anything. The workflow passes the RELEASE_TOKEN secret (inventoried as release-pr-token in secrets-inventory.json: a fine-grained token on the repo with Contents and Pull requests write). A runner has no operator repo to read the manifest from, so the route comes from the repo variable RELEASE_VIA. Before applying a rule that binds admins to bigbrain:

Terminal window
gh secret set RELEASE_TOKEN --repo paulirv/bigbrain # paste the fine-grained token
gh variable set RELEASE_VIA --repo paulirv/bigbrain --body pr

branch-protection.js status grades the release route like a pull request under such a rule: it reaches when every required check reports on every pull request.

Terminal window
node ~/.claude/scripts/fleet/branch-protection.js resolve [--project <name>] [--cwd <dir>] [--json]
node ~/.claude/scripts/fleet/branch-protection.js status [--project <name>] [--cwd <dir>] [--repo <owner/name>] [--json]
node ~/.claude/scripts/fleet/branch-protection.js reconcile [--project <name>] [--cwd <dir>] [--repo <owner/name>] [--apply] [--allow-cut-off] [--json]

--project defaults to the name of the working directory, and the repo to the checkout’s origin remote. --json prints one bob-branch-protection/1 document.

Command What it does Exit codes
resolve Reads the declaration from disk. Never calls GitHub. 0 always · 2 usage
status The declaration against what GitHub holds, and each route to the branch under the declared rule. 0 converged or undeclared · 1 drift or invalid · 2 usage · 3 a route is cut off · 4 not measured
reconcile A dry run: says what would be sent. With --apply, sends it and re-reads the branch to verify. Refuses while a route would be cut off, or while GitHub holds a setting the declaration cannot carry. 0 converged, undeclared or applied · 1 would-change (dry run) · 2 usage · 3 refused · 4 failed

reconcile --apply on a branch that already matches sends nothing.

bob provision status prints the same verdict for the project it is run in:

Branch protection (#3624) — acme/alpha:
declared: master — required checks: ci-passed; enforced for admins — declared in provisions/alpha.json _meta.branch_protection
actual: master is not protected
[drift] DRIFT — GitHub holds no protection for master
fix: node ~/.claude/scripts/fleet/branch-protection.js reconcile --project alpha --repo acme/alpha --apply
Verdict Meaning
[converged] The declaration and GitHub agree.
[drift] They differ. Each difference is named, with the command that applies the declaration.
[undeclared] The manifest declares no protection. GitHub is not read.
[invalid] The block is malformed. Nothing is read or written until it is fixed.
[not measured] GitHub could not be read: no origin remote, gh unreachable, or a token that may not read the protection. Never reported as converged.
[cut off] A route to the branch would stop working under the declared rule. See Routes to the branch.

tests/test-branch-protection.js drives provision.js status and branch-protection.js against a scratch operator repo and a gh stand-in that answers in GitHub’s response shape and records every write. Its last section applies a declared rule and then runs the real merge-on-green.sh against it. The stand-in refuses a ref update to a commit whose required checks did not pass, as GitHub does: a pull request whose check passed lands, and one the required check never reports on stays open.

The same suite runs promotion.js decide and status for projects with and without the declaration (#3718). scripts/warp-drive/test-state-machine.js drives a run into merging and checks for the PROTECTED BRANCH MERGE block.

It also copies bigbrain’s own ci.yml and docs-deploy.yml into a scratch checkout and checks that a rule requiring ci-passed reaches the branch by pull request (#3717). tests/test-ci-lane-split.sh covers the lanes themselves.

That is a model of GitHub’s rule, not GitHub. The first real proof on a repo is the first pull request merge-on-green lands after the rule is applied.

  • About protected branches (read 2026-10-02): “After all required status checks pass, any commits must either be pushed to another branch and then merged or pushed directly to the protected branch.” By default the rule does not apply to people with admin permission; “Do not allow bypassing the above settings” applies it to them.
  • Troubleshooting required status checks (read 2026-10-02): when a workflow is skipped by path filtering, branch filtering or a commit message, its checks stay pending and block merging.