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.

What belongs in the required set and what reports without blocking Fast unit tests, type checks, lint and the core end-to-end journeys block the merge, while the full browser matrix, visual regression, mutation testing and the quarantine lane report their results without blocking. Required — blocks the merge unit tests type check and lint core end-to-end journeys target: the whole set clears in under ten minutes Informational — reports only full browser matrix visual regression mutation and quarantine lanes watched on a trend, not per pull request
The required set is a budget, not a wish list: every addition is paid for by every future change.

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
An aggregate gate job keeps the required check name stable Four matrix shards report their own checks, and a single gate job that always runs converts their combined result into one check whose name never changes when the shard count does. e2e (1) e2e (2) e2e (3) e2e (4) names change with the count e2e-gate always runs, checks results "end-to-end" the required check shard count becomes an implementation detail rather than a protected-branch setting
One indirection makes the protected-branch configuration survive every change to the pipeline's shape.

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)
Two ways a gate silently stops working A renamed job leaves a required check that is never reported, so pull requests wait forever, while a removed requirement leaves a failing job that no longer blocks anything. Renamed job required check never reports every pull request hangs loud — someone complains today Requirement dropped job still runs and still fails merges proceed anyway silent — found weeks later
The loud failure fixes itself; the silent one is why the name comparison belongs in a scheduled job.

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.