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

GitLab CI/CD workflows (MR, branches, releases)

By updated

Improve this page

GitLab logo

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:rules to 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:

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.3 tag?
  • 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.

EventPipeline created if the rules accept itCI_PIPELINE_SOURCE
git push to a branchYespush
Opening an MRYes, with a merge_request_event rulemerge_request_event
Updating a branch with an open MRTwo pipelinespush AND merge_request_event
Creating a tagYespush
ScheduleYesschedule

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_BRANCH

But 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_TAG

This pipeline will be created only for:

  1. Merge request events
  2. Pipelines of the default branch (main), whatever their source
  3. 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 push
lint:
script: npm run lint
test:
script: npm test

When 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_TAG

The 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.

ActionPipeline?
Push to feature/xxx (no MR)No
Opening MR feature/xxx → mainYes (source: merge_request_event)
Push to feature/xxx with an open MRYes (source: merge_request_event)
Merge into mainYes (source: push on default branch)
Tag v1.0.0Yes (source: push tag)
Schedule on mainYes (source: schedule, default branch)
Schedule on release/1.xNo, 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_TAG

Logic:

  1. If it is an MR event → MR pipeline created
  2. If it is a branch pipeline and an MR is open → no pipeline (the MR takes care of it)
  3. If it is a branch pipeline without an MR → branch pipeline created
  4. 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: production

Complete 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.

  1. MR-A is in the train: GitLab tests main + MR-A
  2. MR-B arrives: GitLab tests main + MR-A + MR-B
  3. 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_TAG

Review 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: manual

Key points:

  • auto_stop_in: 1 week destroys the environment automatically after 1 week
  • The review/$CI_MERGE_REQUEST_IID name creates a unique environment per MR
  • on_stop triggers 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 twice
test:
script: npm test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
# ✅ workflow:rules filters upstream
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG

2. 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 code
deploy:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# ✅ Deploys only after the merge
deploy:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

3. 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 mixed
test:
only:
- branches
rules:
- if: $CI_COMMIT_TAG

Use 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 branch
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ✅ Also allow schedules, the interface, the API and triggers
workflow:
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.

SymptomLikely causeSolution
Two pipelines at the same timeNo workflow:rules, or rules that accept both branch and MRAdd the recommended MR workflow
Pipeline missing on an MRworkflow:rules does not include merge_request_eventAdd the MR rule
No pipeline, empty Pipelines tabNo workflow: rule matches the source or the branchAdd the rule for the source concerned (schedule, web, api)
Unwanted pipeline on a feature branchworkflow:rules accepts all pushesAdd a $CI_OPEN_MERGE_REQUESTS check
Merge blocked "pipeline required"The MR pipeline is missing or failingCheck that the workflow does create an MR pipeline
Merge train that does not startFeature not enabled in the settingsEnable in Settings > Merge requests
MR variables not available in a push pipeline$CI_MERGE_REQUEST_IID only exists in MR pipelinesUse 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:rules is 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_BRANCH applies 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_REQUESTS for 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 main always 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

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