Skip to content
Pipelines and Pizza 🍕
Go back

Branch Protection Rules and PR Workflows

13 min read

A few years ago, a teammate force-pushed to main on a Friday afternoon. Not maliciously — they were trying to clean up their commit history and ran git push --force on the wrong branch. The result? Our latest Terraform state reference was gone, three people’s work vanished from the history, and we spent the weekend piecing things back together from reflog and backups.

That Monday, the first thing I did was set up branch protection rules. It took fifteen minutes. The weekend it would have saved us? Priceless.

If you’ve ever had a broken build sneak into production, a PR merged without review, or someone accidentally delete a release branch, this post is for you. Branch protection is the bouncer at the door of your main branch, and every team needs one.


What Are Branch Protection Rules?

Branch protection rules are guardrails you configure on specific branches (usually main or release/*) that enforce policies before code can be merged or pushed. GitHub evaluates these rules on every push and every pull request merge attempt.

Think of them as a checklist that GitHub enforces automatically — no human discipline required.

Here’s the full menu of options available under classic branch protection:

Protection RuleWhat It Does
Require a pull request before mergingNo direct pushes — all changes must come through a PR
Required approving reviews1-6 reviewers must approve before merge (configurable count)
Dismiss stale reviewsNew pushes invalidate previous approvals
Require review from Code OwnersOwners defined in CODEOWNERS must approve changes to their files
Require last push approvalThe person who pushed last cannot be the one to approve
Required status checksSpecific CI checks must pass before merge
Require branches to be up-to-dateBranch must be current with the base branch
Require signed commitsAll commits must be GPG, SSH, or S/MIME signed
Require linear historyOnly squash or rebase merges allowed (no merge commits)
Require merge queuePRs enter a queue and are tested in order before merging
Require deployments to succeedDeployment environments must report success
Lock branchMake the branch read-only
Do not allow bypassingEven admins must follow the rules
Restrict who can pushLimit push access to specific users, teams, or apps
Allow force pushes(Dangerous) Let specific people force push
Allow deletionsAllow the protected branch to be deleted

That’s a lot of knobs. Let’s talk about the ones that matter most for infrastructure teams.


The Rules That Matter Most

Required Pull Request Reviews

This is rule number one. Never let code reach main without another set of eyes. For infrastructure code especially — where a bad terraform apply can delete a production database — requiring at least one approving review is non-negotiable.

I recommend enabling Dismiss stale pull request approvals alongside it. If someone approves your PR and you push new commits afterward, the approval should reset. The reviewer approved a specific version of your code, not a blank check.

Require last push approval is another good one for teams larger than two. It prevents the author from pushing a last-minute change and immediately merging — someone else has to verify that final push.

CODEOWNERS Integration

The CODEOWNERS file lets you map file paths to responsible reviewers. When combined with the Require review from Code Owners protection rule, GitHub automatically requests reviews from the right people.

Place the file in .github/CODEOWNERS, the repo root, or the docs/ directory. GitHub checks them in that order and uses the first one found. Here’s a practical example for an infrastructure repo:

# Default owners for everything
*                           @platform-team

# Terraform modules owned by infra team
/terraform/                 @infra-team
/terraform/modules/network/ @network-team @infra-team

# CI/CD pipelines
/.github/workflows/         @devops-leads

# Ansible playbooks
/ansible/                   @infra-team @sre-team

# The CODEOWNERS file itself -- only leads can modify
/.github/CODEOWNERS         @engineering-leads

An approval from any listed code owner satisfies the requirement. The CODEOWNERS file is read from the base branch of the PR, so changes to CODEOWNERS itself require approval on the current base — you can’t sneak yourself in as an owner via the same PR.

Required Status Checks

This is where CI meets branch protection. You can require that specific GitHub Actions workflows (or external CI systems) pass before a PR can be merged. Common checks for infrastructure repos:

  • terraform fmt -check — formatting is consistent
  • terraform validate — configuration is syntactically valid
  • terraform plan — changes are previewed (even if you review the output manually)
  • tflint — catches common Terraform mistakes
  • checkov or tfsec — security scanning
  • yamllint — for Ansible playbooks or Kubernetes manifests

Enable Require branches to be up-to-date before merging if you want to guarantee that CI ran against the latest version of the base branch. This prevents a scenario where two PRs pass CI individually but break when combined. The trade-off is that contributors will need to merge or rebase more often, which can be frustrating on high-traffic repos.

Signed Commits

Requiring signed commits ensures that every commit is cryptographically verified — proving it came from who it claims to come from. GitHub supports GPG, SSH, and S/MIME signing.

For most individual contributors, SSH signing is the easiest option since you likely already have an SSH key. GPG is the traditional choice with broader tooling support. S/MIME is typically used in enterprise environments with existing certificate infrastructure.

I’ll be honest: not every team needs this. But if you’re in a regulated industry, handle sensitive infrastructure, or want to prevent commit spoofing, it’s worth the setup cost.


Merge Strategies: Pick Your Approach

GitHub offers three ways to merge a PR, and you can enable or disable each one in your repository settings. Branch protection’s Require linear history rule forces squash or rebase only. Here’s when to use each:

StrategyWhat HappensBest For
Merge commitCreates a merge commit preserving all branch commitsOpen source repos, audit trails, preserving contributor history
Squash and mergeCondenses all commits into one on the base branchFeature work with messy WIP commits, clean main history
Rebase and mergeReplays each commit individually onto the base branchLinear history lovers, small PRs with clean commits

My recommendation for infrastructure repos: Default to squash and merge. Infrastructure PRs often have commits like “fix typo”, “try different approach”, “revert that”, “actually this one”. Squashing hides that journey and gives you one clean commit per PR on main. Your git log becomes a readable changelog.

If you require linear history through branch protection, merge commits are disabled automatically — leaving squash and rebase as your options.


GitHub Rulesets: The Modern Approach

GitHub introduced rulesets as a more powerful, scalable alternative to classic branch protection. If you’re setting up a new repository or managing protection across an organization, rulesets are the way to go.

Key advantages over classic branch protection:

  • Multiple rulesets can apply simultaneously — classic protection only allows one rule set per branch pattern
  • Most restrictive rule wins — when rulesets overlap, GitHub picks the strictest setting
  • Organization-wide rulesets — define once, apply across hundreds of repos
  • Bypass lists — grant specific users or apps the ability to bypass rules without making them admins
  • Tag protection — rulesets cover tags too, not just branches
  • Evaluate mode — test a ruleset without enforcing it to see what would be blocked

Rulesets and classic branch protection rules coexist — you don’t have to migrate all at once. But for new setups, I’d start with rulesets directly.


Auto-Merge and Merge Queues

Auto-Merge

GitHub’s auto-merge feature lets a PR author signal “merge this as soon as all requirements are met.” Once approvals and status checks pass, GitHub merges automatically. This is great for:

  • Dependency update PRs (Dependabot, Renovate)
  • Small documentation fixes that don’t need babysitting
  • Infrastructure changes that pass all automated checks

Enable it in repository settings under Allow auto-merge, then PR authors can opt in per PR.

Merge Queues

Merge queues solve a specific problem: on busy repos, PRs that pass CI individually can break when merged together. A merge queue batches PRs, runs CI on the combined result, and only merges if the batch passes.

To enable a merge queue:

  1. Add the Require merge queue rule to your branch protection (or ruleset)
  2. Update your GitHub Actions workflow to trigger on merge_group events:
on:
  pull_request:
    branches: [main]
  merge_group:
    branches: [main]

Configuration options include merge method (squash, rebase, or merge), build concurrency (1-100), minimum/maximum group size, and a status check timeout.

Merge queues are most valuable for repos with 10+ daily PRs where integration failures are a real risk. For smaller teams, the overhead usually isn’t worth it.


Configuring Branch Protection via the API

GitHub REST API

You can configure branch protection programmatically using a PUT request:

curl -L \
  -X PUT \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/OWNER/REPO/branches/main/protection \
  -d '{
    "required_status_checks": {
      "strict": true,
      "contexts": ["terraform-validate", "terraform-plan", "tflint"]
    },
    "enforce_admins": true,
    "required_pull_request_reviews": {
      "dismiss_stale_reviews": true,
      "require_code_owner_reviews": true,
      "required_approving_review_count": 1,
      "require_last_push_approval": true
    },
    "restrictions": null
  }'

Set "restrictions": null to allow anyone with write access to push (after PR requirements are met). Set it to a list of users/teams to lock it down further.

Terraform

The github_branch_protection resource from the integrations/github provider is the infrastructure-as-code approach. This is my preferred method — your branch protection rules live in version control right alongside the repos they protect:

resource "github_branch_protection" "main" {
  repository_id = github_repository.infra.node_id
  pattern       = "main"

  enforce_admins          = true
  required_linear_history = true
  allows_deletions        = false
  allows_force_pushes     = false

  required_status_checks {
    strict   = true
    contexts = [
      "terraform-validate",
      "terraform-plan",
      "tflint",
      "checkov"
    ]
  }

  required_pull_request_reviews {
    dismiss_stale_reviews           = true
    require_code_owner_reviews      = true
    required_approving_review_count = 1
    require_last_push_approval      = true
  }
}

This approach has a huge advantage: when someone asks “what are our branch protection rules?”, the answer is a terraform plan away. No clicking through the UI, no tribal knowledge.


PR Review Best Practices for Infrastructure Code

Reviewing Terraform, Ansible, or Kubernetes manifests is different from reviewing application code. Here’s what I’ve learned:

  1. Always include plan output — attach terraform plan output to the PR as a comment (or use a tool like Atlantis, Spacelift, or Scalr to post it automatically). Reviewers should see what will change, not just the code diff.

  2. Use collapsible sections for large plans — a 500-line plan output will bury the discussion. Wrap it in a <details> tag so reviewers can expand it when they need it.

  3. Separate refactoring from changes — if you’re renaming resources AND adding new ones, split into two PRs. Mixed PRs with destroy/create cycles are terrifying to review.

  4. Run security scanning in CI — don’t rely on reviewers to catch misconfigured S3 bucket policies. Tools like checkov and tfsec catch these automatically.

  5. Review the plan, not just the code — a one-line change in a Terraform module can cascade into dozens of resource modifications. The diff looks small; the blast radius might not be.


Hands-On Lab: Set Up Branch Protection

Let’s configure branch protection from scratch using the GitHub CLI.

Step 1: Create a test repository

gh repo create branch-protection-lab --public --clone
cd branch-protection-lab
echo "# Branch Protection Lab" > README.md
git add README.md && git commit -m "chore: initial commit"
git push -u origin main

Step 2: Add a CODEOWNERS file

mkdir -p .github
cat > .github/CODEOWNERS <<'EOF'
# Default reviewer
* @your-github-username

# Terraform files
*.tf @your-github-username
EOF

git add .github/CODEOWNERS
git commit -m "chore: add CODEOWNERS"
git push

Step 3: Add a CI workflow

mkdir -p .github/workflows
cat > .github/workflows/ci.yml <<'YAML'
name: CI
on:
  pull_request:
    branches: [main]
  merge_group:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lint check
        run: echo "All checks passed"
YAML

git add .github/workflows/ci.yml
git commit -m "ci: add validation workflow"
git push

Step 4: Enable branch protection via the API

# Replace OWNER and REPO with your values
gh api repos/OWNER/REPO/branches/main/protection \
  -X PUT \
  -H "Accept: application/vnd.github+json" \
  -f required_status_checks='{"strict":true,"contexts":["validate"]}' \
  -f enforce_admins=true \
  -f 'required_pull_request_reviews={"dismiss_stale_reviews":true,"required_approving_review_count":1}' \
  -f restrictions=null

Step 5: Test it

git checkout -b test/branch-protection
echo "test change" >> README.md
git add README.md && git commit -m "test: verify branch protection"
git push -u origin test/branch-protection
gh pr create --title "Test branch protection" --body "Verifying rules work"

Try merging the PR immediately — GitHub should block it until CI passes and a review is submitted. That’s your bouncer working.

Step 6: Clean up

gh repo delete branch-protection-lab --yes

Troubleshooting Guide

ProblemCauseFix
PR says “merging is blocked” but checks passedStatus check names don’t match exactlyCompare the check name in the workflow jobs: key with the required check name in settings
Admin merged without review”Do not allow bypassing” is not enabledEnable it — or use rulesets with no bypass list
CODEOWNERS review not requestedFile is on wrong branch or wrong directoryEnsure CODEOWNERS exists on the base branch in .github/, root, or docs/
”Branch is not up to date” errorsStrict status checks require rebasingMerge or rebase against the base branch, then push
Force push was allowedRule not applied or admin bypassCheck “Do not allow bypassing” and “Restrict force pushes” settings
Signed commit check failingContributor hasn’t set up GPG/SSH signingShare signing setup docs — git config --global commit.gpgsign true
Merge queue stuckCI not triggered on merge_group eventAdd merge_group to your workflow triggers
Status check “Expected — Waiting for status to be reported”Workflow never ran for this PRVerify the workflow’s on: trigger matches the PR’s target branch

Quick Reference

# View current branch protection via gh CLI
gh api repos/OWNER/REPO/branches/main/protection

# List rulesets
gh api repos/OWNER/REPO/rulesets

# Check required status checks
gh api repos/OWNER/REPO/branches/main/protection/required_status_checks

# Delete branch protection (use with caution)
gh api repos/OWNER/REPO/branches/main/protection -X DELETE

What’s Next

Next post: PR Templates and Issue Templates. We’ll build standardized templates that make every pull request and issue self-documenting — so reviewers know what they’re looking at and contributors know what to include.

Happy automating!