Branch protection on GitHub lets you say “a pull request can’t merge into main until these checks pass.” It’s one of the most effective controls a team has, and also one of the easiest to make meaningless without noticing. The rule is enforced exactly as written: GitHub waits for a status with a particular name and checks that it reported success. It doesn’t know whether that job ran your tests, ran a subset, or swallowed a failure.
Interviewers ask about required checks to see if you think about what a green tick actually proves. The good answers talk about exit codes, skipped jobs and naming, not just “we run the tests on every PR.”
What GitHub is really checking
When you add a required status check in branch protection or a ruleset, you pick a name such as test or build (ubuntu-latest, 22). For GitHub Actions, that name is the job’s name (with matrix values appended). The merge button unlocks when a check with that name, on the pull request’s latest commit, has a successful conclusion.
Two consequences follow straight from that. If you rename the job, the old required name never reports again and every PR shows it as “Expected, waiting for status to be reported,” which blocks merges until someone updates the rule. And if two different workflows both have a job called test, either one passing can satisfy the rule, which is rarely what you meant. Ruleset settings let you pin a required check to a specific GitHub App source, which closes the second gap.
Green job, failing tests: the pipe problem
A very common CI step saves test output as an artifact:
- name: Run tests
run: npm test | tee test-output.logOn a GitHub-hosted Linux runner, a run step without a shell: key executes as bash -e {0}. The -e flag stops the script when a command fails, but a pipeline’s exit status is the status of its last command, and tee succeeded. Here’s the same thing reproduced locally with a fake test runner that prints a failure and exits 1:
$ bash -e step-default.sh
FAIL src/cart.test.js > total rounds to cents
Tests: 1 failed, 41 passed, 42 total
bash -e exit code: 0
$ bash -eo pipefail step-default.sh
FAIL src/cart.test.js > total rounds to cents
Tests: 1 failed, 41 passed, 42 total
bash -eo pipefail exit code: 1The first run is what your PR sees: a red line in the log and a green check. The fix is to write shell: bash on the step (or under defaults.run.shell for the whole workflow), which GitHub runs as bash --noprofile --norc -eo pipefail {0}. With pipefail, the pipeline fails if any command in it fails.
The same masking happens more obviously with npm test || true, added once to “get CI unblocked” and never removed, and with continue-on-error: true on a step. Search your workflows for all three. An interviewer who asks “how do you know your required check means something?” is often fishing for exactly this: you prove a check can fail by making it fail. Push a commit with a deliberately broken test to a throwaway branch and confirm the check goes red.
Skipped is not the same as missing
GitHub treats two kinds of “didn’t run” very differently, and both trip teams up.
A job skipped by an if: condition reports success. Suppose the test job only runs when a label is present:
test:
if: contains(github.event.pull_request.labels.*.name, 'run-tests')A PR without the label shows the test check as skipped, and a skipped job satisfies a required check. Your protection rule has been reduced to “tests pass if someone chose to run them.”
A workflow skipped by a path filter never reports at all. Teams add on: pull_request: paths: ['src/**'] so documentation PRs don’t run the full suite. Then a README-only PR sits forever with the required test check “Expected”, because the workflow never started, so no check with that name was ever created. People respond by making the check not required, or by adding a dummy workflow with the same job name for the opposite paths, which works but is fragile.
One gate job instead of a list
Both problems, plus the job-renaming one, are easier to handle with a single aggregate job that’s the only thing branch protection requires:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
src: ${{ steps.filter.outputs.src }}
steps:
- uses: actions/checkout@v4
- id: filter
uses: dorny/paths-filter@v3
with:
filters: |
src:
- 'src/**'
- 'package-lock.json'
test:
needs: changes
if: needs.changes.outputs.src == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test | tee test-output.log
shell: bash
ci-ok:
needs: [changes, test]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
echo "changes=${{ needs.changes.result }} test=${{ needs.test.result }}"
[ "${{ needs.changes.result }}" = "success" ] || exit 1
[ "${{ needs.test.result }}" = "success" ] || [ "${{ needs.test.result }}" = "skipped" ] || exit 1The workflow always runs, so the gate always reports. The path decision moves inside the workflow, where it’s explicit. if: always() makes ci-ok run even when test fails or is cancelled, so it can turn that into a failure instead of being skipped itself (a skipped ci-ok would count as success, which is the trap from the previous section). Skipping test is allowed here only because the changes job decided, on purpose, that no source files changed. Matrix jobs can be added or renamed without touching branch protection, since only ci-ok is required. The YAML above is an illustrative pattern rather than something run for this guide; the third-party dorny/paths-filter action is one common choice, and a short git diff --name-only script works too.
Checks that are worth requiring
A required check should be fast, deterministic and tied to a decision. Good candidates:
- Unit and integration tests, type checking, linting that catches real bugs.
- A build of the deployable artifact, so you never merge something that doesn’t build.
- Security scans for secrets in the diff.
Be careful with checks that fail for reasons unrelated to the change. A flaky end-to-end test that fails one run in twenty trains people to click “re-run” without reading the failure, and eventually someone asks an admin to bypass protection. Quarantine flaky tests into a non-required job, track them, and fix or delete them. A required check that people routinely ignore is worse than none, because it gives false confidence.
Branch protection also has related settings worth mentioning: “Require branches to be up to date before merging” makes the check run against the PR combined with the current main, which catches semantic conflicts at the cost of re-running CI after every merge. A merge queue solves the same problem more efficiently on busy repositories. And “Do not allow bypassing the above settings” stops administrators from merging red PRs by habit.
Interview practice
A team’s main branch was broken by a PR that had all required checks green. The workflow for that PR has a test job that runs npm run test:ci | tee results.txt, and a lint job with if: github.event.pull_request.draft == false. Branch protection requires test and lint. The PR was opened as a draft, then marked ready and merged quickly. Explain how a broken change got through, and fix it.
Answer
Two gaps combined. The test step runs under the default bash -e without pipefail, so the pipeline’s exit status is tee’s, which is 0, and a failing test suite still produced a green test check. And while the PR was a draft, lint was skipped by its if: condition, and a skipped job reports success. If the “ready for review” event didn’t trigger the workflow (the default pull_request types are opened, synchronize and reopened, not ready_for_review), no new run happened and the skipped result was what branch protection saw on that commit.
The fixes: set defaults.run.shell: bash so every step gets -o pipefail, and check for || true and continue-on-error. Remove the draft condition from lint, or add ready_for_review to the workflow’s types so it re-runs. Add a ci-ok gate with needs: [test, lint] and if: always() that fails unless each needed job’s result is success, and make that the only required check. Then prove it: push a commit with a failing test and a lint error to a branch and confirm the gate goes red.