Two green GitHub pull requests broke main — require branches to be up to date before merging

Why a GitHub pull request with every check green can still break main, how Require branches to be up to date before merging stops it, and rules for main.

The GitHub Invertocat logo, a cat silhouette cut out of a black circle, beside the GitHub wordmark in black on a white card
On this page

Introduction

I merge a pull request once every check on it is green. So it was a surprise when one of my pull requests failed CI on a test file it never touched.

The failure was not mine at all. Main itself had gone red right after another pull request merged with every check passing, so every open pull request was now red for a reason outside it. The broken main was also hiding a real bug that no test had run against yet.

The checks on the merged pull request were not wrong, only old. They had run before a different pull request merged, and that pull request added code the merged one broke. GitHub’s Require branches to be up to date before merging makes a merge like that wait for a fresh run.

This article walks through how two green pull requests add up to a red main, and the other rules worth setting on main alongside that one.

How two green pull requests merged into a red main

A green check is a result about one snapshot of main. For a pull_request event, GitHub Actions tests a merge commit of the pull request branch and the base branch, built at the moment the workflow starts. Nothing re-runs that test when the base branch moves later.

That leaves room for this sequence:

  1. Pull request A runs CI and goes green. CI tested A merged into main as main was at that moment.
  2. While A waits for review, pull request B merges into main.
  3. A merges. Its green result was computed against a main that no longer exists, and nothing ran CI on A plus B before they met on main.
Two green pull requests and one red mainTwo panels. In both, pull requests A and B branch from the same main commit and each passes CI. In the top panel, B merges first and A merges later on its old green result without a new CI run, so main turns red with a type error. In the bottom panel, after B merges, A is marked behind main; updating it re-runs CI on A plus B, which fails, so the merge is blocked and main stays green.Without the rulePull request AmainPull request BCI ✓CI ✓ on old mainno CI run on A + Bmain ✗ type errorWith “Require branches to be up to date before merging”Pull request AmainPull request BCI ✓CI ✓ on old mainBehind mainUpdate branchCI ✗ on A + B✗merge blockedmain stays green
Each pull request was green against the main it was tested on. The combination was never tested until it was already on main.

Most of the time A and B do not interact, and the stale result is still right. Here they did. Pull request A added four methods to a shared interface and updated the test fakes that implement it. Pull request B, written in parallel, added a new test with its own fake of that interface, implementing the old method set.

They edited different files, so Git merged them without a conflict, and the type checker failed on main (class names shortened):

error: Argument "connector" to "Pipeline" has incompatible type "FakeConnector"; expected "FileConnector" [arg-type]

A second break sat behind the type error. Pull request B had also changed a shared error helper so that a 404 from the storage API became the project’s own NotFoundError. Pull request A’s new restore() method still caught the raw 404 to return False, so after both merged, restoring a permanently deleted file raised instead.

A test for exactly that case existed, and it never ran. The CI job stopped at the type check before reaching the test step, so the test only failed once the type error was fixed.

What branch protection is

Branch protection is a set of rules GitHub enforces on a branch before a change can land on it. Typical rules require a pull request instead of a direct push, a number of approving reviews, and passing status checks, and they block force pushes and deletion of the branch.

GitHub offers two ways to configure them. Branch protection rules, under Settings → Branches, allow one rule per branch pattern. Rulesets, under Settings → Rules → Rulesets, are named lists of rules, and several can target the same branch.

When rulesets overlap with each other or with a branch protection rule, the most restrictive version applies. Anyone with read access can view a repository’s active rulesets, so a developer can check what main requires without asking an admin.

Required status checks come in two modes. In GitHub’s terms, a strict check requires the branch to be up to date with the base branch before merging, and a loose check does not. The repository in this story had required checks in loose mode, which is exactly the gap in the timeline above.

Require branches to be up to date before merging

With this setting on, a pull request that is behind its base branch cannot merge. GitHub offers an Update branch button, which brings the latest main into the pull request branch and triggers CI again, and the merge waits for that new run to pass.

The merge box of a GitHub pull request. Review required is marked with a red cross, and All checks have passed shows 5 successful checks with a green tick. Below them, This branch is out-of-date with the base branch carries an Update branch button. Merging is blocked, citing the required approving review, and the Merge pull request button is greyed out.

Every check is green, and the branch is still behind main. Update branch is what brings it up to date and starts CI again.

In the timeline above, pull request A would have been held after B merged. Its re-run would have hit the type error, and main would have stayed green.

The cost is re-runs. Every merge into main puts every other open pull request behind it, and each one needs an update and a fresh CI run before it can merge.

With a CI run of about a minute and a half and a small team, that is a short wait per merge. On a busy repository the updates pile up, which is what merge queues are for.

Where the setting lives

It is not a rule of its own. In a ruleset, it appears under Require status checks to pass once that rule is enabled. In a classic branch protection rule, it is the checkbox indented under Require status checks to pass before merging, and it stays hidden until the parent is checked.

A ruleset's branch rules in GitHub. Require status checks to pass is checked, and under its expanded additional settings, Require branches to be up to date before merging is checked while Do not require status checks on creation is not. Below them a panel reads No required checks, with an Add checks dropdown open on a Search for checks box. Block force pushes is checked further down.

