Protecting Your Main Branch Is the Cheapest Quality Gate You Have
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Rule Nobody Enforces Until It's Too Late
Your team has a shared understanding: don't push directly to main, always get a code review, only merge when CI passes. It works fine — until it doesn't. A developer is debugging a production incident and pushes a quick fix directly to main. Another developer squashes a flaky test by disabling it in CI and pushes before anyone notices. A third accidentally merges a PR with failing checks because the UI was confusing.
None of these are bad developers. They're humans under pressure making mistakes that a fifteen-minute configuration change would have prevented.
Branch protection is the technical enforcement of the agreements your team already made verbally. The verbal agreement relies on everyone remembering and applying the rules consistently under pressure. The configuration doesn't.
The Core Rules and What They Prevent
Require a pull request before merging. Prevents direct pushes to main. Code must go through a PR, which means it goes through CI and is visible for review before it lands in the shared branch.
Without this: someone pushes directly to main at midnight to fix a bug. Nobody reviews it. It breaks something else. Nobody knows what changed.
Require status checks to pass before merging. Specifies which CI checks must succeed before a PR can merge. Prevents "it worked on my machine" merges.
Without this: a developer merges a PR where the database migration test failed. The next morning, everyone's local environments and the staging database are out of sync.
Require branches to be up to date before merging. Ensures the PR's CI results reflect the current state of main, not the state of main when the PR was first opened. Prevents merges where two PRs each pass CI but conflict when combined.
Without this: PR A and PR B both pass CI. PR A merges. PR B's CI results are now stale (they don't include PR A's changes). PR B merges. Main breaks.
Require at least one approving review. Ensures a second set of eyes before any code lands. Minimum viable code review enforcement.
Do not allow bypassing the above settings. Prevents admins from merging without meeting the same requirements. This is the one most teams skip, but it's important — if admins routinely bypass protection rules, the rules have no teeth.
Configuring Branch Protection on GitHub
Settings → Branches → Branch protection rules → Add rule → Branch name pattern: main
Branch protection rule for: main
[x] Require a pull request before merging
[x] Require approvals: 1
[x] Dismiss stale pull request approvals when new commits are pushed
[x] Require status checks to pass before merging
[x] Require branches to be up to date before merging
Status checks:
+ test / unit-tests
+ test / integration-tests
+ lint / code-quality
[x] Require conversation resolution before merging
[x] Do not allow bypassing the above settings
[ ] Allow force pushes (leave unchecked)
[ ] Allow deletions (leave unchecked)
The status check names come from your CI configuration — they must match the job names exactly. Run a push first, look at the checks that appear on the PR, and copy the names.
The Required Status Checks Problem
Required status checks only block if the check actually runs and fails. If the check is optional (not required), a PR can merge even if it didn't run. If the CI job doesn't exist, GitHub shows the check as "expected but not found" — which blocks the merge until you fix the CI configuration.
This is the right behavior: if you said "this check is required," and the check didn't run, you don't know if the code is valid. Blocking is correct.
The common mistake: requiring a status check by name and then renaming the CI job, or changing the job's trigger so it no longer runs on PRs. The PR queue stops because every PR is blocked by a check that never runs. Keep required status check names in sync with CI job names — add a linter or a test for it if you have enough PRs.
For GitLab: Protected Branches
GitLab's equivalent is in Settings → Repository → Protected Branches:
Branch: main
Allowed to merge: Developers + Maintainers
Allowed to push: No one
Allowed to force push: (unchecked)
Code owner approval: Required
Merge checks:
[x] Pipelines must succeed
[x] All discussions must be resolved
[x] All threads must be resolved
The "Allowed to push: No one" setting is the GitLab equivalent of "require a pull request." Nobody can push directly — even maintainers must go through a merge request.
What Happens to Developers
The most common objection to enabling branch protection is "it slows down the team." This is only true if the team was relying on shortcuts — direct pushes, skipping reviews — that branch protection now prevents.
The correct response to "branch protection is slowing us down" is to identify which shortcuts it's preventing and evaluate whether those shortcuts were actually good practice. Direct pushes to main without review are not a productivity tool. They're a risk that hadn't paid off yet.
For legitimate speed requirements (hotfix deployment at 3am), branch protection rules allow configured bypass for specific roles. Configure a "hotfix" role or allow direct pushes from a CI service account. The bypass is explicit and audited, not a gap in the protection.
The Audit Trail
Every merge to a protected branch is logged with: who merged, when, what PR, what review approvals existed, which CI checks passed. This is the audit trail that matters for compliance, security reviews, and post-incident analysis.
"We always require review before merging" is an assertion. Protected branches make it verifiable — you can query the merge history and confirm that every merge had the required approvals and passed the required checks. Without branch protection, the assertion has no evidence behind it.
Set up branch protection before you need to prove you had it. After an incident is the wrong time to discover you couldn't demonstrate your review process was enforced.