# Run GitLab CI jobs only when files change

`rules:changes` adds a job to a pipeline only when the listed files changed. When it seems not to work, the pipeline's type is usually why: each type compares with something different, and some compare with nothing.

| Pipeline | `rules:changes` compares with | Result |
|---|---|---|
| Merge request | The target branch | What most people expect |
| Branch | The previous commit on the branch | Only what the latest push changed |
| New branch | Nothing | Always true, so the job runs |
| Tag, scheduled or manual | Nothing: there's no Git push | Always true, so the job runs |

These rules come from GitLab's [`rules:changes` reference](https://docs.gitlab.com/ci/yaml/#ruleschanges).

## Filter a job

Pair `changes` with a condition on the pipeline type, so the comparison is the one you want:

```yaml title=".gitlab-ci.yml"
test:
  script:
    - npm ci
    - npm test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        - src/**/*
        - package-lock.json
```

When no rule matches, GitLab leaves the job out of the pipeline. A path that starts with `*` needs double quotes, such as `"**/*.json"`.

## Why the job runs every time

- **The branch is new.** Its first pipeline has nothing to compare with.
- **The pipeline is scheduled, a tag or manual.** `changes` is always true there.
- **A later rule matches.** Rules are checked in order and the first match wins, so a final `- when: on_success` adds the job whenever `changes` doesn't.
- **The job is in two pipelines.** A branch pipeline and a merge request pipeline can both run, each with its own comparison.

## Compare with a branch

`compare_to` sets what to compare with, which covers new branches:

```yaml title=".gitlab-ci.yml"
test:
  script:
    - npm ci
    - npm test
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_BRANCH
      changes:
        paths:
          - src/**/*
        compare_to: refs/heads/main
```

The first rule matters: `main` compared with `main` has no changes, so without it the job would never run there. GitLab also warns that `compare_to` can give unexpected results in merged results pipelines and in forks, because the comparison base isn't what it seems.

## Stop duplicate pipelines

A push to a branch with an open merge request can start both a branch pipeline and a merge request pipeline. GitLab's [`workflow:rules` example](https://docs.gitlab.com/ci/yaml/workflow/) keeps one of them:

```yaml title=".gitlab-ci.yml"
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH
```

## When another job needs a filtered job

`needs` expects the job to be in the pipeline. If rules can leave it out, mark it optional:

```yaml title=".gitlab-ci.yml"
deploy:
  script:
    - ./deploy.sh
  needs:
    - job: test
      optional: true
```

When `test` is in the pipeline, `deploy` waits for it. When it isn't, `deploy` starts once its other needs are met.

## When every job is filtered out

If rules leave a merge request with no jobs, it has no pipeline. With **Pipelines must succeed** on, [GitLab won't merge it](https://docs.gitlab.com/user/project/merge_requests/auto_merge/): a merge request with no pipeline doesn't count as having a successful one. This is GitLab's version of a [required check stuck on GitHub](https://hushrun.com/docs/guides/required-check-waiting-for-status.md).

Keep one job that always runs in merge request pipelines:

```yaml title=".gitlab-ci.yml"
ci-ok:
  stage: test
  script:
    - echo "CI ran"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
```

Give it a normal stage: a pipeline with only `.pre` or `.post` jobs doesn't run. For pipelines skipped with `[skip ci]`, the project setting **Skipped pipelines are considered successful** allows the merge.

## What `rules:changes` can't see

`rules:changes` asks what changed, not whether this code already passed:

- **It can skip too much.** A shared file missing from the list passes with nothing tested.
- **It can run too much.** A revert, or a rebase 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. The job stays in every pipeline, so there's no list to keep up to date, and a skip exits 0, so the pipeline succeeds:

```yaml title=".gitlab-ci.yml"
test:
  image: node:22
  script:
    - npm ci
    - npm test
```

Here `npm test` is wrapped once in `package.json`, as `hushrun run -- vitest run`. GitLab CI records results with an agent token and the key that comes with it: see [Other CI](https://hushrun.com/docs/ci.md#other-ci). The job still starts and installs, so hushrun saves the check's minutes, not the job's.

## Related

- [Run GitHub Actions only when files change](https://hushrun.com/docs/guides/github-actions-run-when-files-change.md)
- [Skip CI when the code hasn't changed](https://hushrun.com/docs/guides/skip-ci.md)
- [Promotion on GitLab CI](https://hushrun.com/docs/promotion/gitlab.md)
