
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,changesandexists, testing variables such as$CI_COMMIT_BRANCHor$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
rulesfromworkflow:rulesto avoid duplicate pipelines and to diagnose a pipeline that is never created or a missing job - Migrate
only/excepttorules
Prerequisites
Before continuing, make sure you are comfortable with:
- GitLab CI/CD variables (rules use variables)
- The basic structure of a
.gitlab-ci.yml
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:
| Operator | Meaning | Example |
|---|---|---|
== | 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:
| Variable | Value | Use case |
|---|---|---|
$CI_COMMIT_BRANCH | Branch name | Limit to main, develop |
$CI_COMMIT_TAG | Tag name (if tag) | Deploy releases only |
$CI_PIPELINE_SOURCE | Pipeline source | push, merge_request_event, schedule |
$CI_MERGE_REQUEST_ID | MR ID | MR-specific jobs |
# Practical examplesrules: # 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:
| Pattern | Meaning |
|---|---|
src/*.js | .js files in src/ |
src/**/*.js | .js files in src/ and subfolders |
*.md | .md files at the root |
**/*.md | All .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: - DockerfileUseful 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.
| Value | Behavior |
|---|---|
on_success | Starts automatically if the previous jobs succeeded (default) |
on_failure | Starts only if a previous job failed |
always | Always starts, whatever the result of the previous jobs |
manual | Waits for a click in the interface to start |
delayed | Starts automatically after a delay |
never | Never 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: manualNotification on failure:
notify_failure: script: ./send-slack-alert.sh rules: # Explicit condition + when: on_failure - if: '$CI_PIPELINE_SOURCE' when: on_failureDelayed deployment (canary):
deploy_canary: script: ./deploy.sh canary rules: - if: '$CI_COMMIT_BRANCH == "main"' when: delayed start_in: 30 minutesExplicit 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_successworkflow: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:
- Check your
workflow:rulessection - Identify which rule matches your current conditions
- If no rule matches, no pipeline is created
# ❌ Problem: this workflow never matches for plain pushesworkflow: rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# Solution: add the other casesworkflow: 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 match | What you see |
|---|---|
No rule in workflow:rules | No pipeline is created: the Pipelines tab stays empty, with no message |
No rules: entry of a job | The pipeline exists, but the job is not added: it does not appear, it is not greyed out |
[skip ci] in the commit message | A 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 changedbuild: 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 changedtest: 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 maindeploy_staging: stage: deploy script: ./deploy.sh staging environment: name: staging rules: - if: '$CI_COMMIT_BRANCH == "main"'
# Deploy prod: manual on tagsdeploy_prod: stage: deploy script: ./deploy.sh production environment: name: production rules: - if: '$CI_COMMIT_TAG =~ /^v\d+/' when: manualAvoiding 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.
| Symptom | Likely cause | Solution |
|---|---|---|
| No pipeline created, Pipelines tab empty | No workflow:rules entry matches | Add a rule for your case |
| Job missing from a pipeline that exists | No rules: entry of the job matches | Add a rule for your case, or a final rule with no condition |
| Job never runs | Order of the rules (never last) | Put the exclusions first |
| Job always runs | No rule, default behavior | Add explicit rules: |
changes always true | Schedule or web pipeline | changes only works with push/MR |
| Undefined variables in rules | Variable does not exist | Test 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: alwaysCareful: 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 styledeploy: 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
rulescontrols when a job runs,workflow:rulescontrols when the pipeline is created.- 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.
iftests variables,changestests modified files,existstests existence.when: neverfirst to exclude,when: on_successlast as the default.changesfilters nothing onscheduleandwebpipelines, nor on a new branch: with no comparison base, GitLab considers all files modified and the condition is true.- Empty Pipelines tab after a push = check
workflow:rulesfirst: with no matching rule, no pipeline is created. A grey pipeline with theskippedstatus 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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: GitLab CI/CD pipeline triggers
A new full quiz with random questions
Work again only on the questions you missed