GitLab CI `rules:changes`: Compare Refs Before Skipping Jobs
Compare GitLab `rules:changes` against the intended ref so monorepo jobs skip irrelevant work without silently running on unrelated events.
Path-based job selection can make a large GitLab pipeline faster, but rules:changes is not a shell if evaluated when a runner starts. GitLab evaluates pipeline and job rules while it creates the pipeline. The meaning of “changed” depends on the pipeline source and comparison base. A job intended to run only for backend changes can therefore run on every scheduled pipeline, match a newly created branch unexpectedly, or disappear because a rule compared against the wrong ref.
Treat a path rule as a contract with three inputs: the event that created the pipeline, the ref used as the comparison base, and the paths whose differences matter. Then test that contract with representative pipeline sources before using skipped jobs as evidence that a component did not need testing.
A change has a pipeline-specific baseline
Without compare_to, GitLab uses a baseline associated with the pipeline type:
- In a branch pipeline,
rules:changescompares the branch commit with the previous commit on that branch. - In a merge request pipeline, it compares changes with the merge request target branch.
- A new branch pipeline also matches automatically. GitLab documents tag, scheduled, and manually created pipelines as contexts where a bare
rules:changescondition matches; do not infer a useful path baseline fromCI_PIPELINE_SOURCEalone. Tag pipelines can still haveCI_PIPELINE_SOURCE == "push", while therules:changesdocumentation separately specifies their matching behavior.
The last behavior is easy to miss. A schedule using a bare changes rule does not mean “run only if this scheduled ref differs from its parent.” GitLab treats the rule as matching because there is no push event from which to derive that comparison. For new branches, this avoids missing jobs when a previous commit does not exist, but it can also run every path-filtered job on branch creation.
Merge request diffs and branch-push diffs answer different questions. An MR rule ordinarily asks whether the proposed change relative to its target includes a path. A branch-push rule asks whether the latest commit changed a path relative to the preceding commit. With compare_to, you deliberately choose a different ref and ask whether the current pipeline ref differs from that ref. That can include changes accumulated earlier on a long-lived branch, not just the newest commit.
Choose compare_to for the job’s actual question
Use rules:changes:compare_to when the implicit baseline is not the one you intend, especially for new branches and non-push sources. It must be used with rules:changes:paths. The comparison target can be a branch, tag, commit SHA, or supported CI/CD variable. Variable support was introduced in GitLab 17.2, so verify the feature against the GitLab version used by a self-managed instance before relying on it.
These examples use job-level rules. include:rules is a separate configuration-inclusion gate, evaluated while GitLab assembles the pipeline rather than after a job starts. GitLab’s predefined-variable reference identifies which predefined values are available at that phase; its include guide separately documents supported user-supplied and project/group/instance variables. Do not read the predefined-variable list as excluding every documented include-time variable, and do not assume job-only or dotenv values exist during include evaluation. The include guide currently demonstrates rules:changes:compare_to inside an include rule, while the main YAML reference labels compare_to as a job keyword. Treat that documentation difference as a version-sensitive compatibility question: check the exact GitLab release and validate with its CI Lint and pipeline editor before relying on the syntax.
For example, a component job on a feature branch may need to run when that component differs from the default branch, even if the current commit changed only documentation. An explicit baseline makes that policy visible:
backend_tests:
script:
- ./ci/test-backend.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH'
changes:
compare_to: 'refs/heads/$CI_DEFAULT_BRANCH'
paths:
- "services/backend/**/*"
- "libraries/contracts/**/*"
- when: never
This is a cumulative branch-versus-default-branch policy. If the feature branch still contains backend differences from the default branch, the job continues to match on later commits even when those later commits touch only documentation. That may be correct for validating the branch’s complete proposed state. If the intended question is instead “did this push modify backend files?”, leave the branch-pipeline baseline implicit and use changes without compare_to.
An MR usually already uses its target branch as the baseline. Add compare_to to an MR rule only when the policy intentionally compares against another ref. Merged-results pipelines use an internal commit, and GitLab documents situations where an explicit comparison can produce unexpected results for merged-result and forked-project pipelines. Validate those configurations with actual pipeline types instead of assuming that the comparison is equivalent to a local git diff command.
Match paths, not shell expressions
Each path in a changes list is a path pattern. A list is an OR: the rule’s changes condition is true when at least one listed path matches. Supported forms include exact paths, directory globs such as services/api/**/*, and variable-expanded paths. These patterns are evaluated by GitLab’s documented matching behavior, not expanded by the runner shell. Avoid ./, doubled slashes, and path fragments that merely look like shell globs; GitLab compares normalized path strings according to its CI configuration rules.
If an if, changes, or exists condition appears in the same rule item, all of those included conditions must be true for that rule to match. Separate items in rules are evaluated in order and GitLab stops at the first match. Thus the following is an AND between branch identity and path change, while the two path entries are an OR:
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
changes:
- "services/api/**/*"
- "libraries/contracts/**/*"
This job is not added unless the commit is on the default branch and one of the listed paths matches. A later rule is not an additional independent chance to run the job if this item already matched; rule order also determines attributes such as when and allow_failure.
Rules run before any job starts. A value written into a dotenv report by an earlier job cannot decide a later job’s rules:changes; dotenv values do not exist at rule-evaluation time. Use repository paths and variables available when GitLab creates the pipeline (pre-pipeline or pipeline-phase variables supported by job rules), or move a truly runtime-dependent choice into a job that starts and performs the check itself.
Keep workflow admission separate from job selection
workflow:rules decides whether GitLab creates a pipeline; job-level rules:changes only decides whether a job is added to a pipeline that already exists. A path rule cannot restore a pipeline rejected by workflow policy. Event deduplication and source admission are covered in the dedicated GitLab pipeline design guide; keep this article focused on the distinct question of which admitted jobs should run for a ref comparison.
When reviewing an apparently skipped job, inspect both decisions in order: first confirm the event created a pipeline, then evaluate the first matching job rule and its comparison base. That prevents a workflow-level suppression from being misdiagnosed as a compare_to or glob-matching defect.
If scheduled pipelines should inspect a maintenance branch only when it differs from the default branch, make that comparison explicit:
scheduled_backend_audit:
script:
- ./ci/audit-backend.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
changes:
compare_to: 'refs/heads/$CI_DEFAULT_BRANCH'
paths:
- "services/backend/**/*"
- when: never
The scheduled pipeline must target the ref whose state you want to inspect. If it targets the default branch and compares that branch to itself, there is no backend difference to match. If the job must run every night regardless of source changes, use an if for the schedule without a changes gate. A scheduled pipeline that should be change-aware and one that must execute on every schedule are different policies.
Test the rule as a behavior matrix
Before merging a path filter, record expected behavior for both positive and negative cases. Across the example policies above, a useful fixture matrix is:
| Event and fixture | Expected result |
|---|---|
MR changes services/backend/auth.go |
MR job is added because the path differs from the target branch. |
MR changes only docs/operations.md |
Backend job is absent unless another listed backend path changed. |
| New feature branch contains backend changes relative to the default branch | Explicit compare_to evaluates those changes; a bare new-branch changes rule would match automatically. |
| Existing feature branch receives a docs-only commit but still differs from the default branch in backend files | The cumulative compare-to rule still matches; an implicit push rule would inspect only the newest commit. |
Scheduled pipeline has no compare_to |
A changes rule can match regardless of actual file differences because no push event supplies a baseline. |
| Scheduled pipeline targets the default branch and compares it with itself | No listed path differs; use an unconditional schedule rule if the job must run every time. |
| A globbed rule reaches GitLab’s 50,000-check limit | Patterned rules are assumed to match, so expect extra jobs rather than a reliable skip. |
For the paths/glob form, GitLab allows at most 50 paths or patterns in one rules:changes section. For performance, after 50,000 checks, patterned-glob rules assume a match. This can increase pipeline work; it is not evidence that the paths were exhaustively checked. GitLab 19.2 also adds rules:changes:regexp, which uses a Ruby regular expression instead of paths; the two are mutually exclusive, the pattern is limited to 255 characters, and oversized evaluation can fail on its timeout budget. The 50-path limit describes paths, not a regexp list. Check the server version before using regexp, keep either form small and intentional, and treat matching as workload selection rather than authorization.
Diagnose a job that runs or disappears unexpectedly
When a job runs on a docs-only change, check the pipeline source first. A schedule, manual pipeline, tag pipeline, or new branch can make an implicit changes rule true. Then inspect the comparison base: a feature branch compared with the default branch may still differ in the selected component from earlier commits. Finally, check glob paths, variable expansion, and the 50,000-check behavior.
When a job is absent, check whether an earlier rule matched and selected when: never, whether the path was actually changed relative to the chosen ref, and whether compare_to points to the intended branch or commit. Check merged-results and fork context before trusting a comparison that worked in a simple same-project MR. Use GitLab CI Lint and the pipeline editor to validate syntax and inspect the resulting configuration, then verify real branch, MR, tag, and schedule behavior in a test project or controlled branch. Syntax validation alone does not prove that a rule selected the right baseline.
Use rules:changes as a workload optimization, not as a security boundary or proof that an omitted test was irrelevant. Protect required checks with the repository’s merge policy, and make release pipelines explicit about which pipeline sources are allowed to create them. A deliberate comparison ref, a deliberate event policy, and a small set of positive and negative fixtures make skipped work reviewable instead of mysterious.
Related:
- GitLab CI Production Pipelines: DAG Dependencies, Artifact Contracts, and Deployment Locks
- GitLab CI/CD Components: Typed Inputs, Version Pins, and Safe Reuse
Sources: