Skip to content

Required Reviews on GitHub: Enforcing Approval Before Merge

Lesson 5 of 12Intermediate11 min readGitHub Engineering · Pull RequestsVerified: GitHub.com and gh 2.98.0, August 2026

Reviewing is a habit. Required review is a policy the platform enforces, and the difference is whether a change can reach your default branch when nobody looked.

This lesson is about the enforcement layer: what can be required, what each requirement actually guarantees, and where the gaps are.

Review requirements are configured either through branch protection or through a ruleset. Both express roughly the same options:

RequirementWhat it enforces
Required approvals (N)At least N approving reviews before merge
Dismiss stale approvalsApprovals are discarded when new commits are pushed
Require review from code ownersOwners of the touched paths must approve
Require approval of the most recent pushSomeone other than the last pusher must approve
Require conversation resolutionAll review threads resolved before merge
Restrict who can dismiss reviewsLimits dismissal to named actors

Each closes a different hole, and each has a cost. Turning all of them on maximally is not a security posture; it is a way to make a repository unpleasant enough that people work around it.

The base requirement: N people must approve. One is the common choice; two is common for higher-risk repositories.

What it guarantees is thinner than it appears. It guarantees that N accounts pressed approve — not that anyone read the diff. Required approvals raise the cost of merging unreviewed code; they do not make it impossible.

Without this, the sequence approve → push something entirely different → merge is permitted. The approval refers to a commit that is no longer the head.

With it, any push discards existing approvals. This closes the hole and introduces friction: fixing a typo after two approvals costs both of them.

For most teams this should be on. The friction argument is real, and the correct response to it is smaller pull requests rather than weaker policy.

A subtler protection. Without it, an author can approve a colleague’s pull request, then push their own commits to it and merge — their own code, technically approved.

This requirement demands that the approver be someone other than whoever pushed last. It is the setting that makes “two people saw this” genuinely true, and it is the one most often left off.

Ties approval to the right people rather than any people. Configured through CODEOWNERS, which maps paths to owners.

Worth understanding precisely: CODEOWNERS decides who is asked. This setting decides whether their answer is required. They are two separate decisions and both must be made.

Prevents merging with unresolved review threads. Cheap, and it stops the common failure where a substantive question is left unanswered and merged past.

Why approved pull requests still cannot merge

Section titled “Why approved pull requests still cannot merge”

The single most common confusion. gh pr view distinguishes the two:

Terminal window
gh pr view PULL_NUMBER --json mergeable,mergeStateStatus,reviewDecision

What it doesReports Git mergeability, the policy state, and the review decision as three separate fields.

Why we run itThey answer different questions. A pull request can be cleanly mergeable in Git terms, fully approved, and still blocked by policy — and only mergeStateStatus says so.

Expected resultA JSON object with the three fields, which frequently disagree.

Output:

{
"mergeStateStatus": "BLOCKED",
"mergeable": "MERGEABLE",
"reviewDecision": "APPROVED"
}

Common mergeStateStatus values and what they mean:

ValueMeaning
CLEANEverything satisfied
BLOCKEDA policy requirement is unmet — reviews, checks, conversations
BEHINDBase has moved and up-to-date branches are required
DIRTYMerge conflicts
UNSTABLEA non-required check is failing

UNSTABLE is worth knowing: it means something failed but nothing that blocks. Merging into an UNSTABLE state is legitimate and sometimes correct, and it is also how flaky non-required checks get ignored into uselessness.

Every enforcement mechanism needs an escape hatch, and every escape hatch is a hole.

Bypass can be granted to repository administrators, to named teams, or to specific Apps. The questions worth answering deliberately:

Do administrators bypass by default? Historically administrators could ignore branch protection unless the rules were explicitly applied to them. Enabling enforcement for administrators is usually correct — the point of the policy is that it applies.

Which Apps need bypass? Automation that merges, or that pushes release commits, often needs it. Grant it to the App, not to a human account it borrows.

Is there a break-glass path? During an incident, someone may need to push a fix without waiting for review. Deciding this in advance, with an audit trail, is far better than discovering it at three in the morning.

Being clear-eyed about this matters, because required review is often treated as a security control.

