๐Ÿ”€ GitLab CI/CD: Jobs Have Rules, Pipelines Have Rules Too

You push a commit, open a merge request, and suddenly GitLab is running two pipelines for what feels like the same change.

Congratulations: you’ve discovered a surprisingly common CI/CD configuration headache.

The problem might be the difference between rules and workflow:rules. They look similar, but they operate at different levels.

The discovery: One controls jobs, the other controls pipelines

GitLab evaluates workflow:rules when deciding whether to create a pipeline. Job-level rules determine whether an individual job is included in that pipeline.

Think of it this way:

  • workflow:rules: Should this pipeline exist?
  • rules: Should this job exist in the pipeline?

For example, this job runs in merge request pipelines:

unit_tests:
  script:
    - ./mvnw test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

But this rule alone doesn’t prevent GitLab from creating a separate branch pipeline after a push.

That’s where workflow:rules helps.

A practical fix: Avoid duplicate branch pipelines

Suppose you want branch pipelines before a merge request exists, and merge request pipelines once one is open.

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"'
      when: never
    - if: '$CI_COMMIT_BRANCH'

Here’s what happens:

  1. Merge request events are allowed.
  2. Push-triggered branch pipelines are suppressed when the branch already has an open merge request.
  3. Other branch pushes are allowed.

The CI_PIPELINE_SOURCE == "push" condition matters: it avoids unintentionally blocking other kinds of branch pipelines, such as triggered pipelines.

You can then use job-level rules to decide which tests, scans, or deployments belong in each pipeline.

See GitLab’s official workflow documentation.

The important nuance: Rules are evaluated in order

GitLab evaluates a job’s rules from top to bottom and stops at the first matching rule.

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

This includes the deployment job as a manual job on the default branch and excludes it elsewhere.

Also, when: manual doesn’t automatically mean the same failure behavior in every configuration: a manual job defined inside rules defaults to allow_failure: false, unlike a job-level manual declaration without rules.

Be explicit when pipeline behavior matters.

The takeaway โ˜•

When your pipeline runs at the wrong time, ask which decision is wrong: creating the pipeline or including a job?

Use workflow:rules for the first, job-level rules for the second, and check the first matching rule before adding another.

Sometimes the best pipeline optimization is simply not creating the extra pipeline in the first place.

References

Related articles