Skip to content

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.

LevelHowA required checkA skipped run costs
Workflowpaths on the triggerStays pendingNothing
Jobif: on a paths action's outputReports successOne short job
Stepif: on a git diffReports successThe job's start and checkout

Filter the whole workflow

paths starts a workflow only when the push or pull request changes a matching file:

.github/workflows/ci.yml
on:
  pull_request:
    paths:
      - "src/**"
      - "package-lock.json"

paths-ignore does the reverse, which suits documentation:

.github/workflows/ci.yml
on:
  pull_request:
    paths-ignore:
      - "docs/**"
      - "**.md"

GitHub's workflow syntax sets these limits:

  • You can't use paths and paths-ignore for the same event. To do both, use paths with 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:

.github/workflows/ci.yml
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: api

On 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:

.github/workflows/ci.yml
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:

.github/workflows/ci.yml
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 test

Keep 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> # v4

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

Markdown