Skip to content

GitHub Pull Request Stuck on “Expected”? Debug Required Checks Without Disabling Protection

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.

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.

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, deployment or deployment_status. A workflow_dispatch or schedule run on the branch — even a passing one — does not count. → Fix: trigger on pull_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: on pull_request filters by the base branch. A PR into release/1.x does not trigger a workflow filtered to main. → 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_group event. → 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’s name:. Requiring “CI” when the job is called build waits forever. Matrix jobs report one check per leg — build (20), build (22) — so requiring build alone 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 use if: always() with needs, 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”

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: ci
on:
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.)

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/**'

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 requirement

Matrix 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.

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.

In the merge boxMeaningWhere the fix lives
✗ red, with a link to a runthe check ran and failedthe run’s logs; fix the code or the job
“Expected — waiting for status to be reported”, no linkno check with that name reported for this committhe workflow’s on:, filters, job names, or the ruleset’s expectations
✓ green but PR still blockeda different required check is missing, or the branch is out of datescroll the list; check “require branches to be up to date”
“not set by the expected GitHub App”the check exists but from another sourcethe requirement’s source setting

The order that keeps protection intact:

  1. Change the workflow, not the ruleset. Fix the trigger, filter, name or merge_group in a pull request of its own — workflow files are code and get reviewed.
  2. 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.
  3. Never require a check that depends on a secret, a schedule, or a manual dispatch.
  4. Do not use pull_request_target to “make it run for forks”. If a required check cannot run for forks without secrets, it is the wrong required check.
  5. 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”
  1. Open a throwaway pull request that exercises the previously blocked case (a docs-only change, a release-branch base, a queue entry).
  2. 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.
  3. 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.
  4. 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.
  5. Close the throwaway PR.

Each is a real configuration. Decide why the required check is missing before opening the answer.

Ruleset requires test. Workflow:

name: test
on:
pull_request:
paths:
- 'src/**'
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci && npm test

The 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.

Ruleset requires CI. Workflow:

name: CI
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
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 test

The 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.

Ruleset requires test, and the repository uses a merge queue. Workflow:

name: test
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci && npm test

The 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.

  • 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 by if: reports Success; a job skipped because its dependency failed may not block. The aggregating job needs if: always() and an explicit result check.
  • pull_request_target as 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.

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?