How Do I Run a Job Only When Files Change?
Gate a workflow with a paths filter on the trigger, and use a change detection job feeding job-level if conditions when one workflow serves many projects.
Two mechanisms cover this, and the right one depends on how many projects a single workflow serves. Put a paths filter on the trigger when the workflow belongs to one project, and run a short change detection job whose outputs feed if conditions on the other jobs when one workflow serves several. The choice has a consequence beyond tidiness: GitHub documents that a workflow skipped by path filtering leaves its checks in a Pending state that blocks merging, while a job skipped by a conditional reports Success (troubleshooting required status checks).
Answer
Gate the whole workflow with a trigger filter
When every job in the file belongs to one project, the filter goes on the trigger and the run never starts.
# .github/workflows/api.yml
name: api
on:
pull_request:
paths:
- 'services/api/**'
- 'packages/shared/**'
- '!services/api/docs/**'
jobs:
test:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v5
- run: bun install --frozen-lockfile
- run: bun run test --filter apiFour rules from GitHub's workflow syntax reference decide whether that filter behaves the way you expect. You cannot use paths and paths-ignore for the same event in one workflow, so a mixed include and exclude list uses paths with ! prefixes, as above. Pattern order matters, because a negative pattern after a positive match excludes the path and a later positive match includes it again. Path filters are skipped entirely for pushes of tags. And when branches and paths are both present, the run needs both to be satisfied.
Gate individual jobs with a detection job
When one workflow serves several projects, the decision has to happen after the run starts. A first job computes which project directories changed and exposes the result as job outputs; every other job reads those outputs in its if.
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
changes:
runs-on: warp-ubuntu-latest-x64-2x
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'services/api/**'
- 'packages/shared/**'
web:
- 'apps/web/**'
- 'packages/shared/**'
api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v5
- run: bun run test --filter api
web:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v5
- run: bun run test --filter webdorny/paths-filter is the common public action for the detection step. A git diff --name-only step against the base ref does the same work, provided the checkout uses fetch-depth: 0, because actions/checkout fetches a single commit by default and a shallow clone has nothing to diff against.
Here is how the two mechanisms differ on the points that usually decide the choice.
| Question | paths on the trigger | Detection job plus job-level if |
|---|---|---|
| Granularity of the decision | The whole workflow | One job at a time |
| A skipped run appears as | Checks stay Pending | The job reports Success |
Works on schedule and workflow_dispatch | No | Yes |
| Runner minutes spent deciding | None | One short job per run |
| Fans a shared package out to dependents | No | Yes, when the filters list it |
| Where the rule lives | The on: block of each file | One job that every other job reads |
Detail
The required check trap
This is the failure that sends teams back to running everything. A branch protection rule requires the check named test. Someone adds a paths filter to the workflow that produces it. A pull request that touches only documentation now shows test as Pending forever, and the merge button stays disabled. GitHub's own guidance on this is to avoid requiring workflows that can be skipped (handling skipped but required checks).
| Situation | What the check reports | Effect on a required check |
|---|---|---|
| Workflow skipped by path filtering | Pending | Blocks the merge |
Job skipped by an if condition | Success | Satisfies the rule |
| Job skipped because a needed job failed | Skipped | May not block the merge |
The third row is why the detection pattern usually ends with an aggregate gate. Point the branch rule at one job that always runs and inspects the results of the others.
required-checks:
if: always()
needs: [changes, api, web]
runs-on: warp-ubuntu-latest-x64-2x
steps:
- name: Fail when a needed job failed or was cancelled
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1Branch protection then requires required-checks and nothing else, so adding or removing a project job never means editing the rule. Renaming jobs stays cheap, which matters in a repository where the job list grows with the project list.
What the gating saves in a monorepo
Skipped jobs consume no runner minutes, and pricing is purely usage based. That makes the saving a straight subtraction once you know how often each project is touched.
Assumptions for the worked example, so you can substitute your own: a repository with four projects, 900 pull request pushes per month, project jobs on warp-ubuntu-latest-x64-4x at $0.008 per minute, and a detection job of 30 seconds on warp-ubuntu-latest-x64-2x at $0.004 per minute. Rates are from the cloud runners documentation and the pricing page, checked on 2026-08-13. The touch rates below are the share of pushes that change each project.
| Project | Job length | Touch rate | Ungated minutes | Gated minutes |
|---|---|---|---|---|
| api | 12 min | 40 percent of pushes | 10,800 | 4,320 |
| web | 9 min | 45 percent of pushes | 8,100 | 3,645 |
| workers | 7 min | 20 percent of pushes | 6,300 | 1,260 |
| docs | 3 min | 15 percent of pushes | 2,700 | 405 |
| Detection job | 0.5 min | every push | 0 | 450 |
| Line | Monthly minutes | Rate | Monthly cost |
|---|---|---|---|
| Every project on every push | 27,900 | $0.008 per minute | $223.20 |
| Gated project jobs | 9,630 | $0.008 per minute | $77.04 |
| Detection job | 450 | $0.004 per minute | $1.80 |
| Gated total | 10,080 | $78.84 |
Gating removes 17,820 runner minutes and $144.36 per month at these assumptions, and it shortens the critical path of the average pull request by more than it shortens the bill, because the jobs that disappear were running in parallel with the ones that remain. The Jobs section of the reports page gives you the two inputs the model needs from your own repository rather than from an assumption: run count and duration percentiles for each unique repository, workflow, and job name combination, with CSV export for the arithmetic. Substitute your P75 durations and your real touch rates before deciding whether the detection job earns its place.
The pattern itself is platform independent. The if condition on a job is evaluated by GitHub before any runner is assigned, so a skipped macOS job costs the same as a skipped Linux one. The guide to speeding up GitHub Actions covers the levers that apply to the jobs that do run.
Where path filters get the answer wrong
GitHub builds the changed-file list with two-dot diffs for pushes and three-dot diffs for pull requests, and it publishes three limits that override your filter. A push containing more than 1,000 commits always runs the workflow. A diff that times out always runs the workflow. And a diff with more than 3,000 files skips the workflow when the matching files fall outside the first 3,000 returned. Large merges and long-lived branches hit all three, so a repository that gates on paths should expect occasional full runs and size its concurrency for them.
The harder failure is silent. A glob matches paths and knows nothing about imports, so a change to packages/shared skips every project that depends on it unless each filter lists that path explicitly, which is what the filters block above does by repeating packages/shared/** under both api and web. That hand-maintained mapping drifts the first time someone adds an import without touching the workflow file. Deriving the affected set from the build graph removes the drift, which is the subject of test impact analysis on GitHub Actions. Monorepo pipelines on GitHub Actions covers how the surviving jobs get sized and sharded once the gating is in place.
Related Questions
Why is my pull request stuck on a pending check after I added a paths filter?
Because the workflow never ran. GitHub documents that a workflow skipped by path filtering leaves the checks associated with it in a Pending state, and a pull request that requires those checks is blocked from merging. A job skipped by an if condition reports Success instead, so moving the gate from the trigger to the job level clears the block. The aggregate required-checks job above is the version of that pattern which also survives a failed dependency.
Do path filters work on schedule or workflow_dispatch triggers?
No. The paths and paths-ignore filters apply to push, pull_request, and pull_request_target, and GitHub documents that path filters are not evaluated for pushes of tags either. A workflow that also runs on a schedule or on manual dispatch has to do its gating inside the jobs, because the trigger filter has nothing to compare against on those events. The detection job pattern handles every trigger, at the cost of one short job per run.
How do I make a change in a shared package rebuild everything that depends on it?
A glob pattern knows file paths and nothing about the dependency graph, so a change to a shared package skips the projects that import it unless you say otherwise. Either add the shared path to every dependent filter by hand, which stays accurate until someone adds an import, or compute the affected set from the build graph and feed that list into the matrix. Run only affected tests on GitHub Actions works through the second approach, and what a monorepo is covers the repository shape where the question comes up.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.