skip to content

A GitLab CI job guarded by `rules:changes` keeps running even when none of the listed paths were modified. What are the usual causes?

level: seniorimportance: should knowfreq 45%

answer

  1. it needs two commits to compare
  2. no diff means it says yes
  3. the push, not the last commit
  4. a brand-new branch has no previous state
  5. pin the ref you diff against

basics

~20 s

rules:changes needs a diff to compare against. When there is none — a new branch, a tag pipeline, a scheduled or manual run — GitLab treats the paths as changed and the rule evaluates true. On branch pipelines it also compares the whole push, not just the last commit.

solid answer

~50 s

`rules:changes` is only meaningful when GitLab can compute a diff, and its answer depends on the pipeline type. In a **merge request pipeline** it compares the merge request's changes against the target branch, which is the behaviour people expect. In a **branch pipeline** it compares the commits in the push, so a push of ten commits or a merge into the branch matches anything any of those commits touched. And when there is no diff to compute at all — the first push of a new branch, a tag pipeline, a scheduled or `web`-triggered run — `changes` evaluates to **true** by design, so the job runs. The usual fixes are to scope the rule to merge request pipelines, or to pin the comparison with `rules:changes:compare_to` (GitLab 15.3+) so it always diffs against a known ref such as the default branch, and to pair `changes` with an `if` that restricts it to the pipeline sources where a diff exists.

code

yaml · 13 lines
yaml
frontend-tests:
  stage: test
  script: ./test-frontend.sh
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        paths:
          - frontend/**/*
          - package-lock.json
        compare_to: refs/heads/main

go deeper

for a junior

Know that rules:changes runs a job only when listed paths were modified, and that globs like frontend/**/* are how paths are written.

for a middle

Explain that the comparison basis differs by pipeline type — merge request diff versus the commits in a push — and that with no diff available the rule evaluates true and the job runs.

for a senior

Diagnose the surprise from the pipeline source rather than guessing: identify which of the four cases fired, pin the comparison with compare_to, scope the rule with an if clause, and re-test on a fresh branch and a merge.

for a principal

Set the policy for how far path filtering should go: which jobs may be skipped at all, why merge-gating jobs should not be, and how the team measures the runner minutes saved against the risk of a skipped check.

## What `changes` actually compares `rules:changes` looks like a simple path filter and behaves like one only in the case people test first. What it compares depends on the pipeline: | Pipeline type | Comparison basis | |---|---| | Merge request pipeline | The merge request's diff against its target branch | | Branch pipeline (push) | The commits included in that push | | New branch / new tag | Nothing to compare — evaluates true | | Scheduled, `web`, `api` runs | Nothing to compare — evaluates true | That table is the whole answer to "why did it run anyway". The evaluate-to-true fallback is deliberate: GitLab refuses to silently skip a job when it cannot determine what changed, because skipping would be the unsafe direction — you would ship untested code. ## The failure modes in order of frequency **1. First push of a new branch.** There is no previous state for that ref, so every `changes` rule is true and the whole pipeline runs. Teams that switch on path filtering and measure it on an existing branch never see this until someone opens a new one. **2. Multi-commit pushes and merges on branch pipelines.** A branch pipeline compares the push, not the last commit. Merge the default branch into a long-lived feature branch and the push contains everything that landed upstream, so every `changes` rule in the config matches. The job did not misfire; the diff really did contain those paths. **3. Non-push sources.** Scheduled nightly runs, manually started pipelines from the UI, and API-triggered runs have no push diff. Every `changes` rule is true, and the nightly "only if the frontend changed" job runs every night. **4. Glob mismatch in the other direction.** `changes: [src/**/*]` and `changes: ["src/**/*"]` behave the same, but `src/*` does not match nested files, and a top-level file that a build genuinely depends on — a lockfile, the CI config itself — is easy to leave out. That produces the opposite bug and it is worse: a job that should have run did not. ## Making it deterministic **Pin the comparison.** Since GitLab 15.3, `changes:` accepts a `paths` list plus `compare_to`: ```yaml frontend-tests: script: ./test-frontend.sh rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: paths: - frontend/**/* - package-lock.json compare_to: refs/heads/main ``` `compare_to` names the ref to diff against, so the result no longer depends on how many commits the push contained. Note the shape change: with `compare_to` the paths move under `changes:paths:` rather than being a bare list under `changes:`. **Scope by pipeline source.** Combine `changes` with an `if` in the same rule entry — clauses within one entry are ANDed — so the filter applies only where a diff is meaningful, and give the other sources their own explicit entry: ```yaml rules: - if: $CI_PIPELINE_SOURCE == "schedule" when: never - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: [frontend/**/*] ``` **Accept the safe direction.** Where determinism is hard, let the job run. A job that occasionally runs unnecessarily costs runner minutes; a job that is skipped when it should have run costs an incident. Reserve aggressive path filtering for genuinely expensive jobs, and never for the ones that gate a merge. ## Verifying a change to a `changes` rule - Test on a **new** branch, not just an existing one — that is the case the fallback fires on. - Test with a merge of the default branch into the feature branch. - Check a scheduled run if the project has one. - Confirm the merge request widget shows the job present or absent as intended; the job simply not existing is easy to miss. ## Related keyword, different problem `rules:exists` checks whether files matching a glob are **present** at the commit, not whether they changed. It is the right tool for "build a container only for components that have a `Dockerfile`" and it is stable across pipeline types, because presence needs no diff. Reaching for `exists` when the real question is "does this component exist" removes a whole class of `changes` surprises.

  • Why does GitLab make `changes` evaluate to true rather than false when it cannot compute a diff?
    Because the two errors are not symmetric. Running a job unnecessarily wastes runner minutes; skipping a job that should have run lets untested code through. When GitLab has no basis for the comparison — a new branch, a tag, a scheduled run — it defaults to the safe direction and creates the job.
  • What does `rules:exists` do that `rules:changes` does not?
    `exists` tests whether files matching a glob are present in the repository at that commit, with no diff involved, so it behaves identically in every pipeline type. It answers "does this component exist" rather than "did it change", which makes it the stable choice for per-component jobs in a repository where components come and go.
  • How would you keep a path-filtered job from silently skipping on the default branch?
    Give the default branch its own rule entry with no `changes` clause, placed before the filtered one, so the job always runs there. Path filtering is for reducing merge request feedback time; the branch that produces releasable artifacts should build everything, because that is the run whose output you may later have to promote.

saying these in an interview costs you the question

  • Assumes changes always compares against the target branch
  • Thinks a new branch's pipeline skips unchanged paths
  • Blames the runner cache for the job running again
  • Believes changes and exists are interchangeable
  • Uses path filters on the jobs that gate a merge

context