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 api

Four 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 web

dorny/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.

Questionpaths on the triggerDetection job plus job-level if
Granularity of the decisionThe whole workflowOne job at a time
A skipped run appears asChecks stay PendingThe job reports Success
Works on schedule and workflow_dispatchNoYes
Runner minutes spent decidingNoneOne short job per run
Fans a shared package out to dependentsNoYes, when the filters list it
Where the rule livesThe on: block of each fileOne 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).

SituationWhat the check reportsEffect on a required check
Workflow skipped by path filteringPendingBlocks the merge
Job skipped by an if conditionSuccessSatisfies the rule
Job skipped because a needed job failedSkippedMay 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 1

Branch 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.

ProjectJob lengthTouch rateUngated minutesGated minutes
api12 min40 percent of pushes10,8004,320
web9 min45 percent of pushes8,1003,645
workers7 min20 percent of pushes6,3001,260
docs3 min15 percent of pushes2,700405
Detection job0.5 minevery push0450
LineMonthly minutesRateMonthly cost
Every project on every push27,900$0.008 per minute$223.20
Gated project jobs9,630$0.008 per minute$77.04
Detection job450$0.004 per minute$1.80
Gated total10,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.

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.