In a ruleset, the setting sits in the additional settings of Require status checks to pass.

Either way it needs at least one required check selected, since there is nothing to re-run otherwise.

The checkbox that only suggests

Settings → General → Pull Requests has a setting with a similar name that does something weaker:

Always suggest updating pull request branches Whenever there are new changes available in the base branch, present an “update branch” option in the pull request.

The pull request branch update setting in GitHub's general repository settings. Under the line Control how and when users are prompted to update their branches if there are new changes available in the base branch, a single unchecked box reads Always suggest updating pull request branches.

The repository-level checkbox controls whether the Update branch button appears, not whether merging waits for it.

It adds the button and nothing else. A pull request that is behind can still merge with it on, so it would not have stopped the merge in the timeline above.

A baseline for main, and where each rule bites

The rules below are a starting point for main on a repository where several people merge pull requests. Each row says what the rule stops; drop the ones whose failure you can live with.

RuleWhat it stops
Require a pull request before mergingDirect pushes to main that skip review and CI
Require approvals, and dismiss stale approvals when new commits are pushedMerging unreviewed code, and an approval surviving changes made after it
Require status checks to pass, with each check namedMerging a pull request whose CI is red
Require branches to be up to date before mergingMerging a green result computed against an older main
Require conversation resolution before mergingMerging while review threads are still open
Block force pushes, and restrict deletionsRewriting or deleting main’s history
Do not allow bypassing the above settings (classic), or keep the ruleset’s bypass list emptyAdmins merging past every rule above

The repository in this story applied its rules to non-admins only, so even a complete rule set would not have covered a merge made by an admin.

Required checks have to name the right job

A required check is matched by name. In this repository, two workflows each had a job named check, one for the application code and one for the infrastructure code, and the branch rule listed a single required check called check. From that list alone, there is no way to tell which workflow it means.

Give every job a name that is unique across workflows, such as app-check and infra-check, and require each one by that name.

Renaming a job that is already required

Renaming a required job needs an order. The pull request that renames it stops reporting the old name, so while the old name is still required, that pull request waits on a check that will never arrive.

Removing the old requirement first would merge it, but leaves main with no required check until the new names are added.

The order that kept main covered the whole time:

  1. Open the rename pull request and let its CI run once, so the new check names exist. The settings page only offers checks it has seen: its search box reads “Search for status checks in the last week for this repository”.
  2. Replace the old required check with the new names, and turn on Require branches to be up to date before merging in the same edit.
  3. Merge the rename pull request. Its CI already reported the new names, so it passes the new requirement.
  4. Update the other open pull requests. Until their CI runs again, the new checks show as “Expected — Waiting for status to be reported” and the merge stays blocked.
Renaming a required job in four stepsFour steps in columns. Step 1: the rename pull request runs CI once and reports app-check and infra-check, but it waits for the old required check named check, which it no longer reports. Step 2: the required checks are swapped from check to app-check and infra-check, with the up-to-date rule turned on; the rename pull request now passes, while other open pull requests show Expected — Waiting. Step 3: the rename pull request merges; other pull requests are still waiting and behind main. Step 4: the other pull requests are updated and their CI passes. Throughout all four steps, main always has at least one required check.1Open the renamepull request andlet CI run once2Swap the requiredchecks and requireup-to-date branches3Merge the renamepull request4Update the otheropen pull requestsRequiredchecksRenamepull requestOther openpull requestscheckapp-checkinfra-check+ up-to-date ruleapp-checkinfra-check+ up-to-date ruleapp-checkinfra-check+ up-to-date rulewaits for checknew checks ✓merged—check ✓Expected — WaitingExpected — Waitingbehind mainupdated, CI ✓✓ main always has a required check
The rename pull request reports the new names before they are required, so the swap never leaves main without a required check.

Leaving path-filtered workflows out

A workflow with a paths: filter cannot be a required check. GitHub’s troubleshooting guide says that when a workflow is skipped by path filtering, its checks “stay in a ‘Pending’ state and block merging”. A pull request that only edits documentation would then never be mergeable. Leave path-filtered workflows such as image builds out of the required list.

Merge queue, and when it is available

A merge queue automates the update-and-retest loop. Instead of each author pressing Update branch and waiting, the queue builds a temporary branch with the latest main plus the queued pull requests, runs CI on it, and merges only if it passes. GitHub describes it as providing the same guarantee as requiring branches to be up to date.

Two conditions apply. Merge queues are available in public repositories owned by an organization, and in private repositories only on GitHub Enterprise Cloud, so a private repository on the Team plan does not get one. And every workflow that a required check comes from has to trigger on the merge_group event, or the queue waits for checks that never start:

.github/workflows/ci.yml
on:
pull_request:
merge_group:

Without a merge queue, requiring branches to be up to date gives the same protection with a manual update step.

Summary

A green check on a pull request says the pull request worked against main as main was when CI ran. Two pull requests can each be green against their own snapshot and still break main together, and Git will not warn you when they touch different files.

Require branches to be up to date before merging moves that question to merge time: a pull request behind main has to be updated and pass CI again first. It sits under the required status checks rule, and the similarly named “Always suggest updating pull request branches” only adds a button.

It works best alongside required pull requests and reviews, uniquely named required checks, and no bypass for admins.

References

Share this article