
You push a branch, open a merge request, and two pipelines run in parallel? It is the most common problem when starting with GitLab CI/CD. The workflow:rules keyword lets you decide at the global level which type of pipeline to create depending on the context: a push to a branch, the opening of an MR or the creation of a tag.
Without a structured workflow, you waste CI minutes, pollute the pipeline history and lose readability. This guide gives you the proven patterns for each situation.
What you will learn
- Understand why GitLab sometimes creates two pipelines (branch + MR)
- Use
workflow:rulesto control pipeline creation at the global level - Implement the three standard workflows: branch-only, MR-only, hybrid
- Configure a release workflow with semantic tags
- Enable merge trains to serialize integrations
- Avoid the classic anti-patterns (duplicates, deploying from an MR)
Prerequisites
This lesson assumes you master rules: at the job level: if: conditions,
the when: clause and reading a predefined variable such as
$CI_COMMIT_BRANCH. workflow:rules uses exactly the same syntax one level
higher, and without it the global filter makes no sense. It also assumes you
know that a pipeline is born from an event (push, merge request, tag,
schedule) and that $CI_PIPELINE_SOURCE tells you which one, as well as what a
merge request (MR) is.
On the rights, versions and tooling side, the page cites three constraints.
Merged results and merge trains require a Premium edition and are
set in the project settings (Settings > Merge requests), without the page
stating the role needed. The release-cli tool is given there as deprecated
since GitLab 18.0. The release job relies on glab, supplied by an image
in the pipeline: nothing to install locally. The rest (the workflows,
$CI_OPEN_MERGE_REQUESTS, review apps) is written in the .gitlab-ci.yml.
Upstream lessons:
- Execution conditions (rules):
if:,when:and the first introduction toworkflow:rules - Pipeline triggers:
pipeline sources and
$CI_PIPELINE_SOURCE - Variables and secrets: the predefined variables that rules test
- Environments:
the
environmentkeyword, which review apps rely on
In which context?
You have a working pipeline but the same questions keep coming up:
- Why do two pipelines run when I open an MR?
- How do I run a pipeline only on merge requests?
- How do I trigger a deployment only when I create a
v1.2.3tag? - How do I stop the pipeline from running on useless feature branches?
This guide answers all these questions with concrete patterns: each workflow is presented with its YAML, its effect on the pipelines created and the case where it fits.
The duplicate problem
Before fixing it, you need to understand where the duplicate comes from. GitLab does not
think in terms of "one branch, one pipeline" but in terms of events: each action on
the repository can trigger its own pipeline, with its own value of
CI_PIPELINE_SOURCE. Two simultaneous events accepted by your rules therefore mechanically give two pipelines, not one pipeline duplicated by mistake. The pipeline sources
(push, MR, tag, schedule, API) are presented in
GitLab CI/CD pipeline triggers;
this page deals with what you do with them at the scale of a team.
Why two pipelines?
GitLab creates a pipeline for each event that your rules accept. A job
without rules: runs only in branch and tag pipelines, never in MR
pipelines: the duplicate appears as soon as the jobs' rules:, or a
workflow:rules, accept both branches and MRs.
| Event | Pipeline created if the rules accept it | CI_PIPELINE_SOURCE |
|---|---|---|
git push to a branch | Yes | push |
| Opening an MR | Yes, with a merge_request_event rule | merge_request_event |
| Updating a branch with an open MR | Two pipelines | push AND merge_request_event |
| Creating a tag | Yes | push |
| Schedule | Yes | schedule |
The trap: when you push to a branch that has an open MR, and your rules accept both the branch and the MR, GitLab creates two pipelines, one for the push, one for the MR. The jobs that accept both sources then run in both.
CI_OPEN_MERGE_REQUESTS
GitLab provides the CI_OPEN_MERGE_REQUESTS variable, which contains the list of open MRs for the current branch. It lets you avoid duplicates in a job's rules::
job: rules: # Do not run on pushes that already have an open MR - if: $CI_PIPELINE_SOURCE == "push" && $CI_OPEN_MERGE_REQUESTS when: never - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHBut this job-by-job approach is fragile: you must repeat the condition in every job and the slightest omission brings the duplicate back. The robust solution: workflow:rules.
workflow:rules, the global gatekeeper
workflow:rules decides whether the pipeline is created or not, even before evaluating each job's rules. It is the first filter, evaluated from top to bottom: the first rule that matches wins.
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAGThis pipeline will be created only for:
- Merge request events
- Pipelines of the default branch (main), whatever their source
- Tags
Everything else is ignored: a push to a feature branch with no MR, but also a schedule, a "Run pipeline" from the interface or an API call that targets a branch other than the default branch.
The three standard workflows
Almost every project fits one of the three models below. They differ by a single criterion: which events create a pipeline. The first accepts every push, the second keeps only merge requests and the default branch, the third combines both by explicitly excluding the case that would produce a duplicate. Choose according to how your team works, not according to the complexity of the configuration.
1. Branch pipeline (the simplest)
Each push creates a pipeline. No notion of MR.
workflow: rules: - if: $CI_COMMIT_BRANCH - if: $CI_COMMIT_TAG
# All jobs run on each pushlint: script: npm run lint
test: script: npm testWhen to use it: personal projects, small teams without a review process.
Drawback: no dedicated MR pipeline, no visual review in the MR.
2. MR pipeline (the most common in teams)
The pipeline runs only on merge requests and the default branch.
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAGThe table below shows what this workflow creates for each action. The rule
on the default branch applies to the branch, not to the source: a
schedule that targets main goes through, one that targets release/1.x is
discarded.
| Action | Pipeline? |
|---|---|
Push to feature/xxx (no MR) | No |
Opening MR feature/xxx → main | Yes (source: merge_request_event) |
Push to feature/xxx with an open MR | Yes (source: merge_request_event) |
Merge into main | Yes (source: push on default branch) |
Tag v1.0.0 | Yes (source: push tag) |
Schedule on main | Yes (source: schedule, default branch) |
Schedule on release/1.x | No, no rule matches |
When to use it: the majority of team projects. No duplicates, pipeline visible in the MR.
3. Hybrid workflow (branch + MR)
A variant for those who want a pipeline even on branches without an MR (for quick feedback):
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS when: never - if: $CI_COMMIT_BRANCH - if: $CI_COMMIT_TAGLogic:
- If it is an MR event → MR pipeline created
- If it is a branch pipeline and an MR is open → no pipeline (the MR takes care of it)
- If it is a branch pipeline without an MR → branch pipeline created
- If it is a tag → pipeline created
This pattern gives a pipeline from the very first push (quick feedback) then switches to the MR pipeline as soon as an MR is opened. No duplicates.
Release workflow with tags
Once duplicates are sorted out, there remains the question of deployment. The tag is
the safest trigger: unlike a branch, it designates a precise commit
that does not move with every push, which makes the deployed version
traceable (protect version tags so that nobody can move them). GitLab exposes the
tag in the $CI_COMMIT_TAG variable, absent from every other type of
pipeline.
Semantic versioning
The convention retained here: production deployments are triggered by
tags in the vX.Y.Z format. The regular expression /^v\d+\.\d+\.\d+$/ is
deliberately strict, anchored at the start and at the end, so that a working tag
like v2-rc1 does not go to production. when: manual adds a human approval, and resource_group: production prevents two deployments from
running at the same time on the same environment.
deploy:production: stage: deploy environment: name: production url: https://www.example.com script: - echo "Deploying $CI_COMMIT_TAG" - ./deploy.sh production rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ when: manual allow_failure: false resource_group: productionComplete pipeline with a release
The example below puts everything together: the global filter, then per-job rules: that
restrict further. Note the progression, it is deliberate. test
runs everywhere, build only on the default branch and tags,
deploy:production only on a version tag. A job with no matching rule simply does not appear in the pipeline.
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG =~ /^v\d+/
stages: - test - build - deploy - release
test: stage: test script: npm test rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG
build: stage: build script: npm run build artifacts: paths: [dist/] rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG
deploy:staging: stage: deploy environment: name: staging script: ./deploy.sh staging rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy:production: stage: deploy environment: name: production script: ./deploy.sh production rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ when: manual resource_group: production
create:release: stage: release image: registry.gitlab.com/gitlab-org/cli:v1.124.0@sha256:f6837b5aea075ca297281b554556d2fc4f8fb291ace6b48286790790dce622cd script: - echo "Creating release for $CI_COMMIT_TAG" release: tag_name: $CI_COMMIT_TAG name: "Release $CI_COMMIT_TAG" description: "Automatic release" rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/The release: keyword creates the GitLab release at the end of the job. It requires the glab tool, GitLab's command-line client, to be present in the job's $PATH: with the Docker executor, that is the role of the registry.gitlab.com/gitlab-org/cli image, pinned here by tag and by digest.
Merged results and merge trains
These two features answer the same blind spot: an MR pipeline validates the
code as it is today, while the merge will happen on a target branch
that may have moved in the meantime. They stack, with merge trains building on merged results. Both require a Premium edition
and are set in the project settings, not in the
.gitlab-ci.yml file.
Pipeline for merged results
By default, the MR pipeline tests the code of your branch. But at merge time, the target branch may have moved on. The merged results pipeline tests the anticipated result of the merge. To enable it, open Settings > Merge requests > Merge pipelines, then enable Pipelines must succeed and Merged results pipelines.
Merge trains
Merge trains go further: when several MRs are ready to merge, GitLab serializes them and tests each MR including the previous ones. To enable them, open Settings > Merge requests > Merge trains.
- MR-A is in the train: GitLab tests
main + MR-A - MR-B arrives: GitLab tests
main + MR-A + MR-B - MR-A passes and merges: MR-B is tested again if necessary
Advantage: each merge is tested with everything that precedes it, which greatly reduces the risk of breaking main.
Drawback: slower, each MR waits its turn. Reserved for critical branches.
The workflow: reuses the MR workflow, with one more rule for the train pipelines:
workflow: rules: - if: $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train" - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAGReview apps
Review apps create a temporary environment per merge request, which
lets a reviewer click through the feature instead of guessing it
from a diff. The mechanism relies entirely on the environment keyword: a dynamic name built on $CI_MERGE_REQUEST_IID creates a distinct environment
per MR, and GitLab displays the link directly in the merge
request interface. The risk with this model is the accumulation of abandoned environments, hence the two cleanup mechanisms present in the example:
on_stop and auto_stop_in.
review: stage: deploy environment: name: review/$CI_MERGE_REQUEST_IID url: https://$CI_MERGE_REQUEST_IID.review.example.com on_stop: stop:review auto_stop_in: 1 week script: - ./deploy-review.sh "$CI_MERGE_REQUEST_IID" rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop:review: stage: deploy environment: name: review/$CI_MERGE_REQUEST_IID action: stop script: - ./destroy-review.sh "$CI_MERGE_REQUEST_IID" when: manual rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" when: manualKey points:
auto_stop_in: 1 weekdestroys the environment automatically after 1 week- The
review/$CI_MERGE_REQUEST_IIDname creates a unique environment per MR on_stoptriggers the cleanup when the MR is closed
For the basics of environments, see Environments.
Anti-patterns to avoid
These four mistakes keep showing up in the .gitlab-ci.yml files of experienced teams. None of them causes a syntax error: the pipeline starts,
but it does something other than what was intended. Each case shows the faulty version then its fix.
1. Duplicate branch + MR pipeline
When a job's rules: accept both the branch and the MR, and no
workflow:rules settles the matter, GitLab creates a pipeline for each
event. Filtering job by job forces you to repeat the condition everywhere, at
the risk of forgetting one. The filter must be placed at the global level.
# ❌ The rules accept the branch AND the MR: the job runs twicetest: script: npm test rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH# ✅ workflow:rules filters upstreamworkflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG2. Deploying from an MR
The code of a merge request has not yet been reviewed or merged, and it may come from a fork. Deploying from this pipeline amounts to putting code online that nobody has validated. Reserve deployment for the default branch or a tag.
# ❌ Dangerous: deploys unmerged codedeploy: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"# ✅ Deploys only after the mergedeploy: rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH3. only/except mixed with rules
only/except and rules are two competing syntaxes, inherited from two generations of GitLab CI. Mixing them in the same job produces a file validation error, not a degraded behavior: the pipeline does not start.
# ❌ only/except and rules cannot be mixedtest: only: - branches rules: - if: $CI_COMMIT_TAGUse only rules: (recommended since GitLab 12.3).
4. A workflow:rules that is too restrictive
The opposite trap from the first one. A workflow:rules that lists only MRs and the
default branch silently discards every branch pipeline whose branch is not
the default branch: the schedule (schedule) of a release/1.x branch, the
"Run pipeline" button (web) on a working branch, an API call or a trigger
token. No error message: the pipeline is not created, and the nightly
backup of that branch no longer runs.
# ❌ Discards schedules, web, API and triggers outside the default branchworkflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH# ✅ Also allow schedules, the interface, the API and triggersworkflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG - if: $CI_PIPELINE_SOURCE == "schedule" - if: $CI_PIPELINE_SOURCE == "web" - if: $CI_PIPELINE_SOURCE == "api" - if: $CI_PIPELINE_SOURCE == "trigger"Troubleshooting
Nearly all the symptoms below are diagnosed in the same place: the
value of $CI_PIPELINE_SOURCE in the pipeline concerned, compared with the
conditions of your workflow:rules. Display it in a job with
echo $CI_PIPELINE_SOURCE before changing anything, it avoids
fixing the wrong rule.
| Symptom | Likely cause | Solution |
|---|---|---|
| Two pipelines at the same time | No workflow:rules, or rules that accept both branch and MR | Add the recommended MR workflow |
| Pipeline missing on an MR | workflow:rules does not include merge_request_event | Add the MR rule |
| No pipeline, empty Pipelines tab | No workflow: rule matches the source or the branch | Add the rule for the source concerned (schedule, web, api) |
| Unwanted pipeline on a feature branch | workflow:rules accepts all pushes | Add a $CI_OPEN_MERGE_REQUESTS check |
| Merge blocked "pipeline required" | The MR pipeline is missing or failing | Check that the workflow does create an MR pipeline |
| Merge train that does not start | Feature not enabled in the settings | Enable in Settings > Merge requests |
| MR variables not available in a push pipeline | $CI_MERGE_REQUEST_IID only exists in MR pipelines | Use the right CI_PIPELINE_SOURCE |
Hands-on lab
The lab "One change, one pipeline" replays this guide on a GitLab CE forge of your own, set up on a virtual machine with its runner and its registry, rather than on gitlab.com: no minute quota, no identity verification, and a project you can break as often as you need.
A branch carrying an open merge request runs two pipelines for the same
commit: the machine does everything twice, and the merge request shows two
results without saying which one counts. You fix the workflow: so that the
MR pipeline is the one that stays, relying on the sections
"workflow:rules, the global gatekeeper" and "3. Hybrid workflow (branch +
MR)". The grade is taken by counting the pipelines of the branch's last
commit: there must be only one, with the source merge_request_event.
Two points of order, which the lab puts to the test. First, GitLab evaluates
the rules from top to bottom: the first rule that matches wins, so a
when: never rule placed before the merge_request_event rule also closes
the MR pipeline as soon as its condition is true for it. In the hybrid
workflow, the MR rule comes first for this reason. Second, the MR must be
open before the push you measure: opened afterwards, $CI_OPEN_MERGE_REQUESTS
is still empty when GitLab evaluates the rules, and the branch pipeline is
created anyway. The lab therefore has you push a branch, open the MR in the
interface, then push a new commit.
The lab starts with dsoxlab run gl-17-un-seul-pipeline-par-changement, after
dsoxlab provision prepares the virtual machine, its runner and its
registry. Installing DSOXLab and its prerequisites are described in
the DSOXLab documentation.
Key takeaways
workflow:rulesis the first filter, it decides whether the pipeline is created or not- When no rule matches, no pipeline is created: the Pipelines tab stays empty, with no message
- A rule on
$CI_COMMIT_BRANCHapplies to the branch, not to the source: it also lets through a schedule or an API call on that branch - The MR workflow (merge_request_event + default branch + tags) is recommended for teams
- Without a workflow, rules that accept both branch and MR create duplicates (a push pipeline + an MR pipeline)
- Use
$CI_OPEN_MERGE_REQUESTSfor the hybrid pattern (branch then MR) - Merged results test the anticipated result of the merge, not the branch alone
- Merge trains serialize MRs to keep
mainalways green - Never deploy from an MR pipeline, only after a merge or on a tag
- Review apps create a temporary environment per MR with automatic cleanup
Test your knowledge
Ten questions to check that the notions of this guide are mastered: origin of duplicates, rule evaluation order and choice of workflow according to the context.
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 reliability
A new full quiz with random questions
Work again only on the questions you missed