A Practical Guide to GitHub Branch Protection Rules

GitHub branch protection is one of those features that most people either ignore entirely or enable with whatever defaults seemed reasonable at the time. Both are mistakes. The defaults are too loose for anything important, and ignoring protection entirely is fine until the day someone force-pushes to main and your afternoon disappears.

The rules themselves are straightforward enough. The challenge is that there are twelve of them, they interact in non-obvious ways, and the right configuration depends heavily on your team size, release process, and risk tolerance. This post walks through every rule, explains what it actually does (including edge cases the docs don’t emphasize), and offers concrete configurations for different scenarios.

All examples use rampart configs, so you can take any of these, save it as rampart.yaml, and apply it across your repos with rampart apply --owner yourname.

The Baseline: What Unprotected Looks Like

With no branch protection at all, anyone with write access to a repo can:

  • Push directly to main (no PR required)
  • Force-push to main (rewriting history)
  • Delete the main branch
  • Merge PRs without any reviews
  • Push code that breaks CI

For a personal project with one contributor, this is fine. For anything with collaborators, users, or deployments, it’s a time bomb. The real question is which rules to turn on.

Pull Request Reviews

This is the most impactful rule and the one most teams should start with.

require_pull_request

When enabled, nobody can push commits directly to the protected branch. All changes must come through a pull request. This is the foundational rule that enables code review, CI checks, and audit trails.

rules:
  require_pull_request: true

What people miss: this rule alone doesn’t require reviews. It just requires that changes go through a PR. Someone can open a PR, approve it themselves, and merge it. The next few rules are what make PR requirements meaningful.

required_approvals

The number of approving reviews required before a PR can be merged. Setting this to 1 means at least one person besides the author must approve.

rules:
  require_pull_request: true
  required_approvals: 1

The right number depends on team size and risk. For most teams, 1 is the right answer. It ensures a second pair of eyes on every change without creating a bottleneck. Teams I’ve seen try 2 required approvals often end up with rubber-stamp reviews because the second reviewer knows the first already looked at it.

For high-risk codebases (financial systems, auth infrastructure, anything handling PII), 2 can make sense. For solo maintainers with occasional contributors, 1 is sufficient - it just means you can’t merge your own PRs without someone else looking.

Setting this to 0 while still requiring a PR is useful for repos where you want the PR workflow (CI runs, discussion thread, audit trail) without blocking on reviews. Open source projects with a single maintainer sometimes use this.

dismiss_stale_reviews

When enabled, any existing approvals are dismissed when new commits are pushed to the PR branch. Without this, someone could get an approval, then push entirely different code and merge without re-review.

rules:
  require_pull_request: true
  required_approvals: 1
  dismiss_stale_reviews: true

This should almost always be true. The only argument against it is in high-throughput teams where reviewers frequently ask for small tweaks and don’t want to re-approve after each fixup commit. But the security tradeoff isn’t worth the convenience. If you trust your contributors enough to skip re-review, you probably also trust them enough that the occasional extra approval click isn’t a real cost.

require_code_owner_reviews

When enabled, PRs that modify files with designated code owners (defined in a CODEOWNERS file) require approval from those specific owners, not just any reviewer.

rules:
  require_pull_request: true
  required_approvals: 1
  require_code_owner_reviews: true

This only matters if you have a CODEOWNERS file. If you don’t, enabling this rule does nothing. But if you do, it’s powerful for large repos where different teams own different directories. A change to infrastructure/ requires infra team approval even if a frontend engineer technically has write access.

For small teams and personal projects, this adds overhead without much benefit. Skip it unless you have clear ownership boundaries.

Status Checks

Status checks are the bridge between branch protection and CI. They let you require that specific CI jobs pass before a PR can be merged.

require_status_checks

Enables the requirement that configured status checks must pass before merging.

rules:
  require_status_checks: true
  required_checks:
    - "CI / build (1.21)"
    - "CI / build (1.22)"
    - "CI / build (1.23)"

The required_checks list refers to the names of GitHub Actions jobs or other CI status checks as they appear on a PR. Getting these names right is the most common point of confusion. The name comes from the combination of workflow name, job name, and matrix parameters as they show up in the PR checks UI, not the workflow filename.

For a workflow like:

name: CI
jobs:
  build:
    strategy:
      matrix:
        go-version: ['1.21', '1.22', '1.23']

The check names would be CI / build (1.21), CI / build (1.22), and CI / build (1.23). If you get the names wrong, the checks will never show as passing and PRs become unmergeable. Start by looking at an existing PR to see the exact check names before configuring this.

One important caveat: you need to have the checks run at least once on the repo before you can require them. GitHub won’t let you require a check that has never existed. For a brand new repo, push code with CI configured, let it run, then enable the requirement.

strict_status_checks

When enabled, the PR branch must be up to date with the base branch before merging. This means if someone merges another PR to main while yours is open, you have to merge or rebase main into your branch and re-run CI before you can merge.

rules:
  require_status_checks: true
  strict_status_checks: true

This prevents a subtle class of bugs: two PRs that are individually correct but broken when combined. PR A changes a function signature, PR B calls the function with the old signature. Both pass CI against the current main, but merging both would break main.

The tradeoff is velocity. Strict status checks create merge queues in all but name. If your team merges ten PRs a day, requiring each one to be up-to-date with main before merging means a lot of rebasing and re-running CI. For repos with infrequent merges this is fine. For high-throughput repos it can be painful.

My recommendation: enable it for anything that deploys automatically. Skip it for libraries and tools where a broken main gets caught by the next PR’s CI run anyway.

Administrative Rules

enforce_admins

When enabled, branch protection rules apply to repository administrators too. When disabled, admins can bypass all protections.

rules:
  enforce_admins: true

This is the rule most teams get wrong. The default instinct is to leave admins unrestricted “just in case.” But the whole point of branch protection is to prevent mistakes, and admins make mistakes too. Especially admins, because they’re often the ones making changes under time pressure.

The argument for leaving it off: sometimes you genuinely need to push an emergency fix to main without waiting for CI or review. A broken production deploy at 2am isn’t the time to find a second reviewer.

My take: enable it. If you have a genuine emergency, you can temporarily disable protection, make the fix, and re-enable it. That friction is a feature - it forces you to be deliberate about bypassing safeguards rather than accidentally doing so.

allow_force_pushes

Controls whether force pushes (rewriting history) are allowed on the protected branch.

rules:
  allow_force_pushes: false

This should be false on any branch that multiple people work with. Force pushing rewrites history, which means anyone who has already pulled the branch will have a divergent history. It’s the single most disruptive Git operation and the one most likely to cause someone to lose work.

The only legitimate use of force pushing to a shared branch is fixing a secret that was accidentally committed. And even then, you should coordinate with the team first and understand that the secret is already compromised and needs to be rotated regardless.

allow_deletions

Controls whether the protected branch can be deleted.

rules:
  allow_deletions: false

Deleting your main branch is catastrophic but surprisingly easy to do accidentally. This should always be false. I’ve never heard a legitimate reason to allow deleting a protected branch through normal workflow.

Workflow Rules

required_linear_history

When enabled, only squash merges and rebase merges are allowed. Regular merge commits are blocked.

rules:
  required_linear_history: true

Linear history means git log on main reads like a clean sequence of changes rather than a tangled graph of merges. It makes git bisect more effective, makes reverts cleaner, and makes the commit history easier to understand.

The tradeoff is that contributors can’t use merge commits. Some teams prefer merge commits because they preserve the full development history of a feature branch. Others prefer squash merges because they collapse the “work in progress” commits into a single clean commit.

This is genuinely a matter of team preference. Neither approach is wrong. But if you have a preference, enforcing it through branch protection is better than documenting it in a CONTRIBUTING.md that nobody reads.

required_conversation_resolution

When enabled, all review comments on a PR must be marked as resolved before merging.

rules:
  required_conversation_resolution: true

This prevents the “I’ll fix it later” merge. Someone leaves a review comment about a potential issue, the author responds “good point, will address in a follow-up,” and then merges. The follow-up never happens.

The downside is that it can slow down merges when a reviewer leaves a nitpick comment and then goes offline. The author can’t merge until the reviewer comes back and resolves their own comment (or the author resolves it, which some teams consider bad etiquette).

For teams with timezone spread, this rule can be frustrating. For co-located teams with fast review cycles, it’s a good forcing function.

