Skip to content
Julien Danjou Julien Danjou
June 26, 2026 · 8 min read

Path Filters Are a Convenience, Not a CI Gate

Skipping CI with paths: is a fine optimization until you make the check required. Then it breaks, and not because the glob is imprecise. A path-filtered workflow that doesn't run reports no status at all, so the required check sits pending and blocks the merge, or you fake it green and gate on nothing. Here's why a path filter can't be a merge gate, and what to use instead.

Skipping CI on changes that don’t need it feels like the responsible thing to do. You have an expensive suite, a PR that only touches documentation, and no reason to burn an hour of runner time on it. So you reach for path filters:

on:
  pull_request:
    paths:
      - "src/**"
      - "package.json"

Now a docs-only PR doesn’t trigger the workflow at all. No wasted minutes, no human deciding anything, the decision comes straight from the diff. It looks like the clean version of gating CI on a label. It isn’t. The moment that check becomes required it breaks, and not because the glob is too coarse, though we’ll get to that. It breaks because of something more basic: a required check can report that it passed or that it failed, but it has no way to report that it deliberately didn’t need to run.

It deadlocks the merge

Make the path-filtered job a required status check and open a PR that only edits Markdown. The workflow doesn’t run, because nothing matched src/**. A required check that doesn’t run never reports a status. To branch protection, a required check with no status is a check that’s still pending. The PR can’t merge.

flowchart TD
    A[PR edits only docs] --> B{paths match src?}
    B -->|no| C[Workflow never starts]
    C --> D[Required check never reports]
    D --> E[Status stays pending]
    E --> F[Merge blocked]

You hit this on the first docs PR after turning the filter on. It’s not a rare edge case, it’s the default behavior, and it’s the reason most people who try path filters as a gate go looking for a workaround within a day.

The official workaround wires a green light to nothing

GitHub’s documented answer is to add a second workflow with the same job name, triggered on the inverse paths, that does nothing but report success. The real tests run when src/** changes. The stub runs when it doesn’t. Both publish a check called test, so branch protection always sees a test result:

# ci.yml — the real suite
on:
  pull_request:
    paths: ["src/**"]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: ./run-tests.sh
# ci-skip.yml — same job name, fires when src/** did NOT change
on:
  pull_request:
    paths-ignore: ["src/**"]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "no src changes, nothing to test"
flowchart LR
    PR[Pull request] --> R{src changed?}
    R -->|yes| Real[ci.yml: real tests]
    R -->|no| Stub[ci-skip.yml: echo success]
    Real --> Chk[Check 'test']
    Stub --> Chk
    Chk --> BP[Branch protection: green]

Branch protection now has a green test next to every PR, and on some of them that green came from a job whose entire body is an echo. The required check is satisfied by design, whether or not anything was actually checked. It’s the label gate problem turned inside out: there, a skipped job reports itself as a pass and the bypass is automatic; here, the workflow leaves no status at all, so you have to manufacture the green by hand. Either way the checkmark stops meaning the test ran.

The real problem: there’s no status for “didn’t need to run”

Step back from the workaround and look at what GitHub actually gives you. A check can report success or failure. What it has no way to report is “deliberately not applicable to this PR.” Be precise here, because the precision is the trap. A job skipped by an if: inside a workflow that did run reports a skipped status, and branch protection counts that as a pass. A path filter doesn’t skip a job, it skips the whole workflow, so no job is ever created and no status, skipped or otherwise, is ever reported. A required check with no status sits pending.

flowchart TD
    P[Job ran and passed] --> PS[status: success]
    F[Job ran and failed] --> FS[status: failure]
    SK["Job skipped by an if: workflow ran"] --> SKS[status: skipped, counts as a pass]
    NA[Workflow skipped by path filter] --> NS[No status at all]
    NS --> Q{Required check}
    Q -->|left as-is| D[Pending forever: deadlock]
    Q -->|forced green by a stub| L[Reads as passed: a lie]

So you get two options and both are bad. Leave the job required and it deadlocks every PR the filter skips. Force a status with a stub and every skipped PR reports a pass it never earned. There’s no honest third choice, because the thing you actually want to express, “this didn’t need to run, and that’s fine,” is not something a status check can say.

And “didn’t run” isn’t only produced by your filter. A dropped webhook, an Actions incident, a misrouted event, a typo in the glob that matches nothing: each lands in the same place, no run and no status, indistinguishable from a clean skip. A gate that reads the absence of a check as permission to merge will wave a PR through in the middle of an outage, because an outage produces exactly the silence the gate is treating as fine.

You’ll also miss things, and that part is usually fine

There’s a second objection to path filters, the one most people reach for first: the files in the diff aren’t always the code the change affects. A lockfile bump touches pnpm-lock.yaml and nothing under src/, yet changes the versions running beneath all of it. A tsconfig.json edit or a base image change shifts how everything builds. Change a .proto that CI regenerates into src/ and the watched output never moves.

This is real, and it’s a trade-off rather than a disqualifier. Teams that scope CI by path know the filter is approximate and tune it: watch the lockfile, watch the shared config, widen a glob when something slips through. You treat the filter as a heuristic for what’s affected and maintain it, and plenty of repos run that way without trouble.

It doesn’t rescue path filters as a gate, though, because accuracy was never the problem. Make the glob perfect, match every file that could possibly matter, and you still have a required check that reports nothing whenever it decides not to run, and you still have to choose between deadlocking and lying. The blast-radius gap is a tuning question you can keep closing. The status gap is structural, and no tuning touches it.

What to do instead

The fix is the same principle that undoes the label gate. A merge gate has to be a positive signal: the work that needed to run, ran, and passed. Stop deriving “safe to merge” from whether a trigger happened to fire.

Path filters are genuinely useful, just not as a gate. Use them on workflows that don’t gate a merge, where skipping only saves minutes and a missed run costs you nothing: a preview deploy, an advisory lint, a nightly job. Keep them off anything branch protection requires.

For the case people actually want, run only the tests for the parts of the repo a PR affects and let that decide the merge, the missing piece is a way to say “this part wasn’t affected” as an explicit, recorded decision instead of an absent check. That belongs to whatever owns the merge. Mergify’s monorepo CI does it with scopes: named slices of the repo mapped to file patterns in .mergify.yml.

# .mergify.yml
scopes:
  source:
    files:
      frontend:
        include:
          - apps/web/**
      api:
        include:
          - services/api/**/*.py
          - services/api/poetry.lock   # a dependency bump counts as an api change

