Branch Protection Rules
A branch protection rule is a set of guardrails you attach to a branch on GitHub — almost always main — that stop specific bad things from happening to it: force-pushes that rewrite history, deletions, merges of unreviewed code, or merges of code that hasn’t passed CI. Protection rules live entirely on GitHub’s servers, not in your local .git directory, so they only kick in the moment you push, merge, or delete against GitHub. This lesson explains how protection is enforced, how to configure it with the GitHub web UI and the gh CLI, and the mistakes that either lock teams out of their own repository or, worse, leave it unprotected without anyone noticing.
Overview / How it works
Everything else in this course has been about Git’s own object model: blobs, trees, commits, and branches that are just movable pointers to a commit. Branch protection is different — Git itself has no idea a branch is “protected.” When you run git push, your local Git client doesn’t check any rule; it simply tries to update the ref on the remote. It’s GitHub’s server that intercepts the push, looks up whether the target ref (for example refs/heads/main) matches a protected branch pattern, and if it does, runs a set of checks before it will accept the update. If any required check fails, GitHub rejects the push outright, and your local Git client reports a normal-looking rejection error even though nothing about your repository’s history is actually wrong.
A protection rule is configured per branch (or a wildcard pattern like release/*) and can enforce any combination of:
- Required status checks — the branch can’t be updated unless specific CI jobs (reported through the Checks API, e.g. from GitHub Actions) have passed on the exact commit being merged.
- Required pull request reviews — a minimum number of approving reviews, optionally with code owner review and automatic dismissal of stale approvals when new commits are pushed.
- Required signed commits — every commit on the branch must carry a verified GPG, SSH, or S/MIME signature, tying directly into the commit-signing setup covered elsewhere in this section.
- Restrict who can push — only specific users, teams, or apps may push directly, even if they’d otherwise have write access.
- Require linear history — blocks merge commits, forcing squash or rebase merges only.
- Require conversation resolution — every PR review comment thread must be marked resolved before merging.
- Include administrators — whether repository admins are also bound by the rule, or can bypass it.
- Allow force pushes / Allow deletions — off by default for a protected branch; turning them on is a deliberate, narrow exception (for example, for a bot that force-pushes a rebased release branch).
Classic protection rules vs. Rulesets
GitHub has two overlapping systems. Branch protection rules (the classic feature, and the one this lesson focuses on) are configured per repository against one branch or branch pattern. Rulesets are the newer, more flexible replacement: they can target multiple branches or tags with more precise patterns, can be scoped at the organization level so every repository inherits them, and support the same underlying checks (required reviews, required status checks, required signatures, and more) plus a few things classic protection lacks, like blocking specific file paths or commit message patterns. Rulesets are viewable with the CLI too:
gh ruleset list --repo octo-org/hello-world
For a single repository with straightforward needs, classic branch protection is still perfectly fine and is what the examples below use, since it maps directly onto the GitHub REST API’s protection endpoints.
Syntax
There’s no git subcommand for branch protection — you configure it through the GitHub web UI (Settings → Branches → Add branch protection rule) or through the API, most conveniently via the gh api command:
gh api --method PUT "repos/<owner>/<repo>/branches/<branch>/protection" -F "<field>=<value>"
The key fields you send in the request body:
| Field | Meaning |
|---|---|
required_status_checks |
Object with strict (require the branch to be up to date before merging) and contexts (the exact names of required checks). |
enforce_admins |
Boolean — whether repository admins must also follow the rule. |
required_pull_request_reviews |
Object controlling approval count, code owner review, and stale-review dismissal. |
restrictions |
Users/teams/apps allowed to push directly, or null for no restriction beyond normal write access. |
required_signatures |
Set separately via the /protection/required_signatures endpoint; requires every commit to be signed. |
required_linear_history |
Boolean — disallows merge commits onto the branch. |
allow_force_pushes / allow_deletions |
Booleans, both false by default once protection is enabled. |
Examples
Example 1: Check whether a branch is protected
gh api repos/octo-org/hello-world/branches/main/protection
Output:
{
"url": "https://api.github.com/repos/octo-org/hello-world/branches/main/protection",
"required_status_checks": {
"strict": true,
"contexts": ["ci/build"]
},
"enforce_admins": { "enabled": true },
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null
}
If main weren’t protected, the same call would return a 404 Not Found instead — GitHub only exposes a protection object once one has been created.
Example 2: Turn on protection with required reviews and a required check
gh api \
--method PUT \
repos/octo-org/hello-world/branches/main/protection \
-F 'required_status_checks[strict]=true' \
-F 'required_status_checks[contexts][]=ci/build' \
-F enforce_admins=true \
-F 'required_pull_request_reviews[required_approving_review_count]=1' \
-F 'required_pull_request_reviews[dismiss_stale_reviews]=true' \
-F restrictions=null
Output:
{
"url": "https://api.github.com/repos/octo-org/hello-world/branches/main/protection",
"required_status_checks": { "strict": true, "contexts": ["ci/build"] },
"enforce_admins": { "enabled": true },
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null
}
This requires that the CI job reporting the status context ci/build passes, that at least one reviewer approves the pull request, that stale approvals are dismissed when new commits land, and — because enforce_admins is true — that even repository admins can’t bypass any of it with a direct push.
Example 3: What happens when someone pushes directly to a protected branch
git switch main
git commit -am "fix: correct typo in README"
git push origin main
Output:
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: Changes must be made through a pull request.
To github.com:octo-org/hello-world.git
! [remote rejected] main -> main (protected branch hook declined)
error: failed to push some refs to 'github.com:octo-org/hello-world.git'
The commit itself is perfectly valid — it exists in the local repository and could be pushed to any unprotected branch. GitHub rejects the ref update, not the commit. The fix is to push the commit to a new branch and open a pull request instead: git switch -c fix/readme-typo && git push -u origin fix/readme-typo.
Example 4: Require signed commits
gh api --method POST repos/octo-org/hello-world/branches/main/protection/required_signatures
Output:
{
"url": "https://api.github.com/repos/octo-org/hello-world/branches/main/protection/required_signatures",
"enabled": true
}
From this point on, GitHub verifies every commit’s GPG, SSH, or S/MIME signature before allowing it onto main, whether it arrives through a merged pull request or (if otherwise permitted) a direct push. An unsigned commit shows up in the pull request as “Unverified” and blocks the merge button.
How it works step by step
- A client (your local Git, or GitHub’s own merge-button logic) attempts to update a ref on the remote — either directly via
git push, or indirectly when a pull request is merged. - GitHub’s server matches the target ref name against any protection rules or rulesets configured for the repository.
- If a rule matches, GitHub evaluates each configured requirement against the current state: has the required number of reviews been given and not gone stale? Have all required status check contexts reported a
successconclusion for this exact commit SHA? Is every commit’s signature verified? Is the pusher on the allowed list? - If every requirement passes, GitHub performs the ref update exactly like normal — the branch pointer moves to the new commit, and nothing about the underlying object graph is different from an unprotected push.
- If any requirement fails, GitHub refuses the update. For a direct
git push, your Git client receives a non-zero exit and prints the rejection GitHub sent back (as in Example 3). For a pull request, the “Merge pull request” button is simply disabled with an explanation of which requirement is unmet.
Because all of this evaluation happens server-side against the ref, cloning the repository, rebasing locally, or otherwise manipulating your own copy of history never bypasses protection — only the actual push or merge does.
Common Mistakes
Mistake 1: A required status check name that never matches. Required status checks are matched by exact string against the context (classic) or job name (Actions) that gets reported. If you type ci/build as the required context but your workflow job is actually named build, GitHub never sees a check with the name it’s waiting for — every pull request stays blocked forever, with the check listed as “Expected — waiting for status to be reported.” The fix is to open one existing pull request, look at the exact name shown next to a completed check, and use that string when configuring the required check.
Mistake 2: Leaving “Include administrators” off. If enforce_admins is false, everything you configured — reviews, status checks, signed commits — is optional for anyone with admin access. Teams frequently protect main, feel secure, and don’t realize admins (often several people, not just one) can still push straight past every rule. For a security-sensitive repository, turn this on and treat admin bypass as a deliberate, logged exception rather than a silent default.
Mistake 3: “Fixing” a blocked merge by disabling protection instead of the underlying problem. A common panic move when a PR is stuck is to remove the branch protection rule, merge, and re-add it. This defeats the entire purpose — a failing status check or a missing review is exactly the information protection exists to surface. Fix the failing check or get the review instead.
Mistake 4: Bare --force on a branch where force pushes happen to be allowed. Even outside main, if you’ve enabled allow_force_pushes on a shared branch (for example a long-lived release/2.0 branch multiple people build on), a bare force push can silently discard a teammate’s commits. Prefer git push --force-with-lease origin release/2.0, which fails instead of overwriting if the remote has commits you haven’t fetched yet.
Best Practices
- Protect
main(and any long-lived release branch) from day one, even on a solo project — it’s free insurance against an accidental force push or deletion. - Require at least one status check tied to your CI so a red build can never be merged.
- Turn on
enforce_adminsfor anything beyond a personal playground repository; make bypassing protection an explicit, visible action rather than an admin’s silent default. - Use
required_signaturestogether with commit signing (GPG or SSH) so every commit onmainis provably from who it claims to be. - Prefer rulesets over classic protection once you’re managing more than a couple of repositories — organization-level rulesets keep policy consistent without configuring each repo by hand.
- Require pull request reviews with stale-review dismissal enabled, so an approval doesn’t silently carry over to a materially different set of commits.
- Don’t leave
allow_force_pushesorallow_deletionsenabled onmainunless you have a specific, narrow reason (like an automated release bot) — and scope that exception to a bot account, not every contributor.
Practice Exercises
- On a test repository, protect
mainwith one required approving review and no required status check. Open a pull request from a second branch and confirm the merge button stays disabled until you (or a collaborator) approve it. - Try pushing a commit directly to your now-protected
mainwithgit push origin main. Read the rejection message closely, then redo the change correctly by pushing to a new branch and opening a pull request. - Enable
required_signatureson a test branch, then make a commit without a signing key configured. Open a pull request and observe how the commit is marked in the GitHub UI, and what it takes to make it “Verified” (see the commit-signing lesson in this section).
Summary
- Branch protection rules are enforced by GitHub’s servers, not by Git — your local repository has no concept of a protected branch.
- Rules can require passing status checks, a minimum number of approving reviews, signed commits, linear history, and can restrict who is allowed to push at all.
- A rejected push shows up as a normal-looking Git error (like
GH006) even though nothing is wrong with the commit itself — only the ref update was refused. - Required status checks must match the reported check name exactly, or the rule blocks pull requests forever.
enforce_adminsdecides whether the rule applies to admins too — leaving it off is the most common way teams end up with protection that doesn’t actually protect.- Rulesets are the newer, more flexible evolution of classic branch protection and can apply consistently across an entire organization.
