Run GitHub Actions only when files change
Updated
You can filter on changed files at three levels: the workflow, a job or a step. The level decides what happens to required checks, and how much of a run you still pay for.
| Level | How | A required check | A skipped run costs |
|---|---|---|---|
| Workflow | paths on the trigger | Stays pending | Nothing |
| Job | if: on a paths action's output | Reports success | One short job |
| Step | if: on a git diff | Reports success | The job's start and checkout |
Filter the whole workflow
paths starts a workflow only when the push or pull request changes a matching file:
on:
pull_request:
paths:
- "src/**"
- "package-lock.json"paths-ignore does the reverse, which suits documentation:
on:
pull_request:
paths-ignore:
- "docs/**"
- "**.md"GitHub's workflow syntax sets these limits:
- You can't use
pathsandpaths-ignorefor the same event. To do both, usepathswith patterns that start with!. - A pull request compares with the point where its branch left the base branch. A push to an existing branch compares the commits before and after it.
- Past 3,000 changed files, a workflow doesn't run if its matches aren't among the first 3,000. A push of more than 1,000 commits always runs.
A workflow filtered out this way never reports. If one of its checks is required, the pull request sticks on Waiting for status to be reported. Use this level for workflows that aren't required.
Filter one job
To filter a required check, or to run only part of a workflow, start the workflow every time and decide per job. GitHub has no built-in filter at this level, so a first job works out what changed:
jobs:
changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
api:
- "api/**"
web:
- "web/**"
test-api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: make test
working-directory: apiOn a pull request, dorny/paths-filter reads the changed files from GitHub's API, so it needs pull-requests: read and no checkout. On a push it uses git, so add actions/checkout before it, and contents: read to the job's permissions.
A job skipped by if: reports success. A job whose dependency failed is skipped too, and that skip may not block merging, which can hide a failure: require one summary job to close that gap.
In a monorepo
The action's changes output lists the filters that matched, as JSON. A matrix can use it to start one job per changed package:
jobs:
changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
packages: ${{ steps.filter.outputs.changes }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
api: "packages/api/**"
web: "packages/web/**"
test:
needs: changes
if: needs.changes.outputs.packages != '[]'
strategy:
matrix:
package: ${{ fromJSON(needs.changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: make test
working-directory: packages/${{ matrix.package }}The if: matters: a matrix built from an empty list is an error.
Filter one step
With no third-party action, compare the files yourself and skip the expensive steps. The job always runs, so its check always reports:
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: code
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: |
if git diff --quiet "$BASE" HEAD -- src package-lock.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.code.outputs.changed == 'true'
run: npm ci && npm testKeep the base commit in env, as here, rather than in the script, so that nothing from the event ends up in a shell command.
Pin third-party actions
An action that reads your changed files runs in a job that may hold secrets. In March 2025, tj-actions/changed-files was compromised: its version tags were moved to a commit that wrote secrets to build logs. A tag can move, and a full commit SHA can't, so pin to the SHA:
- uses: dorny/paths-filter@<full commit SHA> # v4What a path filter can't see
A path filter asks what changed in this push. That leaves two gaps:
- It can skip too much. A filter that lists
src/**passes a change to a lockfile or a shared package with nothing tested. Keep the list complete, and put what a check reads in it. - It can run too much. A revert, or a rebase or merge that leaves the files as they were, shows up as changed files, though these exact files already passed.
hushrun run compares the files with what already passed, not with the last push. A check runs unless the same files, check name, command and directory have a recorded pass, and a skip exits 0, so a required check still reports. The job still starts and installs. Skip CI when the code hasn't changed sets the methods side by side.