Build the mental model
A workflow that runs on every pull request and reports results is informative. It is not a gate. Nothing stops a reviewer from merging a red pull request: the checks are a suggestion until branch protection turns them into a condition. The gate lives in the repository's branch protection rule or ruleset for the target branch, where you select specific checks as required. Once selected, the merge button stays disabled until each of those checks reports success on the pull request's head commit.
The mechanic that surprises people is that required checks are matched by name, and a check that never reports is indistinguishable from a check still running. Rename a job, change a matrix dimension so the generated job names shift, or add a path filter that skips the workflow on documentation-only pull requests, and the required check simply never arrives: the pull request sits blocked forever with no failure to look at. The standard answer is a single small aggregating job with if: always() and needs: on everything, which always reports and whose name never changes, and to require only that job.
What deserves gating is anything deterministic that a human cannot reasonably catch in review: tests, typecheck, lint, a build that must not break. What does not is anything advisory or noisy, such as bundle-size deltas, coverage drifting by a percent, or a security scanner with known false positives. Make those comment rather than block.
The human factor decides whether any of this survives. A gate that takes twenty-five minutes or fails randomly one time in ten teaches people to re-run it, then to bypass it, then to remove it. A slow, flaky gate protects nothing.
PR OPENED, CHECKS RUN, MERGE UNLOCKED OR BLOCKED
------------------------------------------------
PR opened, or a new commit pushed to it
|
v
+---------------------------------------+
| workflow triggers on: pull_request |
| concurrency cancels the previous run |
+---------------------------------------+
|
+--> lint (required) --+
+--> typecheck (required) --+
+--> test (required) --+--> required-gate
+--> build (required) --+ if: always()
+--> bundle-size (advisory) .. needs: [...]
|
v
+-------------------------------+
| branch protection on main |
| is required-gate a success? |
+-------------------------------+
| yes | no
v v
MERGE ENABLED MERGE BLOCKED
advisory checks report but never block the mergeConnect it to a real scenario
Start by making the pipeline gate-shaped. The fast-checks job runs lint, typecheck, and test as a small matrix so they run in parallel and each reports independently, with fail-fast: false so one failing check does not hide the other two. build runs after them, because there is no point compiling a branch that does not typecheck. timeout-minutes on both stops a hung job from blocking a pull request for six hours.
concurrency with cancel-in-progress: true means a new push to a pull request cancels the previous run for that branch. This is the single biggest saving on an active repository, and it also stops a stale run from reporting a result against an old commit.
Then add the gate job. It declares needs: [fast-checks, build] and if: always(), so it runs even when an upstream job failed, and it inspects needs.<job>.result to decide its own exit code. In branch protection you then require exactly one check, required-gate. Adding a new job to the pipeline now means adding it to needs: with no change to the protection rule, and a skipped upstream job cannot silently satisfy the gate the way it would if you required each job by name.
Try the working example
name: PR quality gate
on:
pull_request:
branches: [main]
concurrency:
group: gate-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
fast-checks:
name: fast-checks
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
check: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Run ${{ matrix.check }}
run: npm run ${{ matrix.check }}
build:
name: build
needs: fast-checks
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-${{ github.sha }}
path: dist/
retention-days: 3
required-gate:
name: required-gate
if: always()
needs: [fast-checks, build]
runs-on: ubuntu-latest
steps:
- name: Fail unless every upstream job succeeded
env:
CHECKS_RESULT: ${{ needs.fast-checks.result }}
BUILD_RESULT: ${{ needs.build.result }}
run: |
echo "fast-checks: $CHECKS_RESULT"
echo "build: $BUILD_RESULT"
if [ "$CHECKS_RESULT" != "success" ] || [ "$BUILD_RESULT" != "success" ]; then
exit 1
fi
On every pull request targeting main, three fast-checks jobs run in parallel, one each for lint, typecheck, and test, and because fail-fast is false all three report even if one fails. build runs only after all three succeed; if any of them fails, build is skipped and its result is skipped rather than success. The required-gate job runs in every case because of if: always(), reads the results of both upstream jobs from the needs context, and exits non-zero unless both are exactly success, which is what makes a skipped build block the merge instead of quietly passing it. Pushing a new commit to the pull request cancels the in-flight run for that branch through the concurrency group, so only the newest commit's result stands. Branch protection is configured outside the workflow file: with required-gate listed as a required status check, GitHub keeps the merge button disabled until that job reports success on the pull request's head commit.5-minute try-it
Add a single aggregating gate job to one of your pipelines and make it the only required status check in branch protection. Then rename one of the upstream jobs and confirm the pull request is still gated correctly; under per-job required checks that rename would have left the pull request blocked on a check that never reports. Separately, measure your pipeline's median pull-request wall-clock time and its failure rate on unchanged code over the last thirty runs. If it is slow or flaky, fix that before adding any more required checks.
One important caution
Requiring each job by name in branch protection, then renaming a job or changing a matrix value: the old check name never reports again and every pull request sits blocked with no failure to investigate.
Forgetting if: always() on the aggregating gate job: when an upstream job fails the gate is skipped and reports nothing, so the pull request shows as pending forever rather than as a clear failure you can read.
GitHub Docs - About protected branches — CI/CD with GitHub Actions