DEV Community

jidonglab
jidonglab

Posted on

GitHub Actions paths Filter + Required Checks: 23 PRs Stuck Pending

I saved about four CI minutes per pull request and lost two weeks of merges.

The change was one line. Our backend test workflow ran on every PR, including the ones that only touched a README, so I added a paths filter. Docs PRs stopped burning runner time. Then they stopped merging. Twenty-three pull requests piled up with a yellow dot and the message "Expected — Waiting for status to be reported", and nothing was ever going to report.

This post covers what a GitHub Actions paths filter does to required status checks, why the first "fix" most people reach for is fragile, and why the second fix can quietly mark a failing PR green. That last one bit me harder than the first.

TL;DR

  • A workflow skipped by on.pull_request.paths (or paths-ignore, or a branch filter) never starts, so its jobs never report a status. A required check with that job's name stays Pending forever and blocks the merge.
  • A job skipped by a job-level if: is different: GitHub reports it as Success, and branch protection accepts it.
  • So the fix is: always trigger the workflow, detect changed paths inside it, and skip jobs with if: instead of skipping the workflow.
  • Make one aggregate "gate" job the only required check. Give it if: always() and fail it explicitly when any dependency failed or was cancelled. Without if: always(), the gate is skipped when tests fail, and skipped counts as passing.

Why does a GitHub Actions paths filter leave required checks pending?

Because branch protection waits for a check by name, and a workflow filtered out by paths never creates that check. There is no "skipped" status to report. There is just silence, and a required check treats silence as "not yet."

Here is the setup that broke:

# .github/workflows/backend.yml
name: backend
on:
  pull_request:
    paths:
      - 'backend/**'
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make test
Enter fullscreen mode Exit fullscreen mode

Branch protection on main required the status check test. When a PR touched backend/, the workflow ran, test reported, everything was fine. When a PR touched only web/ or docs/, GitHub evaluated the filter, decided the workflow should not run, and created nothing. The PR page kept showing test as expected.

The key distinction, and the whole post really:

How the job was skipped Status reported Required check
Workflow-level paths / paths-ignore / branches filter None Pending forever, merge blocked
Job-level if: evaluated to false Success (shown as skipped) Passes

How do you reproduce the stuck required check in five minutes?

  1. Create a fresh repo with the backend.yml workflow above and an empty backend/ folder with a trivial Makefile.
  2. Open one PR that touches backend/ so the test check exists at least once. GitHub only lets you pick check names it has seen recently.
  3. In branch protection (or a ruleset) for main, require the status check test.
  4. Open a second PR that only edits README.md.

The second PR shows test as "Expected — Waiting for status to be reported" and the merge button stays disabled. It will stay that way until someone pushes a backend change to that branch or an admin bypasses protection.

In our repo, "an admin bypasses protection" became the workflow. That is worse than the original problem, because it trains people to click past the check that exists to stop them.

What doesn't work well: the duplicate no-op workflow

The fix you will find in old answers is a twin workflow: same workflow name, same job name, the opposite filter (paths-ignore: ['backend/**']), and a job that just runs echo ok. Docs PRs trigger the twin, the twin reports test: success, the PR merges.

I ran this for three days. Problems:

  • Two sources of truth for one check name. A PR touching both backend/ and docs/ triggers both workflows. Now two runs report test. Which one counts depends on details I did not want to rely on.
  • Filters drift. Someone adds shared/** to the real workflow's paths and forgets the twin's paths-ignore. Now there is a hole where neither runs, and you are back to stuck PRs, or an overlap where a fake pass sits next to a real run.
  • It scales badly. Every required job needs a twin.

It works. It is just a fragile trick that hides intent.

How do you skip CI jobs by path without blocking merges?

Trigger the workflow on every PR, compute which paths changed in a small first job, and gate the expensive jobs with if:. A job skipped by if: reports Success, so nothing hangs.

name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  changes:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            backend:
              - 'backend/**'
              - 'shared/**'
            web:
              - 'web/**'
              - 'shared/**'

  backend-tests:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make -C backend test

  web-tests:
    needs: changes
    if: needs.changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --prefix web && npm test --prefix web
Enter fullscreen mode Exit fullscreen mode

The changes job takes a few seconds. On a docs-only PR, both test jobs show as skipped, and skipped is green. On pull requests, dorny/paths-filter asks the API for the changed file list, which is why it needs pull-requests: read. It is a third-party action, so pin it to a commit SHA in anything you care about.

This is where I stopped the first time. Then I made the obvious next move, and it was wrong.

Why can a GitHub Actions gate job pass when tests fail?

Because a job whose needs dependency failed is skipped by default, and a skipped job reports Success. If that job is your only required check, a red test run produces a green merge button.

I did not want to list backend-tests and web-tests separately in branch protection, since every new job would mean another settings change. So I added one aggregate job and required only that:

  # Looks fine. Is not.
  ci-ok:
    needs: [backend-tests, web-tests]
    runs-on: ubuntu-latest
    steps:
      - run: echo "all good"
Enter fullscreen mode Exit fullscreen mode

Walk through a failing PR. backend-tests fails. ci-ok has the implicit condition if: success(), which is now false, so ci-ok is skipped. Skipped reports Success. Branch protection sees ci-ok: success and enables the merge button. The failing job is right there in the checks list, but the required one is green, and green is what people look at.

One PR with a broken migration test merged this way before anyone noticed. CI had caught the bug. The gate threw the result away.

What does a correct required gate job look like?

Force the gate to always run, then fail it on purpose if any dependency failed or was cancelled. Skipped dependencies are fine; those are the path-filtered jobs doing their job.

  ci-ok:
    if: always()
    needs: [changes, backend-tests, web-tests]
    runs-on: ubuntu-latest
    steps:
      - name: Fail if any required job failed or was cancelled
        if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
        run: |
          echo '${{ toJSON(needs) }}'
          exit 1
      - run: echo "Every job passed or was skipped on purpose"
Enter fullscreen mode Exit fullscreen mode

needs.*.result gives you success, failure, cancelled, or skipped for each dependency. The toJSON(needs) dump makes the failing log readable: you see exactly which upstream job turned the gate red.

Now branch protection requires exactly one check, ci-ok. Adding a new job means adding it to needs, in the same file, in the same PR, where a reviewer can see it.

Checklist before you add a paths filter to a required workflow

  • Is any job in this workflow listed as a required check? If yes, do not use workflow-level paths, paths-ignore, or branches filters on it.
  • Are skip decisions made with job-level if:? Those report Success.
  • Is there exactly one required gate job with if: always()?
  • Does the gate fail explicitly on failure and cancelled?
  • Does the paths filter include shared code (shared/**, lockfiles, the workflow file itself) that should trigger tests?
  • Did you test it with three PRs: docs-only, backend change, and a deliberately failing test?

The third PR is the one I skipped the first time. It takes two minutes and would have caught the bad merge.

So why are my required checks stuck on "Waiting for status to be reported"?

Your required check is a job inside a workflow that a paths, paths-ignore, or branch filter prevented from starting, so the check was never created and GitHub waits for it forever. Remove the workflow-level filter, always run the workflow, detect changed files in a first job, and skip the expensive jobs with job-level if: conditions, which GitHub reports as Success. Require a single gate job that uses if: always() and explicitly fails when any dependency's result is failure or cancelled, because without that condition a failed test causes the gate to be skipped, and a skipped required check counts as passing.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)