Gating Merges With Required Status Checks
A merge gate is a policy statement in the shape of a configuration file: it says which failures are serious enough to stop work. Get it too loose and broken code reaches the default branch; get it too strict and the queue stalls behind a nightly browser matrix that nobody can influence. The mechanics are not hard, but two details cause most of the pain in practice — check names are the contract with branch protection and quietly break when a job is renamed, and a required check that is skipped by a path filter never reports at all, which blocks the merge forever. This guide covers choosing what to require, naming checks so they stay stable, handling conditional jobs, and keeping the gate honest. It sits under continuous integration test orchestration.
Root Cause Analysis
The first question is which failures deserve to block. The instinct to require everything is understandable and wrong: a gate is only as useful as the speed at which it clears, and adding a twenty-minute browser matrix to the required set means every one-line change waits twenty minutes. The right set is the smallest one that catches the failures you cannot tolerate on the default branch, with everything else reporting without blocking — the same reasoning that underlies a quarantine lane for flaky tests.
The second is that branch protection matches checks by name, as a string. Rename a job from test to unit-tests and the required check named test will never report again — so every pull request waits for a check that no longer exists, and the fix requires an administrator. Matrix jobs make this worse, because their check names include the matrix values, so changing a shard count from four to three silently orphans a required check.
The third is conditional execution. A job skipped by a path filter reports no status at all on most platforms, which branch protection treats as pending rather than as passed. The result is the confusing situation where a documentation-only change can never merge, because a test job that correctly decided not to run is also required to report.
Reproducible Setup
Give every job an explicit, stable name, and keep the required ones in a single workflow so the contract is easy to read.
# .github/workflows/pr.yml
name: pr
on: pull_request
jobs:
unit:
name: unit tests # this string is the contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx vitest run
types:
name: type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx tsc --noEmit
gh api repos/:owner/:repo/branches/main/protection/required_status_checks --jq '.contexts'
# ["unit tests", "type check"]
Implementation
Step 1 — Aggregate matrix jobs behind one stable check. Requiring e2e (1), e2e (2), e2e (3) breaks the moment the shard count changes. Require a single summary job that depends on the matrix instead.
e2e:
strategy:
fail-fast: false
matrix: { shard: [1, 2, 3, 4] }
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx playwright test --shard=${{ matrix.shard }}/4
e2e-gate:
name: end-to-end # the only e2e name branch protection knows
needs: [e2e]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
if [ "${{ needs.e2e.result }}" != "success" ]; then
echo "end-to-end shards failed"; exit 1
fi
Step 2 — Make skipped jobs report success rather than nothing. A path-filtered job that does not run must still produce its check, or the merge waits forever. The aggregate pattern from Step 1 handles this too, because the gate job itself always runs.
unit:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'docs-only') }}
# …
unit-gate:
name: unit tests
needs: [unit]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
case "${{ needs.unit.result }}" in
success|skipped) echo "ok" ;;
*) echo "unit tests failed"; exit 1 ;;
esac
Step 3 — Require the checks, and require the branch to be current. Blocking on a stale branch is what prevents the classic semantic conflict: two changes that each pass alone and break together.
gh api -X PUT repos/:owner/:repo/branches/main/protection/required_status_checks \
-f strict=true \
-f 'contexts[]=unit tests' \
-f 'contexts[]=type check' \
-f 'contexts[]=end-to-end'
Step 4 — Use a merge queue rather than forcing everyone to rebase. With strict=true, every merge invalidates every other open branch, and on a busy repository that becomes a rebase treadmill. A merge queue tests the combination once, in order, without human involvement.
# .github/workflows/merge-queue.yml
on:
merge_group:
jobs:
unit:
name: unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npx vitest run
Step 5 — Review the required set on a schedule. Gates accumulate. Once a quarter, list what is required and ask of each entry whether a failure there should genuinely stop a release.
Verification
Verify the gate blocks by breaking it deliberately on a throwaway branch, which is the only way to know the wiring is right.
git checkout -b verify-gate
sed -i 's/toBe(42)/toBe(43)/' src/domain/pricing.test.ts
git commit -am "verify: gate blocks on a failing unit test" && git push -u origin HEAD
gh pr create --fill && gh pr checks --watch
# unit tests fail 45s
# merge is blocked
Then verify the skip path, which is the failure that produces permanently-pending pull requests. Push a documentation-only change and confirm the required checks report success rather than hanging.
gh pr checks 1234
# unit tests pass 6s ← the gate job ran even though the test job was skipped
# type check pass 5s
# end-to-end pass 4s
Finally, verify that the names in branch protection still match the names the workflow emits. A scheduled comparison catches a rename before it silently disables a gate for weeks.
required=$(gh api repos/:owner/:repo/branches/main/protection/required_status_checks --jq '.contexts[]' | sort)
emitted=$(gh run view --json jobs --jq '.jobs[].name' | sort)
comm -23 <(echo "$required") <(echo "$emitted")
# (empty — every required check is still produced)
Troubleshooting
Symptom: a pull request waits forever on a check that never appears. Diagnosis: the required name does not match any emitted job, usually after a rename or a matrix change. Fix: compare the two lists as in the verification step, and adopt the aggregate-job pattern so the required name is decoupled from the workflow’s structure.
Symptom: documentation changes cannot merge. Diagnosis: a path filter skipped the test job, and a skipped job reports no status. Fix: keep the filter on the work job and let an always-running gate job report on its behalf, treating skipped as acceptable.
Symptom: merges succeed but the default branch keeps breaking. Diagnosis: branches are being merged against a stale base, so two independently-passing changes conflict semantically. Fix: enable the strict up-to-date requirement, and use a merge queue so that requirement does not turn into constant manual rebasing.
Symptom: the required set keeps growing and the queue is slow. Diagnosis: every new check was added as required because that felt safer. Fix: hold a quarterly review, and apply the test: would we roll back a release for this failure? If not, it reports rather than blocks.
FAQ
Should end-to-end tests be a required check?
A small, fast set of core journeys should be — they catch integration failures no other tier sees. The full matrix should not, because its length is unbounded relative to the value it adds per pull request. Splitting the suite into a blocking core and an informational remainder is the practical compromise, and it follows the layering in end-to-end test architecture.
What about coverage thresholds?
Coverage makes a reasonable required check when it is enforced per package and compares against a baseline rather than an absolute number. A repository-wide absolute threshold as a merge gate produces arguments about unrelated code and gets bypassed. See enforcing coverage thresholds in a monorepo for the shape that works.
Who should be able to bypass the gate?
As few people as possible, and every bypass should be visible. An administrator override is a legitimate emergency tool, but if it is used more than a couple of times a year the gate is wrong rather than the situations. Log bypasses and review them at the same cadence as the required set.
Does a merge queue remove the need for strict mode?
It replaces it. The queue tests each candidate against the current default branch before merging, which is exactly what strict mode approximates by forcing rebases — and it does so without human effort. If you have a queue, the strict flag adds friction without adding safety.
Related
- Back to Continuous Integration Test Orchestration
- Failing fast with bail in large test suites — making the blocking set clear faster.
- Quarantining flaky tests in CI — keeping unstable tests out of the required set.
- Running only tests affected by a change — shrinking what the gate has to run.