It does not guarantee anyone read the code. Approval is a button.

It does not stop a malicious insider with approval rights. Two colluding accounts satisfy a two-approval rule.

It does not cover everything. Wikis are a separate repository with no protection. Repository settings changes are not pull requests. Releases can be published without review.

It does not apply to bypassers. See above.

Required review is a control against accident and haste, and it is very good at that. Against a determined adversary it is one layer among several, alongside signed commits, restricted push access, audit logging and required status checks.

For a team repository where main is deployed:

  • Require a pull request before merging
  • One approval, or two for anything with production impact
  • Dismiss stale approvals on push
  • Require approval of the most recent push
  • Require conversation resolution
  • Require the status checks that genuinely gate quality — no more
  • Enforce for administrators
  • Code owner review on the paths that have genuine owners

For a solo project, most of this is friction with no benefit. Requiring a pull request and status checks gives you the CI gate and the record without the approval deadlock that self-approval prohibition creates.

Terminal window
gh api repos/OWNER/REPO/branches/main/protection \
--jq '{reviews: .required_pull_request_reviews, checks: .required_status_checks.contexts}'
gh ruleset list --repo OWNER/REPO
gh ruleset check --repo OWNER/REPO main

gh ruleset check is genuinely useful — it reports which rules would apply to a given branch, including rules inherited from organisation-level rulesets that are not visible in the repository’s own settings.

Turning on required reviews for the first time on an active repository is disruptive if done carelessly. A sequence that works:

  1. Announce it, with the reason. “We shipped three incidents from unreviewed pushes last quarter” makes the case; “best practice” does not.
  2. Require a pull request first, with no approval requirement. People adjust to the workflow change without also being blocked.
  3. Add one approval a week or two later.
  4. Add stale dismissal, which is where friction becomes noticeable.
  5. Add code owner review last, once CODEOWNERS is accurate.

Doing steps 2 through 5 simultaneously produces a repository where nothing merges for a day and everyone concludes the policy is the problem.

Skipping the announcement produces the same outcome plus resentment. A push rejected for a reason nobody explained is experienced as a tooling failure.

Review requirements are usually discussed as a number, but the settings around them do more work than the number does.

Required conversation resolution is the cheapest genuine improvement available. It costs almost nothing and prevents the specific failure where a reviewer asks a substantive question, the author pushes an unrelated fix, and it merges with the question unanswered.

Required status checks are usually more valuable than a second approval. A human might miss a broken test; the test does not.

Require branches to be up to date closes the correctness gap where a change passed CI against an older base. On a low-traffic repository it costs nothing. On a busy one it creates the race that merge queues exist to solve — so enable it, and if it becomes painful, that pain is the signal to consider a queue rather than to disable the requirement.

Restrict who can push is worth considering even alongside required pull requests. It constrains who performs the merge, not just who proposes — which matters if your concern is a compromised account rather than a careless colleague.

GitHub does not permit approving your own pull request. On a single-maintainer repository, requiring one approval therefore means nothing can ever merge.

Three workable answers:

Do not require approvals. Require a pull request and status checks instead. You still get the record, the CI gate and the diff review — you simply cannot approve, and you merge on the checks.

Add a bypass for yourself. Honest, and it makes the policy decorative.

Use an App to approve. Legitimate for narrow automated cases such as dependency updates that pass a strict allowlist, and a poor idea generally, since it satisfies a human-review requirement with no human.

The first is usually right. The value on a solo repository is the CI gate and the record, not the approval.

When behaviour is confusing, the question is what actually applies — which can come from branch protection, a repository ruleset, an organisation ruleset, or all three.

Terminal window
gh ruleset check --repo OWNER/REPO main
gh ruleset list --repo OWNER/REPO
gh api repos/OWNER/REPO/branches/main/protection --jq '.required_pull_request_reviews'

gh ruleset check is the one to run first: it resolves everything into the effective rules for one branch, including organisation-level rules that do not appear in the repository’s own settings and are invisible to a repository administrator looking for them.

The failure mode this prevents: spending an afternoon removing a branch protection setting and finding the behaviour unchanged, because an organisation ruleset was enforcing it all along.