Putting It Together: Configs by Scenario

Solo maintainer, personal projects

The lightest config that still prevents accidents:

branch: main
rules:
  require_pull_request: false
  required_approvals: 0
  dismiss_stale_reviews: false
  require_code_owner_reviews: false
  require_status_checks: false
  strict_status_checks: false
  required_checks: []
  enforce_admins: true
  allow_force_pushes: false
  allow_deletions: false
  required_linear_history: false
  required_conversation_resolution: false

No PR requirement (you’re the only contributor), but force pushes and deletions are blocked. This prevents the two most destructive accidental operations while adding zero friction to your workflow. enforce_admins: true ensures you can’t accidentally bypass even these minimal protections.

Small team (2-5 people), no CI

branch: main
rules:
  require_pull_request: true
  required_approvals: 1
  dismiss_stale_reviews: true
  require_code_owner_reviews: false
  require_status_checks: false
  strict_status_checks: false
  required_checks: []
  enforce_admins: true
  allow_force_pushes: false
  allow_deletions: false
  required_linear_history: false
  required_conversation_resolution: false

This is rampart’s default config, and it’s where most small teams should start. Every change goes through a PR with one approval. Stale reviews get dismissed. Admins follow the same rules as everyone else. No CI requirements because not every repo has CI configured.

Team with CI

branch: main
rules:
  require_pull_request: true
  required_approvals: 1
  dismiss_stale_reviews: true
  require_code_owner_reviews: false
  require_status_checks: true
  strict_status_checks: false
  required_checks:
    - "CI / build"
    - "CI / test"
  enforce_admins: true
  allow_force_pushes: false
  allow_deletions: false
  required_linear_history: false
  required_conversation_resolution: false

Adds CI requirements on top of the small team config. Note strict_status_checks: false - this avoids the merge queue problem while still requiring that CI passes on the PR branch. This is a good balance for teams that merge a few PRs per day.

High-trust team, clean history

branch: main
rules:
  require_pull_request: true
  required_approvals: 1
  dismiss_stale_reviews: true
  require_code_owner_reviews: false
  require_status_checks: true
  strict_status_checks: true
  required_checks:
    - "CI / build"
    - "CI / test"
    - "CI / lint"
  enforce_admins: true
  allow_force_pushes: false
  allow_deletions: false
  required_linear_history: true
  required_conversation_resolution: true

The strictest config that’s still practical for a small team. Linear history and conversation resolution enforced. Strict status checks require branches to be up to date before merging. This works well for teams that value a clean main branch and don’t merge frequently enough for the rebase requirement to be painful.

Regulated / compliance-driven

branch: main
rules:
  require_pull_request: true
  required_approvals: 2
  dismiss_stale_reviews: true
  require_code_owner_reviews: true
  require_status_checks: true
  strict_status_checks: true
  required_checks:
    - "CI / build"
    - "CI / test"
    - "CI / security-scan"
    - "CI / lint"
  enforce_admins: true
  allow_force_pushes: false
  allow_deletions: false
  required_linear_history: true
  required_conversation_resolution: true

Two required approvals, code owner reviews, full CI suite including security scanning, everything enforced. This is appropriate for repos that handle PII, financial data, or infrastructure where a bad merge has real consequences. The friction is high, but so are the stakes.

The Rules That Matter Most

If you’re going to configure only three rules and ignore the rest:

  1. allow_force_pushes: false - prevents the most destructive accidental operation
  2. allow_deletions: false - prevents the second most destructive accidental operation
  3. enforce_admins: true - ensures the above actually apply to everyone

Everything else builds on these. Pull request requirements add process. Status checks add automation. Review requirements add human judgment. But protecting against force pushes and deletions is the floor.

Applying Consistently

The hardest part of branch protection is applying rules consistently across all your repos and keeping them that way over time. New repos get created without protection. Someone tweaks a setting on one repo and forgets the others. Drift happens.

This is why I built rampart. Pick one of the configs above (or write your own), save it as rampart.yaml, and run:

# See what's out of compliance
rampart audit --owner yourname

# Fix it
rampart apply --owner yourname

The audit command exits non-zero when any repos are non-compliant, so you can run it in CI as a drift detector. Define the policy once, enforce it everywhere.