Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab CI/CD execution conditions (rules)

By updated

Improve this page

GitLab logo

Does your pipeline run every job on every commit, even when it is not needed? rules let you control precisely when a job runs, on which branch, for which type of change, with which approval.

This guide shows you how to optimize your pipelines by running only what is needed, and how to avoid the classic trap of the pipeline that is never created, for no apparent reason and with no error message.

This guide is for you if...

Read this guide through the section that describes your current situation: the first three deal with writing rules, the last one gives complete .gitlab-ci.yml files to adapt. To limit the execution of a job to main or to certain modified files, read the basic rules. For a manual approval or a deployment delayed by X minutes, see when. If no pipeline shows up after your push, with no error message, start with workflow:rules: the cause is almost always there and not in your jobs' rules. To optimize resources and avoid rebuilding when only the README changes, see the practical examples.

What you will learn

By the end of this guide, you will be able to decide when a job starts and when a pipeline exists, using the rules and workflow:rules keywords:

  • Write a rule with if, changes and exists, testing variables such as $CI_COMMIT_BRANCH or $CI_PIPELINE_SOURCE
  • Combine conditions: an AND inside a single rule, an OR with several rules
  • Choose the value of when (on_success, on_failure, always, manual, delayed, never) for a manual approval, a delayed deployment or an exclusion
  • Order your rules: the first one that matches wins, exclusions come first
  • Tell rules from workflow:rules to avoid duplicate pipelines and to diagnose a pipeline that is never created or a missing job
  • Migrate only/except to rules

Prerequisites

Before continuing, make sure you are comfortable with:

Why are rules essential?

Without rules, all jobs run on every pipeline. It is a waste of resources and time:

  • Full tests when only the README changed
  • Docker build when the code did not move
  • Automatic deployment with no human validation

Rules let you run only what is needed, when it is needed.

The basic rules: if, changes, exists

if: condition on a variable

The most used rule. It tests an expression that returns true or false.

job:
script: echo "Deployment"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'

Available operators:

OperatorMeaningExample
==Equal to$VAR == "value"
!=Not equal to$VAR != "skip"
=~Regex match$VAR =~ /^feature-/
!~Regex does not match$VAR !~ /wip/
&&Logical AND$A == "x" && $B == "y"
||Logical OR$A == "x" || $A == "y"

Variables most used in rules:

VariableValueUse case
$CI_COMMIT_BRANCHBranch nameLimit to main, develop
$CI_COMMIT_TAGTag name (if tag)Deploy releases only
$CI_PIPELINE_SOURCEPipeline sourcepush, merge_request_event, schedule
$CI_MERGE_REQUEST_IDMR IDMR-specific jobs
# Practical examples
rules:
# On the main branch only
- if: '$CI_COMMIT_BRANCH == "main"'
# On a release tag (v1.0.0, v2.3.4...)
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
# For merge requests only
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# Scheduled pipeline (cron)
- if: '$CI_PIPELINE_SOURCE == "schedule"'

changes: condition on modified files

Runs the job only if certain files were modified in the commit.

build_frontend:
script: npm run build
rules:
- changes:
- "frontend/**/*"
- "package.json"
- "package-lock.json"

Supported patterns:

PatternMeaning
src/*.js.js files in src/
src/**/*.js.js files in src/ and subfolders
*.md.md files at the root
**/*.mdAll .md files in the project

exists: condition on the existence of a file

Runs the job if a file exists in the repository.

docker_build:
script: docker build -t myapp .
rules:
- exists:
- Dockerfile

Useful for monorepos where not all projects have the same files.

Combining conditions

You can combine if, changes and exists in a single rule:

deploy_docs:
script: ./deploy-docs.sh
rules:
# On main AND if docs files changed
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- "docs/**/*"
- "mkdocs.yml"

Careful: within a rule, the conditions are combined with AND. For an OR, use several rules:

job:
rules:
# On main OR on a release tag
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG =~ /^v\d+/'

Controlling how jobs start with when

when defines how the job starts if the rule matches.

The values of when

Six values only, and the nuance that matters lies between never and the others. never excludes the job from the pipeline: it does not even appear in the interface. The other five include it and merely decide when it starts. Also remember that on_success is the default value: a job without an explicit when starts as soon as the previous stages have succeeded. In workflow:rules, only always and never are accepted.

ValueBehavior
on_successStarts automatically if the previous jobs succeeded (default)
on_failureStarts only if a previous job failed
alwaysAlways starts, whatever the result of the previous jobs
manualWaits for a click in the interface to start
delayedStarts automatically after a delay
neverNever starts (excludes the job)

Four common uses of when

The four snippets below cover most of what you will write day to day. Look closely at the last one: it shows the exclusion pair, a first when: never rule that rules out the branches concerned, then a final rule with no condition that catches everything else. Without this second rule, the job would run nowhere, because a job for which no rule matches is simply removed from the pipeline.

Manual deployment to production:

deploy_prod:
script: ./deploy.sh production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual

Notification on failure:

notify_failure:
script: ./send-slack-alert.sh
rules:
# Explicit condition + when: on_failure
- if: '$CI_PIPELINE_SOURCE'
when: on_failure

Delayed deployment (canary):

deploy_canary:
script: ./deploy.sh canary
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: delayed
start_in: 30 minutes

Explicit exclusion:

heavy_tests:
script: ./run-all-tests.sh
rules:
# Not on WIP branches
- if: '$CI_COMMIT_BRANCH =~ /^wip-/'
when: never
# Otherwise, run normally
- when: on_success

workflow:rules: controlling the whole pipeline

workflow:rules determines whether the pipeline itself must be created. It is different from job-level rules, which control each job individually.

Why does it matter?

Without workflow:rules, GitLab creates a pipeline for every push, even if no job is meant to run. This creates noise in the interface.

With workflow:rules, you can prevent the creation of the pipeline in certain cases.

Example: avoiding duplicate pipelines

workflow:
rules:
# No pipeline if it is a tag (we deploy through another workflow)
- if: '$CI_COMMIT_TAG'
when: never
# No pipeline if the branch has an open MR
# (the MR pipeline is enough)
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
when: never
# For merge requests
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# For pushes to branches
- if: '$CI_COMMIT_BRANCH'

The classic trap: the pipeline that "skips"

This is the most baffling symptom of GitLab CI, because it produces no error message: the file is valid, the lint passes, and yet nothing starts. The cause lies in an implicit rule that is easy to forget: a workflow:rules in which no entry matches prevents the creation of the pipeline. There is no default fallback behavior. Despite the word "skips" in the title, you will therefore not see a grey pipeline with the skipped status: no pipeline is created.

Symptom: you push code and the project's Pipelines tab stays empty, with no grey pipeline and no error message.

Likely cause: workflow:rules prevents the pipeline from being created.

Diagnosis:

  1. Check your workflow:rules section
  2. Identify which rule matches your current conditions
  3. If no rule matches, no pipeline is created
# ❌ Problem: this workflow never matches for plain pushes
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# Solution: add the other cases
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'

Three absences that look alike, three different causes

A pipeline "that does not run" actually covers three situations, which the interface lets you tell apart once you know what to look at. The only criterion is whether a pipeline row exists in Build > Pipelines: with no row, the blocker is in workflow:rules; with a row, it comes from a job or from the commit message. The guide Debug: pipeline skipped walks through the full diagnosis.

What does not matchWhat you see
No rule in workflow:rulesNo pipeline is created: the Pipelines tab stays empty, with no message
No rules: entry of a jobThe pipeline exists, but the job is not added: it does not appear, it is not greyed out
[skip ci] in the commit messageA visible, grey pipeline with the skipped status

Only the last case produces a skipped status, with zero jobs in the pipeline. The behavior of the first and that of the third are measured on GitLab 19.5.1 CE; the second is described by the GitLab documentation on job rules ("the job is not added").

Practical examples

The two files below are complete and can be copied as is into a .gitlab-ci.yml. They answer the most frequent request: not rerunning a build and a test suite when only the documentation moved.

Pipeline optimized by type of change

This file combines the three mechanisms seen above. The lint job deliberately has no rule: it runs everywhere, because it costs a few seconds. The build and test jobs are conditioned by changes on src/**/*, backed by an if on $CI_PIPELINE_SOURCE to neutralize the case where changes is always true. The deployment to production is the only one to carry when: manual: the pipeline stops there and waits for a click.

workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
stages:
- lint
- build
- test
- deploy
# Lint: always (fast, cheap)
lint:
stage: lint
script: npm run lint
# Build: only if the code changed
build:
stage: build
script: npm run build
rules:
- if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- "src/**/*"
- "package*.json"
# Tests: only if the code changed
test:
stage: test
script: npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- "src/**/*"
- "tests/**/*"
- "package*.json"
# Deploy staging: automatic on main
deploy_staging:
stage: deploy
script: ./deploy.sh staging
environment:
name: staging
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
# Deploy prod: manual on tags
deploy_prod:
stage: deploy
script: ./deploy.sh production
environment:
name: production
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+/'
when: manual

Avoiding redundant runs

