Skip to content
ExponaBLOG
GIT & GITHUB FOR PROFESSIONAL DEVELOPERS · DAY 035 / 100

Branch protection rules: real-world lessons and examples

Keep main healthy.

Expona Admin6 min read

We've all been there: a developer pushes directly to main, breaks production, and the incident post-mortem becomes a painful conversation. Branch protection rules are your first line of defense against these scenarios, and after years of managing high-velocity teams, we've learned exactly which rules matter and why. In this article, we'll walk through branch protection rules—real-world lessons and examples—that keep your main branch healthy and your deployments predictable.

Why branch protection rules matter in production environments

Branch protection rules exist to enforce process. Without them, any developer can push directly to main, merge unreviewed code, rewrite history, or force-push over someone else's work. In e-commerce platforms, trading systems, or SaaS products handling user data, that's unacceptable risk.

The good news: GitHub's branch protection API is straightforward, and the rules are simple to understand. The hard part is choosing which rules to enforce and how strictly. Too permissive and you lose safety; too strict and developers spend more time fighting the system than shipping features.

Require pull request reviews

Every merge to main should pass human eyes. This is non-negotiable in any professional codebase.

Here's what this rule does: it blocks merging until at least one (or more) approved review exists. The reviewer must have push access to the repository, and they must approve the PR explicitly.

Real-world scenario: A CRM platform team had a bug where customer emails were being logged unencrypted. A senior engineer reviewing a junior engineer's schema migration caught the oversight before it reached production. Without the review requirement, that data would have been exposed.

To enable this in GitHub:

bash
gh api repos/YOUR_ORG/YOUR_REPO \
  --input - <<EOF
{
  "required_pull_request_reviews": {
    "required_approving_review_count": 1,
    "require_code_owner_reviews": false,
    "require_last_push_approval": false,
    "dismiss_stale_reviews": true
  }
}
EOF

Notice dismiss_stale_reviews: true—this forces a new review if the PR gets updated after approval. This catches sneaky last-minute commits.

When to go stricter: If you're handling payments, user authentication, or regulatory compliance, set required_approving_review_count to 2. If you have code owners (e.g., only the database team can approve schema changes), set require_code_owner_reviews to true and define a CODEOWNERS file.

Pro tip: Use require_last_push_approval sparingly. It forces a fresh approval after any new commit, which slows down iteration but is useful for high-risk repos like infrastructure or payment processing.

Require status checks to pass

Code review catches logic errors; automated checks catch everything else. Tests, linting, security scans, and builds must all pass before merging.

Real-world scenario: A ride-hailing platform's test suite includes end-to-end tests for the geolocation service. A developer refactored the location-matching algorithm and all unit tests passed locally, but the e2e test failed in CI because it didn't account for race conditions in the matching queue. The status check block prevented a race condition from reaching production.

Set this up:

bash
gh api repos/YOUR_ORG/YOUR_REPO \
  --input - <<EOF
{
  "required_status_checks": {
    "strict": true,
    "contexts": [
      "build",
      "test-unit",
      "test-integration",
      "lint",
      "security-scan"
    ]
  }
}
EOF

The strict: true parameter is critical. It requires that your branch be up-to-date with main before merging. Without it, a PR could pass all checks, then someone else merges different code to main, and your PR merges code that was never tested against the new main. That's a silent integration bug waiting to explode.

In practice, your CI/CD platform (GitHub Actions, GitLab CI, etc.) must push commit statuses back to GitHub. Here's a minimal GitHub Actions workflow that does this:

bash
name: CI

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '18'
      - run: npm ci
      - run: npm test
      - run: npm run lint

GitHub automatically creates a status check for each job. If any job fails, the PR gets a red X and cannot merge (assuming you've enabled the rule).

Block force pushes

Force push is a power tool that belongs in feature branches only. Never on main.

What git push --force does: It overwrites remote history. If Alice pushes commits A, B, C to main, and Bob does git rebase --interactive locally and force-pushes, commits A, B, C disappear from main's history. If someone else pulled those commits, their local repo now has "orphaned" commits that don't exist on the remote. This breaks collaboration and makes bisecting bugs a nightmare.

Real-world scenario: A payments team had a developer force-push to main to "clean up" the commit log. The forced push removed a transaction ID that another developer had just referenced in a support ticket investigation. The investigation went cold because the commit was gone, and it took hours to track down what happened via git reflog.

Enable this rule:

bash
gh api repos/YOUR_ORG/YOUR_REPO \
  --input - <<EOF
{
  "allow_force_pushes": false
}
EOF

That's it. Developers can still force-push to their own feature branches, but not to main.

Exception: If you use a squash-and-merge workflow exclusively, this rule is less critical because history rewriting is part of your process. But we still recommend enforcing it—it's a good habit.

Require linear history if you rebase

If your team rebases PRs instead of merging with a merge commit, enable this rule. It keeps main's history clean and linear, making bisecting bugs straightforward.

How this works: When a PR is merged, GitHub checks if the merge would create a merge commit. If it would, the merge is blocked. Developers must rebase their PR onto the latest main before merging.

Real-world scenario: A travel booking platform team noticed their main branch had a complex merge history with multiple "bubbles" where feature branches from different teams collided. When a booking bug was reported, git bisect had to traverse a maze of merge commits, making it hard to pinpoint the culprit. After switching to a linear history rule, the same bug took minutes to track down.

Enable linear history:

bash
gh api repos/YOUR_ORG/YOUR_REPO \
  --input - <<EOF
{
  "required_linear_history": true
}
EOF

This requires that developers either rebase their PR onto main or use "squash and merge," which automatically creates a linear history.

A note on rebase vs. merge: Rebase keeps history clean but rewrites commits, which can confuse newer developers. Squash-and-merge hides intermediate commits but makes reviewing history harder later. We recommend squash-and-merge for most teams because it's simpler and safer.

Common mistakes

  • Dismissing reviews automatically. Setting dismiss_stale_reviews to true on every repo can create a false sense of security. Reviewers may not re-review the updated code carefully if they're just clicking "approve" again out of habit. Use this selectively.
  • Not excluding administrators. By default, branch protection rules apply to everyone, including repo admins. Some teams exempt admins for emergencies. This is fine for incident response, but make it explicit in your runbook. Don't let admins bypass rules silently.
  • Forgetting required status checks on your CI/CD platform. If your GitHub Actions workflow doesn't fail, no status check appears, and required_status_checks can't block a merge. Always ensure your CI platform is reporting status back to GitHub.
  • Enabling too many reviewers too early. Requiring 3 approvals on a small startup team will slow you down. Start with 1, add 2 when the codebase grows or you're handling sensitive data.
  • Blocking merges without a clear bypass path. Rules are good, but emergencies happen. Document how to contact an admin if a legitimate merge is blocked unfairly.

Key takeaways

  • Always require at least one code review before merging to main. Use dismiss_stale_reviews: true to catch sneaky last-minute changes.
  • Require status checks to pass with strict: true so PRs are tested against the latest main, not stale code.
  • Block force pushes to main unconditionally. There's no legitimate reason to rewrite main's history in a team environment.
  • If your team uses rebase-based workflows, enforce linear history to keep git bisect and log archaeology straightforward.
  • Start conservative and relax rules only if they're genuinely slowing the team down. It's easier to loosen rules than to rebuild trust after a production incident.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles

Branch protection rules | Git & GitHub Guide | Expona