On the GitHub Actions side, one job detects the touched scopes with the gha-mergify-ci action and the suites gate on its output:

# .github/workflows/ci.yaml
jobs:
  detect-scopes:
    runs-on: ubuntu-24.04
    outputs:
      frontend: ${{ fromJSON(steps.scopes.outputs.scopes).frontend }}
      api: ${{ fromJSON(steps.scopes.outputs.scopes).api }}
    steps:
      - uses: actions/checkout@v5
      - id: scopes
        uses: Mergifyio/gha-mergify-ci@v22
        with:
          action: scopes

  frontend-tests:
    needs: detect-scopes
    if: ${{ needs.detect-scopes.outputs.frontend == 'true' }}
    uses: ./.github/workflows/frontend-tests.yaml
    secrets: inherit

  api-tests:
    needs: detect-scopes
    if: ${{ needs.detect-scopes.outputs.api == 'true' }}
    uses: ./.github/workflows/api-tests.yaml
    secrets: inherit

That if: skip might make you flinch after everything above, but these jobs aren’t your merge gate. The merge queue is, and it owns the decision while reusing the same scopes, so a skipped suite is a scope Mergify recorded as untouched and accounted for, rather than a status someone has to interpret. A PR that only edits apps/web runs frontend-tests and skips api-tests, on purpose and on the record. Because the lockfile is part of the api scope, a dependency bump flips api back to true and pulls the api suite in, the blast-radius case from earlier, closed by widening the scope instead of hoping a glob noticed. And when several PRs are ready, the queue can batch them and prove each batch with one run instead of one per PR.

The whole difference shows up in what branch protection sees on the awkward PR, the one that doesn’t touch the gated area at all:

SetupRequired checkReported on an unaffected PRResult
Path filterthe suite itself (ci / test)no status, the workflow never ranpending, merge blocked
Path filter + stubthe suite itself (ci / test)green, produced by an echomerges, nothing tested
Mergify scope + queuethe merge queue’s own checkgreen, no affected scope to runmerges, correctly

With a path filter the required check is the CI job, so “did it run” and “is the gate satisfied” collapse into one fragile question. With a queue, the required check is the queue’s decision, which is always reported and always means what it says. The CI jobs become inputs to that decision instead of standing in for it.

The two questions aren’t the same

A path filter answers one question: did these files change? A required gate has to answer a different one: did everything that needed to pass, pass? A non-run can’t answer the second. It can only stay silent, and silence is the one thing a gate must never read as a yes. Use the filter to save minutes on work that doesn’t gate anything. Don’t let the absence of a run decide what’s safe to ship.

If you came here from the label post, this is the same disease with a quieter symptom. There, the required check turns green by skipping the test. Here, it never reports at all and gets faked green to unblock the merge. Both hand you a checkmark that doesn’t mean the test ran, and that is the only thing a merge gate was ever for.

Merge Queue

Tired of broken main branches?

Mergify's merge queue tests every PR against the latest main before merging. Try it free.

Learn about Merge Queue

Recommended posts

Merge Queue & CI

Stop Using Labels to Control CI in GitHub Actions

June 26, 2026 · 9 min read

Stop Using Labels to Control CI in GitHub Actions

Gating CI on a PR label feels clean until you learn how GitHub fires label events. Any label change re-runs the whole workflow from scratch, and a required check that's skipped when the label is absent counts as a pass, so PRs merge without ever running it. Here's why the pattern breaks and what to do instead.

Julien Danjou Julien Danjou
When a production deploy fails, our merge queue freezes itself
June 24, 2026 · 7 min read

When a production deploy fails, our merge queue freezes itself

A failed production deploy used to be invisible to our merge queue, which kept merging onto a broken release. Here's the 66-line GitHub Actions job that now freezes the queue the moment any deploy scope fails, and why lifting it is a human's job.

Thomas Berdy Thomas Berdy
Merge Queue & CI

When to Outgrow GitHub's Merge Queue

June 24, 2026 · 7 min read

When to Outgrow GitHub's Merge Queue

GitHub's native merge queue is the right starting point. Six signals it is time to upgrade, and what each one tells you about what to switch to.

Julien Danjou Julien Danjou