Requiring reviews on a solo repository. Self-approval is prohibited, so nothing can merge.

Leaving stale dismissal off. Permits approve-then-change.

Not requiring approval of the most recent push. Permits self-merging your own additions.

Treating CODEOWNERS as enforcement. It routes; a separate setting enforces.

A long bypass list. Quietly nullifies the policy.

Requiring every check. Flaky non-essential checks block merges and teach people to bypass.

Assuming required review is a security boundary. It is a control against accidents.

Use a repository where you can add a collaborator, or a second account.

  1. Enable a rule requiring one approval on main.
  2. Open a pull request and confirm you cannot approve your own.
  3. Have the second account approve, then push a new commit as the author.
  4. Check gh pr view --json reviewDecision,mergeStateStatus — whether the approval survived tells you how stale dismissal is configured.
  5. Enable stale dismissal and repeat step 3. Observe the difference.
  6. Run gh ruleset check --repo OWNER/REPO main and read what applies.

Governance accumulates. Requirements are added after incidents and rarely removed, so a repository’s policy tends to reflect every problem it has ever had rather than the ones it currently has.

An annual review is worth the half hour. For each requirement, three questions:

What failure does this prevent? If nobody can name one, it is a candidate for removal.

Has it ever prevented that failure? Rule insights, where available, answer this directly. A rule that has never blocked anything is either perfectly deterrent or unnecessary, and the distinction matters.

What does it cost? Latency per change, multiplied by every change. A requirement adding two hours to every merge on a repository merging fifty times a week is a hundred hours a week of waiting.

The honest outcome of such a review is usually that one or two requirements should go and one should be added. A policy that only grows is one nobody is thinking about.

What is appropriate scales with how many people can push, not with how important the code is.

ContributorsReasonable baseline
OnePull request required, status checks, no approval requirement
Two to fiveOne approval, stale dismissal, conversation resolution
Five to twentyAdd code owner review on paths with real owners
Twenty or moreAdd most-recent-push approval; consider a merge queue
Public contributionsAll of the above, plus restricted push access

A solo repository with two required approvals cannot merge at all. A twenty-person repository with no requirements will have unreviewed changes on the default branch within a week. Matching the policy to the situation is more useful than applying the strictest available everywhere — which is what produces the bypass lists that make the policy decorative.

  • Required approvals guarantee that buttons were pressed, not that code was read.
  • Stale dismissal closes the approve-then-change hole at a real cost in friction.
  • Requiring approval of the most recent push is what makes “two people saw this” true.
  • CODEOWNERS routes review; a separate setting makes it required.
  • mergeable and mergeStateStatus answer different questions.
  • The bypass list is where enforcement is actually decided.

If you are adding review requirements to a repository that has none, the highest-value first step is not an approval count. It is requiring a pull request at all, plus the status checks that genuinely gate quality.

That alone gives you the record, the diff, and the CI gate — most of the benefit, with almost none of the friction. Approval requirements are worth adding next, and everything beyond that should be justified by a failure you can name.

The bypass list determines what your policy actually enforces, and its use is worth reviewing rather than assuming.

Where rulesets are in force, rule insights record evaluations including bypasses:

Terminal window
gh api "repos/OWNER/REPO/rulesets/rule-suites?per_page=100" --paginate \
--jq '.[] | select(.result == "bypass")
| [.pushed_at[0:10], .actor_name, .ref] | @tsv'
2026-08-19 release-bot refs/heads/main
2026-08-14 alice refs/heads/main
2026-08-02 release-bot refs/heads/main

Read that list with two questions. Is the App’s use expected? Release automation pushing to main is what its bypass exists for, and seeing it confirms the configuration works.

Is the human’s use explained? One entry during a known incident is fine. A pattern of them means the rule is wrong for the work — either it prevents something legitimate, or the emergency path is being used routinely because the normal path is too slow.

Either conclusion is actionable, and neither is visible without looking. A bypass list nobody reviews is a policy nobody is enforcing, and the people subject to it will notice long before the people who configured it do.

Professional ToolkitCODEOWNERS, pull request and issue templates, and repository configuration checklists ready to adapt.