Skip to content

GitHub Branch Protection: Rules That Guard a Branch

Lesson 7 of 12Intermediate11 min readGitHub Engineering · Pull RequestsVerified: GitHub.com and gh 2.98.0, August 2026

A protected branch is one GitHub refuses to let you change in certain ways.

Without protection, main is an ordinary branch: anyone with write access can push to it, force-push over it, or delete it. Branch protection is the mechanism that stops that, and it is where most repositories’ governance begins.

A rule is attached to a branch name patternmain, or release/* — and applies to every branch matching it.

The critical structural property, and the main difference from rulesets: only one branch protection rule applies to a branch. When several patterns match, GitHub picks the most specific one and the others do not contribute. Rules do not combine.

That single fact explains most branch protection surprises. A rule on release/* and another on release/2.0 are not additive; the more specific one wins entirely.

SettingEffect
Require a pull request before mergingNo direct pushes; changes arrive through pull requests
Require approvalsN approving reviews
Dismiss stale approvalsApprovals discarded on new pushes
Require review from code ownersCODEOWNERS approval for touched paths
Require approval of the most recent pushThe last pusher cannot be the sole approver
Require conversation resolutionNo unresolved review threads
Require status checks to passNamed checks must succeed
Require branches to be up to dateHead must include current base before merging
Require signed commitsEvery commit must carry a valid signature
Require linear historyNo merge commits — squash or rebase only
Require deployments to succeedNamed environments must have deployed successfully
Lock branchRead-only; nothing may be pushed
Restrict who can pushOnly named users, teams or Apps
Block force pushesOn by default for protected branches
Block deletionsOn by default for protected branches

Three deserve comment.

Require signed commits. GitHub verifies that commits carry a valid signature from a key associated with a GitHub account. The signing itself is Git’s — Signed Commits covers it — and the enforcement is GitHub’s. Turning this on without first ensuring everyone can sign will block your whole team, so roll it out in that order.

Require linear history. Rejects merge commits on the branch, forcing squash or rebase merges. This is a history-shape decision with real consequences; see Merge Commits before enabling it.

Restrict who can push. Useful even alongside required pull requests, because it constrains who can perform the merge itself, not just who can propose.

These two are why branch protection exists at all.

A force push to main can discard commits permanently — the objects survive until garbage collection, but the branch no longer references them, and anyone who pulls afterwards receives the rewritten history. On a shared branch this is the most destructive routine operation available.

Deleting main is similarly recoverable-if-noticed-quickly and catastrophic-if-not.

Protecting a branch blocks both by default. If you take one thing from this lesson, protect your default branch against force pushes even if you configure nothing else.

Branch protection can be inspected and set through the API, which is the reliable way to audit it:

Terminal window
gh api repos/OWNER/REPO/branches/main/protection

What it doesPrints the current protection configuration for the main branch.

Why we run itThe settings page shows one branch at a time and hides defaults. The API returns the complete state, which is what you want when auditing several repositories.

Expected resultA JSON object describing review requirements, status checks and restrictions. A 404 means the branch is not protected at all.

Setting it:

Terminal window
gh api --method PUT repos/OWNER/REPO/branches/main/protection \
--input - <<'JSON'
{
"required_status_checks": { "strict": true, "contexts": ["build", "test"] },
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true,
"require_last_push_approval": true
},
"restrictions": null
}
JSON

Note "restrictions": null — the field is required by the endpoint even when you are not restricting pushes. Omitting it fails.

By default, repository administrators can bypass branch protection. enforce_admins applies the rules to them too.

Enabling it is usually correct. A rule that the people most likely to be in a hurry can ignore is a rule that protects against everyone except the highest-risk case. The counter-argument — that administrators need an escape hatch during incidents — is real, and the answer is a deliberate, audited break-glass procedure rather than permanent exemption.

These are two mechanisms for overlapping purposes, and conflating them causes real confusion.

Branch protectionRulesets
How many apply to a branchOne — most specific winsMultiple, combining
Can be disabled without deletingNoYes
Visible to non-adminsNoYes, to anyone with read access
Targets tagsNoYes
Targets repository-level eventsNoYes, on some plans
Commit metadata rulesNoYes
Organisation-wide applicationNoYes, on Enterprise plans
Bypass modelAdmins, plus push restrictionsExplicit bypass lists

The practical guidance:

Use branch protection when you have one branch to protect, simple requirements, and no need for anyone but administrators to see the policy. It is well understood and widely documented.

Use rulesets when you need layered policy, tag protection, organisation-wide rules, visibility of the policy to contributors, or the ability to switch a rule off temporarily without losing its configuration.

They can coexist on the same branch, and when they do, the most restrictive combination applies. That is worth knowing before you spend an afternoon wondering why removing a branch protection setting changed nothing — a ruleset may still be enforcing it.

Terminal window
gh ruleset check --repo OWNER/REPO main

That command reports everything applying to a branch from both mechanisms, including inherited organisation rules. When policy is behaving unexpectedly, run it before changing anything.

Auditing protection across many repositories

Section titled “Auditing protection across many repositories”

A protection rule you set two years ago on one repository tells you nothing about the other ninety. Auditing is one loop:

#!/usr/bin/env bash
set -euo pipefail
ORG="${1:?usage: audit-protection.sh ORG}"
gh repo list "$ORG" --limit 300 --no-archived --source \
--json nameWithOwner,defaultBranchRef \
--jq '.[] | [.nameWithOwner, .defaultBranchRef.name] | @tsv' \
| while IFS=$'\t' read -r repo branch; do
if ! prot=$(gh api "repos/$repo/branches/$branch/protection" 2>/dev/null); then
printf 'UNPROTECTED\t%s\t%s\n' "$repo" "$branch"
continue
fi
reviews=$(jq -r '.required_pull_request_reviews.required_approving_review_count // 0' <<<"$prot")
admins=$(jq -r '.enforce_admins.enabled' <<<"$prot")
force=$(jq -r '.allow_force_pushes.enabled' <<<"$prot")
printf 'protected\t%s\treviews=%s\tadmins=%s\tforce_push=%s\n' \
"$repo" "$reviews" "$admins" "$force"
done

The UNPROTECTED lines are the finding. On most organisations that list is longer than anyone expects, because protection is applied when a repository matters and repositories become important after they are created.

Note the 404-means-unprotected handling: GitHub returns 404 rather than an empty object for a branch with no protection, so the absence must be caught rather than parsed.

Once you know which repositories lack it, applying a baseline is the same loop with a write:

Terminal window
apply_baseline() {
local repo="$1" branch="$2"
gh api --method PUT "repos/$repo/branches/$branch/protection" --input - <<'JSON'
{
"required_status_checks": null,
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true,
"require_last_push_approval": true
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
JSON
}

Two details that cause failures. restrictions and required_status_checks must be present even when null — the endpoint requires the keys. And because this is a PUT, it replaces the entire configuration: running it against an already-protected branch discards whatever was there.

For repositories that already have protection you want to extend rather than replace, read first, merge with jq, and write the result back. Test that against a disposable repository before running it anywhere real — the read and write schemas differ enough that getting it right takes an attempt or two.

Worth enumerating, because “the branch is protected” is often treated as covering more than it does.

Repository settings. Changing visibility, transferring ownership, adding collaborators, disabling features — none of these go through a pull request.

The Wiki. A separate repository with no protection at all.

Releases and tags. Branch protection does not protect tags. Only rulesets can.

Actions workflows at runtime. Protection governs changes to workflow files; a workflow that already exists runs with whatever permissions it declares.

Anything by a bypasser. Which is why the bypass list is the real policy.

The gap that surprises people most is tags. A repository with a rigorously protected main can still have its release tags deleted or moved by anyone with write access, which undermines every version guarantee downstream consumers rely on.

If you are on branch protection and considering rulesets, the transition can be gradual because both apply simultaneously.

  1. Create a ruleset reproducing your current protection, set to disabled.
  2. Run gh ruleset check --repo OWNER/REPO main and confirm it resolves as expected.
  3. Activate the ruleset. Both now enforce, and the most restrictive combination applies — which, since they express the same rules, is the same behaviour.
  4. Verify normal workflow still functions for a few days.
  5. Remove the branch protection rule. The ruleset continues alone.

The overlap in step 3 is what makes this safe: at no point is the branch unprotected. The reason to bother is everything rulesets add — layering, visibility to contributors, tag protection, commit metadata rules, and the ability to disable one policy without touching the others.

Expecting multiple branch protection rules to combine. Only the most specific applies.

Using PUT .../protection without reading first. Silently drops omitted settings.

Leaving enforce_admins off. Exempts the people most likely to be rushing.

Requiring signed commits before the team can sign. Blocks everyone immediately.

Protecting main and forgetting release/*. Release branches are usually equally load-bearing.

Assuming branch protection covers the Wiki. It is a separate repository with no protection.

Debugging a ruleset by changing branch protection. Check which mechanism is actually enforcing.

The check-related settings cause more confusion than the review ones, mostly because of how checks are identified.

A required check is matched by its context — the name it reports under. That name must match exactly, and it is easy to get wrong:

Terminal window
# What contexts has this branch actually seen?
gh api "repos/OWNER/REPO/commits/main/status" --jq '.statuses[].context'
gh api "repos/OWNER/REPO/commits/main/check-runs" --jq '.check_runs[].name'
# What is currently required?
gh api "repos/OWNER/REPO/branches/main/protection/required_status_checks" \
--jq '{strict, contexts}'

Three failure modes follow from exact matching:

A renamed job. Renaming a workflow job changes its check name. The old name is still required, never reports, and every pull request is blocked pending a check that no longer exists.

A check that never runs on some pull requests. A required check gated behind a path filter will not report on pull requests that do not touch those paths — so those pull requests wait forever.

A check from a fork. Workflows on fork pull requests run with reduced permissions, and some fail or skip. If they are required, external contributions cannot merge.

The strict field is the “require branches to be up to date” setting. It is genuinely valuable and it is what creates the race that merge queues solve.

Every protection scheme needs an answer to “production is down and the fix is blocked by policy”. Deciding it in advance is much better than improvising at three in the morning.

Three workable approaches:

A named break-glass team with bypass, whose membership is normally empty and is added to during an incident. The addition is logged, which gives you an audit trail.

Temporarily disable the rule. With rulesets this is a status change rather than a deletion, so the configuration is preserved and re-enabling is one action. With branch protection it means deleting and recreating, which is why people avoid it and force-push instead.

Merge with an emergency label and a rule that allows it, followed by a mandatory retrospective pull request. This keeps the change inside the normal path.

What does not work is having no plan. The default improvisation is an administrator disabling protection entirely, fixing the problem, and forgetting to re-enable it — which is how repositories end up unprotected for months.

Whichever you choose, write it down, and make using it produce a record. The purpose of the record is not blame; it is that emergency access used three times in a month is telling you something about the policy.

Settings pages report intent. Testing reports behaviour, and they occasionally differ — particularly where organisation policy overrides a repository setting.

Terminal window
# Attempt a direct push (on a disposable repository)
git commit --allow-empty -m "test"
git push origin main # expect rejection
# Attempt a force push
git push --force origin main # expect rejection
# Attempt a deletion
git push origin --delete main # expect rejection

Read the rejection messages: they name the rule that blocked you, which is how you confirm the right mechanism is enforcing rather than something else you forgot about.

Doing this once on a disposable repository after configuring protection takes five minutes and is the only way to be certain. A repository whose protection was configured and never tested is one where nobody knows whether it works.

  1. Protect main on a practice repository, requiring a pull request and blocking force pushes.
  2. Try git push --force origin main and read the rejection carefully — note it comes from the remote, not from Git.
  3. Try pushing an ordinary commit directly to main and observe the same.
  4. Inspect the configuration with gh api repos/OWNER/REPO/branches/main/protection.
  5. Run gh ruleset check --repo OWNER/REPO main and compare what it reports.
  6. Enable enforce_admins and confirm your own pushes are now constrained too.
  • Only one branch protection rule applies to a branch — the most specific — and rules do not combine.
  • Blocking force pushes and deletions on the default branch is the highest-value single setting.
  • Signed commits and linear history are Git behaviours enforced by GitHub policy.
  • The protection endpoint replaces the whole configuration; read before writing.
  • enforce_admins should normally be on, with a deliberate break-glass path instead of permanent exemption.
  • Branch protection and rulesets can both apply, and the most restrictive combination wins.

If a repository has no protection at all, two settings give you most of the value:

Block force pushes on the default branch. This prevents the one operation that can destroy published work irrecoverably.

Block deletions. Deleting the default branch is recoverable if noticed immediately and catastrophic if not.

Neither affects any normal workflow. Nobody force-pushes to main deliberately as part of ordinary work, so the cost of these two is genuinely zero and the failure they prevent is total.

Everything else — required reviews, status checks, code owners — is worth adding, and worth adding one at a time with a reason. But those two should be on before the repository has more than one contributor, and they are the two most often missing.

Branch protection protects branches. Tags need their own mechanism, and this is the gap most repositories have without knowing it.

Historically tags were protected by tag protection rules — a separate list of name patterns:

Terminal window
gh api "repos/OWNER/REPO/tags/protection" --jq '.[] | {id, pattern}'
gh api --method POST "repos/OWNER/REPO/tags/protection" -f pattern="v*"

That mechanism has been superseded by rulesets, which target tags as a first-class case and can express more than a name pattern.

Either way, the point stands: a release tag is a promise that a version identifier means one specific commit. Without protection, anyone with write access can move or delete one — and package managers cache, so the result is two people with the same version running different code, with nothing in either repository to explain it.

Terminal window
# Which tags exist, and what they point at
gh api "repos/OWNER/REPO/tags?per_page=100" --paginate \
--jq '.[] | [.name, .commit.sha[0:7]] | @tsv'

If you protect one thing beyond your default branch, protect v*. It costs nothing — nobody moves a release tag as part of normal work — and the failure it prevents is one that surfaces days later in somebody else’s build.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

Two branch protection rules exist: `main` requires two reviews, `*` requires status checks. What applies to `main`?
Show answer

Only the `main` rule — the most specific rule applies alone; rules do not combine — Branch protection rules do not merge. The most specific matching rule applies and the others are ignored. Rulesets are the mechanism that layers.

You call `PUT .../branches/main/protection` with only `required_status_checks` in the body. What happens to the existing review requirement?
Show answer

It is removed — the endpoint replaces the whole configuration — The protection endpoint is a full replace: anything omitted is dropped. Read the current configuration first and send the complete object.

Which single branch protection setting does the lesson rate as highest value on the default branch?
Show answer

Blocking force pushes and deletions — Force pushes and deletions are the operations that destroy shared history. Blocking them on the default branch closes the most damaging path with one setting.

Professional ToolkitCODEOWNERS, pull request and issue templates, and repository configuration checklists ready to adapt.