Best practice · Trigger Scope

Filter workflows by path in GitHub Actions

Add `paths` or `paths-ignore` to expensive workflows so a docs-only or unrelated change doesn't trigger the full test suite.

Do this

Expensive workflows (E2E, full integration suites) declare a paths filter listing the source directories that can actually affect them, or a paths-ignore list for docs and markdown. A README change no longer spins up the entire suite. Three syntax rules govern the filter: paths and paths-ignore are only evaluated on the push, pull_request, and pull_request_target events (on any other trigger they do nothing, and they are skipped for tag pushes); * matches any character except / while ** matches across directories; and a ! negation excludes what an earlier pattern matched, with the last matching pattern winning, which is why an exclusion like !**/*.md has to come last. That last rule is GitHub's own; dorny/paths-filter is a different matcher that ORs its patterns, so a ! pattern there does not mean the same thing.1234

Gate the job when the check is required (safe to paste as-is)
# A REQUIRED check must never be filtered at the workflow level: a workflow that
# does not run reports no status at all, and the PR blocks forever waiting for it.
# So leave the trigger unfiltered, compute what changed in a cheap job, and gate
# the expensive job with if:. A job skipped by a conditional reports Success,
# which satisfies branch protection.
#
# Not a required check? Then you do not need any of this. Filter the trigger
# itself and the workflow simply does not run:
#
#   on:
#     pull_request:
#       paths:
#         - "src/**"
#         - "package.json"
#         - "pnpm-lock.yaml"
#         - ".github/workflows/e2e.yml"   # re-run if the workflow itself changes
on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    # dorny reads the PR's file list from the REST API, so it needs
    # pull-requests: read. Declaring a permissions block at all sets every scope
    # you DON'T list to none, and new repos default to a restricted token that
    # lacks it anyway - either way, without this line the step fails with
    # "Resource not accessible by integration" and takes the required check down
    # with it. No checkout needed on pull_request events.
    permissions:
      contents: read
      pull-requests: read
    outputs:
      src: ${{ steps.filter.outputs.src }}
    steps:
      - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
        id: filter
        with:
          # dorny is not GitHub's matcher: it ORs its patterns, so a file that
          # matches ANY of them sets the output. Do not paste a "!" exclusion in
          # here expecting GitHub's last-match-wins rule - "!**/*.md" would match
          # every file that is not markdown, and the filter would fire on
          # everything. List what you DO care about.
          filters: |
            src:
              - "src/**"
              - "package.json"
              - "pnpm-lock.yaml"
  e2e:
    needs: changes
    if: ${{ needs.changes.outputs.src == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
      - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
        with:
          version: 10   # ubuntu-latest ships no pnpm; required unless package.json sets "packageManager"
      - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:e2e

Avoid this

A docs-only change spins up the full suite, spending wall-clock and runner minutes for nothing.

Runs on every change, including docs
on:
  pull_request:   # a one-word README fix triggers the full E2E suite

Seen in the wild

Real workflow files from public projects.

on:
  push:
    branches:
      - main
    paths:
      - "packages/**"
      - .prettierrc
      - .prettierignore
      - tsconfig.json
      - oxlint.json
      - "!**/*.md"
Runs only when the packages tree or the shared config changes, and the trailing negation keeps a markdown-only edit from starting it.
  pull_request:
    paths-ignore:
      - compiler/**
The runtime test suite never starts for a pull request that only touches the compiler.
  pull_request:
    paths:
      - crates/**
      - examples/**
      - Cargo.toml
      - .github/workflows/test-rust.yml
The Rust suite runs only when crate sources, examples, the manifest, or this workflow file itself change, so a Python-only or docs pull request skips it.

Why it matters

Running an expensive suite on changes that can't affect it is pure waste, both wall-clock the developer waits on and runner-minutes you pay for. Path filters are a high-impact, low-risk trigger fix. The one thing to watch: if a required status check is path-filtered, a skipped run never reports its status, and GitHub has no branch-protection setting that treats a missing check as passed, so the PR waits forever. Either don't make the path-filtered workflow a required check, or gate the work with a job-level if: condition instead of a workflow-level paths filter: GitHub reports a job skipped by a conditional as Success, which satisfies the required check, whereas a skipped workflow reports nothing at all.

When to use

Use it when

Workflows with several jobs and a clear set of source paths that gate them, especially E2E and integration suites.

Be careful when

Be careful path-filtering a required status check: a filtered-out workflow never reports, and GitHub can't treat a missing check as satisfied, so the PR stays blocked. Prefer path-filtering non-required expensive workflows. If the check must stay required, gate the work with a job-level if: condition instead: a job skipped by a conditional reports Success and satisfies the check, unlike a workflow skipped by paths. To compute the condition that if: tests, run dorny/paths-filter in a cheap upstream job (pinned to its commit SHA) and read its per-filter output, as in the YAML above.

Verify on your repo

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix this practice in your own repo.

Prompt for your coding agent
Inspect this repo's .github/workflows for expensive workflows (E2E, integration suites) that trigger on every change with no path scoping. For each, look at the `on:` block for `paths:` or `paths-ignore:` and flag multi-job workflows that have neither. Add a `paths` allowlist of the source directories that can actually affect the workflow (include the workflow file itself) so a docs-only change does not spin up the full suite. Important: do not path-filter a required status check, since a skipped workflow never reports and blocks the PR forever, so instead gate the expensive work with a job-level `if:` condition (a job skipped by a conditional reports Success). Show me the diff and open a PR rather than applying it blindly.

Ground these changes in the upstream docs before you edit: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax, https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.

Prefer to check by hand?

  1. Look at the on: block of each expensive workflow for paths: or paths-ignore:.

  2. Flag workflows with several jobs and no path filtering at all.

  3. For any required check, don't path-filter the whole workflow: either drop it from the required list, or gate the expensive work with a job-level if: (a job skipped by a conditional reports Success), so PRs aren't blocked waiting on a check that never runs.

Automate this with StarSling.

Rather than audit this by hand, install the StarSling GitHub App: it opens a pull request to move your workflows onto StarSling runners, then its agents keep reading your jobs, run logs, and machine telemetry and open pull requests that fix what this page covers, along with caching, parallelization, and test sharding across the rest of your pipeline. Every change arrives as a PR you review and merge.

More best practices for GitHub Actions

Where to go next in the CI best-practices catalog.

All CI best practices

FAQ

Should I use paths or paths-ignore?

paths is an allowlist (run only when these change) and is safest for a suite with a well-defined source set. paths-ignore is a denylist (skip when only these change) and fits when you mainly want to exclude docs and markdown. Don't set both on the same event.

Will a path filter break a required status check?

It can. When a workflow is skipped by a paths filter, its check context never reports, and branch protection waits for it forever, GitHub has no setting that counts a missing check as passed. The safe fix: don't require a path-filtered workflow, or gate the expensive job with a job-level if: condition instead. GitHub reports a job skipped by a conditional as Success (which satisfies the required check), whereas a skipped workflow reports nothing.

Why is my paths filter being ignored?

GitHub only evaluates paths and paths-ignore on the push, pull_request, and pull_request_target events, and it skips them for tag pushes. On a schedule, workflow_dispatch, or workflow_call trigger the filter does nothing and the workflow runs anyway. The other common cause is the glob: * stops at a /, so src/* matches only the files directly under src, while src/** matches the whole tree beneath it.

Sources

1GitHub Actions · paths / paths-ignore filters (opens in new tab)

2GitHub Actions · filter pattern cheat sheet (workflow syntax) (opens in new tab)

3dorny/paths-filter (opens in new tab)

4GitHub · troubleshooting required status checks (opens in new tab)

Last updated 2026-07-15

Get started

Don't run the full suite on a docs change.

One line to install. Faster runs on day one, and agents that open reviewable PRs to keep your pipeline following practices like this one.