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.
What a branch protection rule is
Section titled “What a branch protection rule is”A rule is attached to a branch name pattern — main, 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.
What can be required
Section titled “What can be required”| Setting | Effect |
|---|---|
| Require a pull request before merging | No direct pushes; changes arrive through pull requests |
| Require approvals | N approving reviews |
| Dismiss stale approvals | Approvals discarded on new pushes |
| Require review from code owners | CODEOWNERS approval for touched paths |
| Require approval of the most recent push | The last pusher cannot be the sole approver |
| Require conversation resolution | No unresolved review threads |
| Require status checks to pass | Named checks must succeed |
| Require branches to be up to date | Head must include current base before merging |
| Require signed commits | Every commit must carry a valid signature |
| Require linear history | No merge commits — squash or rebase only |
| Require deployments to succeed | Named environments must have deployed successfully |
| Lock branch | Read-only; nothing may be pushed |
| Restrict who can push | Only named users, teams or Apps |
| Block force pushes | On by default for protected branches |
| Block deletions | On 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.
Force pushes and deletions
Section titled “Force pushes and deletions”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.
Configuring it
Section titled “Configuring it”Branch protection can be inspected and set through the API, which is the reliable way to audit it:
gh api repos/OWNER/REPO/branches/main/protectionWhat 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:
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}JSONNote "restrictions": null — the field is required by the endpoint even when you are not restricting
pushes. Omitting it fails.
Administrators and bypass
Section titled “Administrators and bypass”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.
Branch protection versus rulesets
Section titled “Branch protection versus rulesets”These are two mechanisms for overlapping purposes, and conflating them causes real confusion.
| Branch protection | Rulesets | |
|---|---|---|
| How many apply to a branch | One — most specific wins | Multiple, combining |
| Can be disabled without deleting | No | Yes |
| Visible to non-admins | No | Yes, to anyone with read access |
| Targets tags | No | Yes |
| Targets repository-level events | No | Yes, on some plans |
| Commit metadata rules | No | Yes |
| Organisation-wide application | No | Yes, on Enterprise plans |
| Bypass model | Admins, plus push restrictions | Explicit 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.
gh ruleset check --repo OWNER/REPO mainThat 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 bashset -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" doneThe 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.
Applying protection consistently
Section titled “Applying protection consistently”Once you know which repositories lack it, applying a baseline is the same loop with a write:
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.
What protection does not cover
Section titled “What protection does not cover”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.
A migration path to rulesets
Section titled “A migration path to rulesets”If you are on branch protection and considering rulesets, the transition can be gradual because both apply simultaneously.
- Create a ruleset reproducing your current protection, set to disabled.
- Run
gh ruleset check --repo OWNER/REPO mainand confirm it resolves as expected. - Activate the ruleset. Both now enforce, and the most restrictive combination applies — which, since they express the same rules, is the same behaviour.
- Verify normal workflow still functions for a few days.
- 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.
Common mistakes
Section titled “Common mistakes”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.
Required status checks, in detail
Section titled “Required status checks, in detail”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:
# 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.
Emergency access
Section titled “Emergency access”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.
Verifying protection actually works
Section titled “Verifying protection actually works”Settings pages report intent. Testing reports behaviour, and they occasionally differ — particularly where organisation policy overrides a repository setting.
# Attempt a direct push (on a disposable repository)git commit --allow-empty -m "test"git push origin main # expect rejection
# Attempt a force pushgit push --force origin main # expect rejection
# Attempt a deletiongit push origin --delete main # expect rejectionRead 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.
Exercise
Section titled “Exercise”- Protect
mainon a practice repository, requiring a pull request and blocking force pushes. - Try
git push --force origin mainand read the rejection carefully — note it comes from the remote, not from Git. - Try pushing an ordinary commit directly to
mainand observe the same. - Inspect the configuration with
gh api repos/OWNER/REPO/branches/main/protection. - Run
gh ruleset check --repo OWNER/REPO mainand compare what it reports. - Enable
enforce_adminsand confirm your own pushes are now constrained too.
What you learned
Section titled “What you learned”- 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_adminsshould 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.
The minimum worth having
Section titled “The minimum worth having”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.
Tag protection
Section titled “Tag protection”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:
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.
# Which tags exist, and what they point atgh 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.
Related lessons
Section titled “Related lessons”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.