Branch Protection
Branch Protection
Section titled “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.
The declaration
Section titled “The declaration”{ "_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).
What GitHub is sent
Section titled “What GitHub is sent”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.
Routes to the branch
Section titled “Routes to the branch”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.
Which check to require: ci-passed
Section titled “Which check to require: ci-passed”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):
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.
How finalize lands on a protected branch
Section titled “How finalize lands on a protected branch”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:
- The stream is freshened from the main branch as before (#1179).
- The landing commit is built in a temporary detached worktree of
origin/<main>: the merge with themerge_strategyflag, and the squash commit forsquash. The main branch is never checked out or pushed. - It is pushed to a landing branch: the stream itself for
ff-only,finalize/<stream>formerge-commitandsquash. - One pull request into the main branch is opened (an open one is reused), and
merge-on-green.shfast-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.
How a release lands on a protected branch
Section titled “How a release lands on a protected branch”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:
- The release commit is built without
[skip ci]. A commit carrying it starts no workflow, so its required checks would never report. - It is pushed to
release/vX.Y.Z, and a pull request into the main branch is opened. merge-on-green.shwaits for the required checks (30 minutes by default,BOB_RELEASE_MERGE_TIMEOUT) and fast-forwards the main branch to the commit they ran on.- Only then is
vX.Y.Zpushed, 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:
gh secret set RELEASE_TOKEN --repo paulirv/bigbrain # paste the fine-grained tokengh variable set RELEASE_VIA --repo paulirv/bigbrain --body prbranch-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.
Commands
Section titled “Commands”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.
The report in bob provision status
Section titled “The report in bob provision status”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.
Sources
Section titled “Sources”- 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.