# Run GitHub Actions only when files change

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:

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

`paths-ignore` does the reverse, which suits documentation:

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

GitHub's [workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/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](https://hushrun.com/docs/guides/required-check-waiting-for-status.md). 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:

```yaml title=".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`](https://github.com/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](https://hushrun.com/docs/guides/required-check-waiting-for-status.md#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:

```yaml title=".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:

```yaml title=".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](https://github.com/advisories/GHSA-mrrh-fwg8-r2c3): 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:

```yaml
- 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`](https://hushrun.com/docs/run.md) 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](https://hushrun.com/docs/guides/skip-ci.md) sets the methods side by side.

## Related

- [Fix "Waiting for status to be reported"](https://hushrun.com/docs/guides/required-check-waiting-for-status.md)
- [Skip CI when the code hasn't changed](https://hushrun.com/docs/guides/skip-ci.md)
- [Run GitLab CI jobs only when files change](https://hushrun.com/docs/guides/gitlab-ci-rules-changes.md)
