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.

Published

At a glance

Last reviewed
Versions referenced
actions/checkout@v7 dorny/paths-filter@v4 actions/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

  1. Push a commit that changes only a file outside your filters, such as README.md.
  2. With a trigger-level paths filter, confirm no run appears in the Actions tab for that commit.
  3. With the detection-job pattern, open the run and confirm the changes job succeeded and the gated job shows as skipped.
  4. Push a change under services/auth/ and confirm only Build auth appears in the matrix.
  5. If the matrix is wrong, add a step to the changes job 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.