Mapping GitLab CI rules to branch and path changes Jump to heading

GitLab decides which jobs run through rules: — ordered conditions on variables, changed files and pipeline source. Getting them right turns a monorepo pipeline from “everything, every time” into “what this change touched”. Getting them wrong produces familiar problems: two pipelines for every push to a merge request branch, a changes: rule that runs every job on a new branch because there is nothing to compare against, or a required job that is skipped and leaves the merge request blocked. This page maps the common intents — per-branch jobs, per-path jobs, merge-request versus branch pipelines — to rules that behave predictably, as the GitLab side of CI/CD pipeline trigger mapping.

When to use this approach Jump to heading

  • Your repositories are on GitLab and pipelines run more jobs than a change needs.
  • You see duplicate pipelines — one branch pipeline and one merge request pipeline — for every push.
  • changes: rules behave unexpectedly on new branches or tags.
  • You are porting path filters from another CI system, such as those in optimizing CI triggers for path-specific changes.

Step 1 — Decide when pipelines exist at all with workflow rules Jump to heading

workflow: rules: decide whether a pipeline is created. The most common setup creates merge request pipelines for branches with an open merge request, branch pipelines otherwise, and avoids both for the same push.

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never                         # branch has an MR: skip the duplicate branch pipeline
    - if: $CI_COMMIT_BRANCH
    - if: $CI_COMMIT_TAG
Which pipeline a push createsA push to a branch with an open merge request creates only a merge request pipeline. A push to a branch without one creates a branch pipeline. A tag push creates a tag pipeline. With these workflow rules, no push creates two pipelines.What was pushed?branch with open MRMR pipelinebranch pipeline skippedbranch, no MRBranch pipelinenormal CItagTag pipelinerelease jobsthe second rule is the one that removes duplicate pipelines
# Verification: one push to an MR branch shows one pipeline, of source merge_request_event
glab ci list --per-page 3

Step 2 — Run jobs per branch Jump to heading

Job-level rules are evaluated in order; the first match decides. Put the specific cases first and end with an explicit default.

deploy-staging:
  script: ./deploy.sh staging
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - when: never

publish:
  script: ./publish.sh
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
    - when: never

The explicit when: never at the end documents that the job runs nowhere else. Without it, a job with rules runs only when a rule matches anyway, but readers have to know that.

Step 3 — Run jobs per path with changes Jump to heading

changes: makes a rule match only when listed paths changed. What “changed” is compared against depends on the pipeline type, and that is where surprises come from.

What changes: compares againstIn merge request pipelines, changes compares the merge request's commits with its target branch, which is what you want. In branch pipelines it compares with the previous push, and on a brand-new branch or a tag there is no previous push, so every changes rule evaluates as true.Compared againstPitfallmerge request pipelinetarget branchnonebranch pipelineprevious pushnew branch: all truetag pipelinenothingalways truewith compare_tonamed refexplicit and stablecompare_to makes branch pipelines behave like merge request pipelines
test-billing:
  script: make -C services/billing test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        paths: [services/billing/**/*, libs/money/**/*]
    - if: $CI_COMMIT_BRANCH
      changes:
        paths: [services/billing/**/*, libs/money/**/*]
        compare_to: refs/heads/main          # stable comparison even on new branches

Include shared dependencies in the path list — libs/money/ above — or a change to a library will not run the tests of the services that use it. For monorepos, generating these lists from a dependency graph is covered in detecting affected projects from git diff.

Step 4 — Keep required jobs from being skipped Jump to heading

If merge requests require a successful pipeline and the only jobs are path-filtered, a change touching none of the paths produces a pipeline with no jobs — or none at all — and the merge request cannot merge. Add a lightweight job that always runs, so every pipeline has a result.

pipeline-ok:
  stage: .post
  script: echo "pipeline completed"
  rules:
    - when: always
A path-filtered pipeline that still reportsWorkflow rules create one pipeline per push. Job rules select per-branch and per-path jobs, comparing against main so new branches behave. An always-run final job guarantees every pipeline has a result, so merge requests that touch unfiltered paths can still merge.workflow: rulesone pipeline per pushbranch rulesdeploy, publishchanges + compare_toper-path testspipeline-okalways runsthe last job is cheap and prevents a whole class of stuck merge requests

The same problem exists on other forges as skipped required checks, discussed in required checks for path-filtered workflows.

Step 5 — Test rules before relying on them Jump to heading

Rules are easy to get subtly wrong. GitLab’s pipeline editor can simulate a pipeline for a chosen branch, and the CI lint API validates syntax. Use both on a branch before merging rule changes.

glab ci lint .gitlab-ci.yml
# Simulate the pipeline for a branch (dry run)
glab api -X POST "projects/$PROJECT_ID/ci/lint" -f content="$(cat .gitlab-ci.yml)" \
  -F dry_run=true -f ref=feature/billing-rounding | jq '.jobs[].name'

Step 6 — Reuse rule sets instead of repeating them Jump to heading

Large pipelines repeat the same conditions across dozens of jobs, and copies drift. Define each rule set once with a YAML anchor or !reference, and attach it to every job that needs it.

.rules-billing:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes: { paths: [services/billing/**/*, libs/money/**/*] }
    - if: $CI_COMMIT_BRANCH
      changes: { paths: [services/billing/**/*, libs/money/**/*], compare_to: refs/heads/main }

test-billing:
  script: make -C services/billing test
  rules: !reference [.rules-billing, rules]

lint-billing:
  script: make -C services/billing lint
  rules: !reference [.rules-billing, rules]

When the path list changes — a new shared library, a moved directory — one edit updates every job. Pair this with a periodic check that every top-level service directory appears in at least one rule set, so a newly added service does not silently get no CI at all.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should I still use only/except? Jump to heading

No. only and except are older syntax and cannot be mixed with rules in the same job. rules are more expressive and are what GitLab documents going forward.

Why did a merge request pipeline run every job? Jump to heading

Usually because the merge request’s commits really touch many paths, or because a broad pattern like **/*.yml matches a configuration file every change touches. Narrow the patterns, and list shared configuration explicitly where it matters.

How do merged results pipelines interact with changes? Jump to heading

Merged results pipelines still compare the merge request’s changes with the target branch, so changes: behaves the same. They test the merged state, which is what you want for catching interactions, as described in catching semantic conflicts in CI.