DEV Community

Jordan Huang
Jordan Huang

Posted on

FAQ: What GitLab `rules:changes` Actually Compared

Did GitLab skip your test job again?
You probably trusted a path filter.

I keep seeing this on merge requests.
A skipped badge becomes a safety story.

That story is usually wrong.
A skip is a diff, not a verdict.

This FAQ is a corrected mental model.
You leave with a local rehearsal, not a slogan.

Why skipped jobs feel so convincing

GitLab draws a neat skipped badge.
Humans read badges faster than git ranges.

The YAML looks precise and complete.
Glob lists feel like a reviewed contract.

They are not a contract.
They are a comparison against one baseline.

Miss that baseline, and the skip misleads.
The pipeline can still read as green.

Myth 1: A skip means those paths never changed

This is the loudest claim I hear.
"Frontend never touched src/, so tests skipped."

GitLab did not audit your intent.
It compared file names to a list.

Which commit is the left side?
That question is the whole bug.

On many branch pipelines the default is harsh.
rules:changes often compares to the previous commit.

Push a follow-up that only edits docs.
Your src/**/* job can skip on that push.

The merge request can still contain src edits.
Those edits just landed in an earlier push.

Corrected model: skipped means not in this comparison.
It does not mean absent from the merge request.

Check both file lists yourself:

git fetch origin
git log --oneline origin/main..HEAD
git diff --name-only origin/main...HEAD
git diff --name-only HEAD~1 HEAD
Enter fullscreen mode Exit fullscreen mode

Those two diffs are not the same list.
One is the MR. One is the last push.

Primary docs live here, not in chat lore:
GitLab rules:changes.

Myth 2: MR pipelines share the branch-push baseline

Why would they?
The pipeline source just changed.

An MR pipeline can compare against the target branch.
A push pipeline can compare against HEAD~1.

Same YAML. Different left side.
Same glob. Different skip.

Look at this unexecuted example:

# example YAML — not a live pipeline
test_unit:
  stage: test
  script:
    - npm test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - src/**/*
        - package.json
    - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
      when: never
    - if: '$CI_COMMIT_BRANCH'
      changes:
        - src/**/*
Enter fullscreen mode Exit fullscreen mode

Did you pin compare_to?
If not, GitLab picks a source-specific default.

You can make the baseline explicit:

# example YAML — verify against your GitLab docs
test_unit:
  stage: test
  script:
    - npm test
  rules:
    - changes:
        paths:
          - src/**/*
          - package.json
        compare_to: 'main'
Enter fullscreen mode Exit fullscreen mode

Still not magic.
compare_to needs a ref the clone can see.

Forks, shallow clones, and deleted branches bite here.
Default-branch renames bite too.

Corrected model: pipeline source selects the baseline.
YAML reuse does not freeze that baseline.

Confirm it from pipeline type and job log.
Do not confirm it from memory.

Myth 3: Skipped tests mean the change is safe

Safe for whom?
Safe against which failure mode?

A path filter is not a test plan.
It is a cost cut.

You skip jobs to save minutes.
You also skip evidence.

allow_failure is a cousin of this myth.
A warning job is not a green suite.

when: on_success plus a skip is quieter.
The DAG just omits a node.

Ask three questions before you merge:

  1. Which jobs actually ran?
  2. Which jobs skipped, and against which diff?
  3. Which jobs never entered the pipeline at all?

Those are three different states.
The UI folds them together in your head.

I keep this decision table next to the MR:

Observation What it proves What it does not prove
Job skipped on rules:changes Paths missed this comparison Paths never changed on the branch
Job not created (workflow:rules) Pipeline rules excluded it The suite is green
Job green after retry A later attempt passed The first attempt was valid
Job green with allow_failure The pipeline may still pass Failures were absent
JUnit artifact missing No report was uploaded Zero tests failed

Paste the table in the MR if you must.
Then paste git diff --name-only under it.

No table, no merge argument.
A skipped badge is not a receipt.

Myth 4: A parsed glob list is a reviewed filter

This one arrived with generated YAML.
The file parses. The globs look neat. Ship it?

Parsing is not review.
A glob that never matches is a silent skip.

src/**/* does not match Src/.
It does not match backend/src unless you said so.

*.md does not always match docs/guide.md.
GitLab path matching is its own matcher.

I draft YAML in a scratch session sometimes.
I still treat the model as a typist.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option.
I use that pair to draft checklists, not to run GitLab.

The free server is not your runner.
It does not see protected variables or environments.

Ask the model for a glob critique.
Then run the helper below on the real diff.

If the helper and GitLab disagree, trust GitLab.
Then fix the YAML, not the hallway story.

Artifact: a local rules:changes rehearsal

This is a rehearsal, not CI.
Label it that way in your notes.

Save this as scripts/rehearse-changes.sh.

#!/usr/bin/env bash
# rehearse-changes.sh
# Local rehearsal for GitLab rules:changes discussions.
# This does not evaluate GitLab glob syntax 1:1.

set -euo pipefail

BASE_REF="${1:-origin/main}"
SHIFT_REF="${2:-HEAD}"

if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  echo "Run this inside a git checkout."
  exit 1
fi

echo "## Baseline"
echo "compare_to-style range: ${BASE_REF}...${SHIFT_REF}"
echo "previous-commit range:  ${SHIFT_REF}~1 ${SHIFT_REF}"
echo

echo "## Files in the merge-request-style diff"
git fetch origin --quiet || true
git diff --name-only "${BASE_REF}...${SHIFT_REF}" || {
  echo "Could not diff ${BASE_REF}...${SHIFT_REF}"
  exit 1
}

echo
echo "## Files in the last-push-style diff"
if git rev-parse "${SHIFT_REF}~1" >/dev/null 2>&1; then
  git diff --name-only "${SHIFT_REF}~1" "${SHIFT_REF}"
else
  echo "No parent commit. First commit on this ref."
fi
Enter fullscreen mode Exit fullscreen mode

Run it like this:

chmod +x scripts/rehearse-changes.sh
./scripts/rehearse-changes.sh origin/main HEAD
Enter fullscreen mode Exit fullscreen mode

Now add a tiny matcher for paths you care about.

# example only — GNU grep, unexecuted until you run it
echo
echo "## Paths matching src/ or package.json"
git diff --name-only origin/main...HEAD \
  | grep -E '^(src/|package\.json$)' || true
Enter fullscreen mode Exit fullscreen mode

That grep is not GitLab.
It is a flashlight.

If the flashlight shows src/ files, stop celebrating.
A skipped test_unit job now needs a baseline explanation.

Optional YAML lint stays local too:

# example — only if you already install these tools
yamllint .gitlab-ci.yml
git diff origin/main -- .gitlab-ci.yml
Enter fullscreen mode Exit fullscreen mode

No, yamllint cannot expand rules:changes.
It only tells you the file is YAML.

A checklist I actually paste

Copy this into the MR when a job skips:

  1. Pipeline type: branch, MR, tag, or detached?
  2. Baseline: HEAD~1, target branch, or compare_to?
  3. File list: paste git diff --name-only.
  4. Glob list: paste the changes: paths.
  5. Hidden jobs: did workflow:rules drop the pipeline?
  6. Related jobs: did needs point at a skipped job?

Six answers. Then you can talk about merging.
Zero answers. Then you are guessing.

I also paste the two ranges in the comment.
origin/main...HEAD and HEAD~1..HEAD must both appear.

If they disagree, pin compare_to before arguing.
If they agree, the glob itself is the suspect.

Where a free draft session still helps

I let a free model rewrite checklist tone.
I let it propose extra globs I forgot.

I do not let it certify the pipeline.
Certification lives in GitLab's job log.

Keep the YAML and the diff side by side.
Keep secrets off any scratch host.

Ask for missing paths, not merge permission.
Permission is still a human job.

If you try that workflow, one ask is enough:
paste compare_to next to every changes: block.

Limitations

This rehearsal does not implement GitLab's matcher.
Negation patterns and some wildcards can diverge.

Shallow clones can hide compare_to history.
Mirrors can lag the default branch.

Monorepos with generated files will flood the diff.
Path filters then become a product decision.

I did not publish timings here.
I did not claim a pass rate.

Those would be fake precision.
Your runner graph is the only scoreboard.

Re-read the current GitLab YAML reference before copying snippets.
Syntax around paths and compare_to has shifted across releases.

Who should not use this approach

Do not use path filters as a security boundary.
A renamed file can dodge a glob.

Do not use a free scratch host as a GitLab runner.
It will not reproduce protected-branch semantics.

Do not skip integration jobs on *.md only rules.
Docs changes still break links and OpenAPI examples.

Do not merge because generated YAML looks idiomatic.
Idiomatic YAML still skips the wrong job.

If you need a required test, mark it required.
Then stop filtering it on globs.

Teams without merge-request pipelines should not copy MR advice.
Your baseline is the previous push until you pin one.

What I want you to remember

rules:changes answers one question.
Did this comparison contain these paths?

It does not answer "is this MR safe?"
It does not answer "did tests run on the full branch?"

When a job skips, open the two diffs.
When the diffs disagree, pin compare_to.

When a model drafts the globs, keep the rehearsal script.
When GitLab still surprises you, believe the pipeline type first.

Top comments (0)