Skip to content

Run GitLab CI jobs only when files change

Updated

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.

Pipelinerules:changes compares withResult
Merge requestThe target branchWhat most people expect
BranchThe previous commit on the branchOnly what the latest push changed
New branchNothingAlways true, so the job runs
Tag, scheduled or manualNothing: there's no Git pushAlways true, so the job runs

These rules come from GitLab's rules:changes reference.

Filter a job

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

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

.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 keeps one of them:

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

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

Keep one job that always runs in merge request pipelines:

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

.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. The job still starts and installs, so hushrun saves the check's minutes, not the job's.

Markdown