When a merge request is open on a branch, each push creates two pipelines there: the branch one and the MR one. This workflow removes the duplicate thanks to $CI_OPEN_MERGE_REQUESTS, a variable that is only set if the current branch is the source of at least one open MR. The order is decisive: the when: never rule must come before the general $CI_COMMIT_BRANCH rule, otherwise the latter matches first and the duplicate comes back.

workflow:
rules:
# A tag triggers its own pipeline, no need for the push
- if: '$CI_COMMIT_TAG'
# For MRs, use the MR pipeline
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# For pushes to branches with no open MR
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
when: never
- if: '$CI_COMMIT_BRANCH'

Common mistakes

This table is read by symptom, the one you observe in the GitLab interface. The useful reflex is to first tell apart what is missing at the pipeline level from what is missing at the job level: in the first case no pipeline is created, in the second the pipeline exists but a job is missing from the roll call. This distinction points to workflow:rules or to the job's rules, and avoids modifying the wrong file.

SymptomLikely causeSolution
No pipeline created, Pipelines tab emptyNo workflow:rules entry matchesAdd a rule for your case
Job missing from a pipeline that existsNo rules: entry of the job matchesAdd a rule for your case, or a final rule with no condition
Job never runsOrder of the rules (never last)Put the exclusions first
Job always runsNo rule, default behaviorAdd explicit rules:
changes always trueSchedule or web pipelinechanges only works with push/MR
Undefined variables in rulesVariable does not existTest with $VAR (empty = false)

Debugging rules. Add a debug job to see the variables:

debug:
script:
- echo "BRANCH=$CI_COMMIT_BRANCH"
- echo "SOURCE=$CI_PIPELINE_SOURCE"
- echo "TAG=$CI_COMMIT_TAG"
- echo "MR=$CI_MERGE_REQUEST_ID"
rules:
- if: '$CI_PIPELINE_SOURCE'
when: always

Careful: never use printenv or env in production, as it can expose secrets in the logs.

Migrating from only/except

only and except are on GitLab's list of deprecated keywords and are no longer actively developed. Migrate your pipelines to rules, which expresses the same conditions with variable expressions rather than branch lists.

Do the switch job by job, all the way. GitLab advises against mixing only/except jobs and rules jobs in the same pipeline: the default behaviors differ, notably on merge request pipelines, and the result becomes hard to predict. A job that carried both syntaxes at once also causes a configuration error.

# ❌ Old style (deprecated)
deploy:
script: ./deploy.sh
only:
- main
except:
- schedules
# New style
deploy:
script: ./deploy.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
when: never
- if: '$CI_COMMIT_BRANCH == "main"'

Hands-on lab

The lab "Decide when the pipeline runs, and who deploys" is played on a GitLab CE forge of your own (a GitLab server you host yourself), set up on a virtual machine with its runner and its container registry (the server that stores Docker images): no minute quota, no identity verification, and a project you can break as often as you need. dsoxlab run prepares the starting project there.

Every job runs on every push, on every branch, and nothing is ever deployed. The lab has you decide when a pipeline is born, which jobs join it, and which deployment waits for a human. Grading is taken on the pipeline left on the forge, not on the rules you wrote: the server's evaluation decides, and it holds surprises when two rules overlap.

Two points of the lab go beyond the examples above. To name the default branch without hardcoding main, compare $CI_COMMIT_BRANCH with the predefined variable $CI_DEFAULT_BRANCH. And once the production job waits behind when: manual, the pipeline is no longer green: its status becomes manual, which is the intended state and not a failure.

The lab starts with dsoxlab run gl-06-controler-execution, after dsoxlab provision prepares the virtual machine, its runner and its registry. Installing DSOXLab (the command-line tool that runs this site's labs) and its prerequisites are described in the DSOXLab documentation. The sections above explain each rule: keep them at hand during the lab.

Key takeaways

  1. rules controls when a job runs, workflow:rules controls when the pipeline is created.
  2. First matching rule wins, order your rules from most specific to most general. If no rule matches, the job is not added to the pipeline.
  3. if tests variables, changes tests modified files, exists tests existence.
  4. when: never first to exclude, when: on_success last as the default.
  5. changes filters nothing on schedule and web pipelines, nor on a new branch: with no comparison base, GitLab considers all files modified and the condition is true.
  6. Empty Pipelines tab after a push = check workflow:rules first: with no matching rule, no pipeline is created. A grey pipeline with the skipped status points instead to a [skip ci] in the commit.

Test your knowledge

Ten questions drawn from the GitLab CI/CD bank, centered on the order of rule evaluation and on the boundary between rules and workflow:rules. Allow five minutes and aim for 70% to consider the topic mastered. A failure on the changes questions is the signal that you should reread the box on traps before tackling the lab.

Knowledge check

Check what you have learned with this interactive quiz

10 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn