The pull request is green everywhere you look, and still cannot be merged. In the merge box, one line reads Expected — Waiting for status to be reported, next to the name of a check. Nothing is failing. Nothing is running. It is simply waiting, and it will wait forever, because the thing it is waiting for is never going to happen.
The quick fix everyone reaches for — remove the check from the required list, or let an admin bypass — is the wrong one, because the protection was doing its job: something that should report did not. This article is a decision tree for finding out why it did not, and each branch ends in a configuration change that leaves the requirement in place.
Platform behaviour below is taken from GitHub’s own troubleshooting page, read on the verification date; where it makes a specific claim, that claim is quoted. GitHub changes; if your merge box says something different, the reference page is the authority.
The mental model
Section titled “The mental model”A required status check is a name in branch protection or a ruleset. When a pull request is evaluated, GitHub looks for a check (from a workflow job or a GitHub App) or a commit status with that exact name on the pull request’s latest commit — the test merge commit if one exists, the head commit otherwise. If it finds one that succeeded, the requirement is met. If it finds one that failed, the PR is blocked with a red ✗. If it finds nothing with that name, the PR shows “Expected” and is blocked.
“Expected” therefore always means one thing: no check with that name reported for this commit. Every cause in the tree is a reason that did not happen.
The decision tree
Section titled “The decision tree”Start at 1. Each step is a question you can answer from the pull request page or the workflow file.
1. Which required check is missing, exactly? The merge box names it. Copy the name character for character. Then open Settings → Rules (or Branches) and find the requirement — note whether it is expected from “any source” or from a specific GitHub App. → Go to 2.
2. Is it a missing check or a failed check? A failed check shows ✗ with a link to the run; the fix is in the run’s logs, not in configuration — that is the failing-workflow lab. “Expected” with no link is missing. → Go to 3.
3. Did a workflow that produces that name run for this commit at all? Actions tab → filter by branch. No run for the head commit → the workflow never triggered. → Go to 4. A run exists → go to 6.
4. Why did it not trigger?
Read the workflow’s on: block against the pull request:
- Event. GitHub evaluates workflow-job checks for a pull request only when the run was
triggered by
push,pull_request,pull_request_review,pull_request_target,deploymentordeployment_status. Aworkflow_dispatchorschedulerun on the branch — even a passing one — does not count. → Fix: trigger onpull_request. - Path filter.
paths:/paths-ignore:that this PR’s files do not match. GitHub’s documentation: a workflow skipped by path filtering, branch filtering or a commit message leaves its checks “in a Pending state and block[s] merging”. → Fix in section “Path filters” below. - Branch filter.
branches:onpull_requestfilters by the base branch. A PR intorelease/1.xdoes not trigger a workflow filtered tomain. → Fix: list every protected base branch, or drop the filter. - Commit message.
[skip ci]/[skip actions]in the head commit. Same Pending result. → Fix: push a commit without it; do not require workflows people skip by habit. - Merge queue. If the PR is in a merge queue, the run needed is for the
merge_groupevent. → Go to 7.
5. (Fork pull requests.) Workflows on pull_request from forks run with a read-only token and
no secrets; if the workflow needs a secret to start (for example a required input from
secrets.), it fails or never reports. Do not “fix” this with pull_request_target and a checkout
of the PR head — that executes untrusted code with your secrets. → Fix: make the required check one
that needs no secrets (build, test, lint), and keep secret-needing jobs off the required list.
6. The workflow ran. Why is there no check with that name?
- Job name versus workflow name. Checks are identified by the job’s display name (the
name:under the job, or its key), not the workflow’sname:. Requiring “CI” when the job is calledbuildwaits forever. Matrix jobs report one check per leg —build (20),build (22)— so requiringbuildalone matches nothing. → Fix in “Check names” below. - Job skipped by
if:. A job skipped by a conditional reports Success — it does not block. So a skipped job is not your cause; but a job that depends on a failed job is skipped too and “may not block merging”. → For a required aggregating job useif: always()withneeds, and fail it explicitly when a dependency failed. - Wrong commit. The run is for an earlier commit; the PR was updated since. “Required checks must pass on the latest commit SHA. Checks from earlier commits don’t satisfy the requirement.” → Fix: wait for or re-run the workflow on the new head; if protection requires the branch to be up to date, merge or rebase the base branch first.
- Unexpected source. The message “Required status check … was not set by the expected GitHub App” means the requirement is pinned to a specific app and a different app (or a workflow) reported it. → Fix: change the requirement’s source to “any source” or to the correct app.
7. Merge queues. With a merge queue, GitHub creates a temporary branch and triggers workflows
with the merge_group event. A workflow that listens only to pull_request never runs there, and
the queue entry waits. → Fix: add merge_group: to the workflow’s on:.
8. Verify the fix, then stop. Section “Validating” below.
Path filters, branch filters and check naming
Section titled “Path filters, branch filters and check naming”Path filters
Section titled “Path filters”The tempting configuration — “only run tests when code changes” — is exactly the one GitHub warns against requiring. Two safe patterns:
Drop the path filter from the required workflow and make the no-op case fast. A test job that finishes in a minute on a docs-only change costs less than a blocked PR.
Keep the filter, but require a job that always reports. Split the work: a cheap “gate” job with no path filter is the required check; it decides in-workflow whether the expensive job needs to run, and succeeds either way. The decision moves from workflow-level (never triggers → Pending) to job-level (skipped → Success):
name: cion: pull_request: branches: [main]
permissions: contents: read
jobs: changes: runs-on: ubuntu-latest outputs: code: ${{ steps.filter.outputs.code }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Did code change? id: filter env: BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} run: | git fetch --no-tags --depth=1 origin "$BASE" if git diff --name-only "$BASE" "$HEAD" | grep -qvE '^docs/'; then echo "code=true" >> "$GITHUB_OUTPUT" else echo "code=false" >> "$GITHUB_OUTPUT" fi
test: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: npm ci && npm test
# The required check. Runs whether or not `test` ran; fails only if `test` failed. ci-passed: needs: [changes, test] if: always() runs-on: ubuntu-latest steps: - env: RESULT: ${{ needs.test.result }} run: | echo "test result: $RESULT" [ "$RESULT" = "success" ] || [ "$RESULT" = "skipped" ]Require ci-passed. On a docs-only PR test is skipped, ci-passed sees skipped and
succeeds. On a code PR, test runs and ci-passed mirrors it. (Note if: always() on the
aggregating job: without it, a failed test would cause ci-passed to be skipped — and per the
documentation a job skipped because a dependency failed “may not block merging”. always() makes
it run and fail explicitly.)
Branch filters
Section titled “Branch filters”on: pull_request: branches: matches the base branch. If you protect main and release/*,
the workflow must trigger for both or the release PRs sit on Expected:
on: pull_request: branches: - main - 'release/**'Check names
Section titled “Check names”The name GitHub shows — and the name you must require — is the job’s name: if set, otherwise its
key. Set it explicitly and keep it stable:
jobs: build: name: build # this string is the check name; changing it orphans the requirementMatrix legs get the matrix values appended: build (20), build (22). Either require each leg by
its full name, or — better — add an aggregating job like ci-passed above and require that one,
so adding a matrix entry never means editing the ruleset.
Workflow name: is not a check name. Renaming a workflow changes nothing; renaming a job breaks
the requirement silently.
Merge queues and merge_group
Section titled “Merge queues and merge_group”A merge queue tests the PR as it would land, on a temporary branch, and triggers workflows with
the merge_group event. The documentation is direct: update workflows to include that trigger,
“otherwise, status checks will not be triggered when you add a pull request to a merge queue.”
on: pull_request: branches: [main] merge_group:Nothing else changes; the same jobs run and report under the same names. Without the trigger the PR passes its checks, enters the queue, and waits there on Expected. See Merge queues for the queue itself.
Missing versus failed
Section titled “Missing versus failed”| In the merge box | Meaning | Where the fix lives |
|---|---|---|
| ✗ red, with a link to a run | the check ran and failed | the run’s logs; fix the code or the job |
| “Expected — waiting for status to be reported”, no link | no check with that name reported for this commit | the workflow’s on:, filters, job names, or the ruleset’s expectations |
| ✓ green but PR still blocked | a different required check is missing, or the branch is out of date | scroll the list; check “require branches to be up to date” |
| “not set by the expected GitHub App” | the check exists but from another source | the requirement’s source setting |
Safely correcting configuration
Section titled “Safely correcting configuration”The order that keeps protection intact:
- Change the workflow, not the ruleset. Fix the trigger, filter, name or
merge_groupin a pull request of its own — workflow files are code and get reviewed. - If a check name must change, add the new requirement before removing the old one, and remove the old only after a PR has merged with the new one reporting.
- Never require a check that depends on a secret, a schedule, or a manual dispatch.
- Do not use
pull_request_targetto “make it run for forks”. If a required check cannot run for forks without secrets, it is the wrong required check. - Admin bypass is for incidents, with a written reason, and it leaves the requirement in place for the next PR. It is not a fix.
All examples above start with permissions: contents: read and pin actions by commit SHA; a
required check is a trust boundary, and its workflow should be the most conservative one in the
repository.
Validating that the check appears and protection still holds
Section titled “Validating that the check appears and protection still holds”- Open a throwaway pull request that exercises the previously blocked case (a docs-only change, a release-branch base, a queue entry).
- In the merge box, the required check name should show a run — running or complete — within a minute. If it still says Expected, the name or trigger is still wrong; go back to step 6 of the tree.
- Confirm the negative: push a commit that breaks the test and watch the same check go red and block. A required check that cannot fail is not protecting anything.
- Confirm the ruleset still lists the check as required, from the intended source, and that “require branches to be up to date” is set the way you intend.
- Close the throwaway PR.
Three scenarios to diagnose
Section titled “Three scenarios to diagnose”Each is a real configuration. Decide why the required check is missing before opening the answer.
Scenario 1
Section titled “Scenario 1”Ruleset requires test. Workflow:
name: teston: pull_request: paths: - 'src/**'permissions: contents: readjobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: npm ci && npm testThe PR changes package.json and README.md. The merge box says test — Expected.
Diagnosis
Tree step 4, path filter. Neither changed file matches src/**, so the workflow never triggered;
its check stays Pending. The name is fine (job key test). Fix: remove the paths: filter
from the required workflow, or move the decision to a job-level if: behind an always-reporting
gate job as in the “Path filters” section. Do not remove test from the ruleset.
Scenario 2
Section titled “Scenario 2”Ruleset requires CI. Workflow:
name: CIon: pull_request: branches: [main]permissions: contents: readjobs: build: strategy: matrix: node: [20, 22] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} - run: npm ci && npm testThe workflow ran on the PR’s head commit; both legs are green. The merge box says CI — Expected.
Diagnosis
Tree step 6, check names. CI is the workflow name; the checks are build (20) and
build (22). Nothing named CI was ever reported. Fix: add an aggregating job (ci-passed
with needs: build and if: always()), require that name, then remove the CI requirement
once a PR has merged with ci-passed reporting. Requiring build (20) and build (22) by name
also works, at the cost of editing the ruleset every time the matrix changes.
Scenario 3
Section titled “Scenario 3”Ruleset requires test, and the repository uses a merge queue. Workflow:
name: teston: pull_request:permissions: contents: readjobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: npm ci && npm testThe PR shows test ✓. You click “Merge when ready”. The queue entry shows test — Expected — and
never merges.
Diagnosis
Tree step 7, merge queue. The queue triggers workflows with merge_group, which this workflow does
not listen to, so no check reports on the queue’s temporary branch. Fix: add merge_group:
under on:. The job and its name stay as they are.
Common mistakes
Section titled “Common mistakes”- Removing the requirement to unblock one PR. It will be forgotten, and the next regression merges. Fix the workflow.
- Requiring the workflow name. Only job names (plus matrix suffixes) are check names.
- Path filters on required workflows. Pending forever on the files they ignore.
- “Skipped is fine” without
always(). A job skipped byif:reports Success; a job skipped because its dependency failed may not block. The aggregating job needsif: always()and an explicit result check. pull_request_targetas a fork fix. It runs with your secrets; with a checkout of the PR head it runs the fork’s code with your secrets. Never as a required check.- Forgetting the queue.
merge_group:is one line and the whole difference.
Try it
Section titled “Try it”How this article was verified
Every command was run on 17 September 2026 in a freshly created temporary repository with an isolatedHOME, a throwaway identity and hooks disabled — never against a real repository. Output blocks labelled captured are pasted from that run; blocks labelled illustrativeare described rather than pasted. Versions: GitHub documentation as read on 17 September 2026; workflow YAML validated with actionlint 1.7.12. Primary reference: docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks.
How did this go?
Unexpected output. Compare your output with the expected block line by line; the first differing line is usually the cause. Then check the version-sensitive notes.
Missing prerequisite. Most "stuck" moments here come from one earlier concept. The prerequisites list names it; the learning path puts it in order.
Unclear explanation. Read the explanation section, then the mental model, then retry the step — the command usually makes sense once the model is in place.
Instructions did not match my environment. Check `git --version` and your shell (Git Bash vs PowerShell vs zsh). Platform notes on the page name the differences that matter; the verification step confirms you are back to a known state.
Good. The next step is to do it without the page open — or move to the recommended next activity below.
All help on this page: Git troubleshooting decision tree · Find the lesson that comes before this one · The explanation section · Recover a lost commit (lab)