Run GitHub Actions Jobs Only When Specific Files Change
Use paths and paths-ignore to trigger workflows on file changes, and dorny/paths-filter to skip jobs, including monorepo matrices and required checks.
At a glance
- Last reviewed
- Versions referenced
actions/checkout@v7dorny/paths-filter@v4actions/setup-node@v7- Reading time
- 4 min read
Code samples are not run in a live repository. See our editorial standards.
On this page
To run a workflow only when certain files change, add a paths filter under the push or pull_request event. To skip individual jobs inside a workflow that does start, use a detection job with dorny/paths-filter and gate the other jobs on its output.
name: Frontend CI
on:
pull_request:
paths:
- 'src/frontend/**'
- 'package.json'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm test
Workflow-level filters: paths and paths-ignore
The workflow starts only if at least one changed file matches a paths pattern. With paths-ignore, it is skipped only when every changed file matches an ignore pattern. One ignored file among other changes does not stop the run.
| Filter | Workflow runs when | Typical use |
|---|---|---|
paths |
At least one changed file matches a pattern | Build only when a subdirectory changes |
paths-ignore |
At least one changed file does not match any pattern | Skip CI for docs-only changes |
You cannot use paths and paths-ignore on the same event. Actionlint reports both "paths" and "paths-ignore" filters cannot be used for the same event. Use ! patterns inside paths instead.
Exclude files with !
on:
push:
paths:
- 'sub-project/**'
- '!sub-project/docs/**'
pull_request:
paths:
- 'sub-project/**'
- '!sub-project/docs/**'
A push that changes sub-project/src/index.js triggers the workflow. A push that changes only sub-project/docs/readme.md does not. Order matters: a ! pattern only excludes files matched by a positive pattern listed before it. A paths list containing only ! patterns matches nothing, so use paths-ignore for that case.
How GitHub computes the changed files
GitHub builds the file list from a Git diff before applying the filter.
- Pushes: a two-dot diff between the head and base SHAs.
- Pull requests: a three-dot diff between the latest topic branch commit and the commit where it last synced with the base branch.
Diffs are limited to 300 files. If a matching file falls beyond that limit, the workflow may not start. If you hit this on large changes, move the filtering into a job (see below) or split the change.
Required checks stay pending when a workflow is skipped
If a path filter prevents a workflow from starting, any required status check from that workflow stays Pending and blocks the merge. GitHub has no workflow-level setting to report success for a workflow that never ran. For a related walkthrough, see GitHub Actions Scheduled Workflow Not Triggering: Causes and Fixes.
The fix is to let the workflow always start and skip work at the job level. A job skipped by a conditional if reports Success, so it satisfies a required check. Use the pattern in the next section, and remove paths from the workflow trigger.
Skip jobs with dorny/paths-filter
Add a detection job that publishes one boolean output per filter, then make the real job depend on it.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
frontend:
- 'src/frontend/**'
- 'package.json'
- 'package-lock.json'
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: |
npm ci
npm test
Each filter name becomes steps.<step-id>.outputs.<filter-name>, set to the string 'true' or 'false'. Outputs are strings, so compare against 'true'. Job outputs must be declared explicitly, as shown in outputs:. For a related walkthrough, see Pass Outputs Between GitHub Actions Jobs.
On push events the action diffs against the previous commit, so it needs the checkout step. On pull requests it uses the GitHub API, which is why pull-requests: read is set. The job also reports Success when skipped, so it works with required checks.
Skip matrix jobs per microservice
github.event.commits.*.modified is not a usable file filter, and the matrix context is not available in a job-level if. Instead, compute the list of changed services in a detection job and feed it into the matrix with fromJSON.
name: Build and Test Services
on:
push:
branches: [main]
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
services: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
auth: 'services/auth/**'
billing: 'services/billing/**'
notifications: 'services/notifications/**'
search: 'services/search/**'
build:
needs: changes
if: needs.changes.outputs.services != '[]'
name: Build ${{ matrix.service }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: ${{ fromJSON(needs.changes.outputs.services) }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: services/${{ matrix.service }}/package-lock.json
- name: Install, build, test
working-directory: services/${{ matrix.service }}
run: |
npm ci
npm run build
npm test
The changes output is a JSON array of the filter names that matched, for example ["auth","search"]. The matrix expands to one job per entry. When nothing matched, the array is [] and an empty matrix would fail, so the if on build skips the job instead.
fail-fast: false lets the other services finish when one fails. Keep service names in the filters identical to the directory names, because the matrix value is reused in paths.
Choosing between the two approaches
| Method | Advantage | Trade-off |
|---|---|---|
paths on the trigger |
No workflow run, no runner minutes | Required checks stay Pending when skipped |
Detection job plus if |
Required checks pass; per-service matrix | One short runner job always runs |
Verify it
- Push a commit that changes only a file outside your filters, such as
README.md. - With a trigger-level
pathsfilter, confirm no run appears in the Actions tab for that commit. - With the detection-job pattern, open the run and confirm the
changesjob succeeded and the gated job shows as skipped. - Push a change under
services/auth/and confirm onlyBuild authappears in the matrix. - If the matrix is wrong, add a step to the
changesjob that prints${{ steps.filter.outputs.changes }}and compare it with the files in the commit.
Common failures: a paths pattern with a leading ./ never matches, a ! pattern listed before its positive pattern has no effect, and a missing checkout step on push makes the filter step fail.
Spotted an error? Report it on our Contact page and see our editorial standards for how we correct articles.