GitLab CI runs two pipelines on every push: branch and merge request pipelines
After we added merge request rules to a few jobs, every push to an open merge request started two pipelines, doubling runner usage. Job-level rules matched both the branch push and the merge request event. A workflow:rules block that picks one pipeline type fixed it.
Our runner bill and queue times jumped in the same week. The pipeline list for a busy repository showed pairs of pipelines for the same commit, seconds apart:
#91822 merge request !1407 a3f9c21 running
#91821 branch feat/checkout-totals a3f9c21 running
Both ran the full test suite, about 14 minutes of runner time each. Every push to a branch with an open merge request did this.
Reproducing it
The change that week: a developer added rules to the danger job so it would only run for merge requests, and copied a pattern to a few other jobs:
danger:
stage: lint
script: [bundle exec danger]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
test:
stage: test
script: [make test]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
Push a commit to a branch with an open merge request: two pipelines.
What we ruled out
- A webhook or integration triggering extra pipelines. Both pipelines had
sourcevalues ofpushandmerge_request_event, generated by GitLab itself. - Duplicate
includefiles. The merged configuration in the pipeline editor showed each job once.
Root cause
Before any job used rules with merge request conditions, GitLab created only branch pipelines. As soon as some job's rules could match merge_request_event, GitLab started creating merge request pipelines too. A single push to a branch with an open merge request then produced two pipeline creation events, one for the branch push and one for the merge request, and the test job's rules matched both. Without a workflow:rules block, nothing decided which of the two pipeline types should exist.
What is happening under the hood
GitLab evaluates pipeline creation for each source event. For a push to a branch with an open merge request, there are two candidate pipelines:
- A branch pipeline:
CI_PIPELINE_SOURCE == "push",CI_COMMIT_BRANCHis set. - A merge request pipeline:
CI_PIPELINE_SOURCE == "merge_request_event",CI_MERGE_REQUEST_IIDis set,CI_COMMIT_BRANCHis not.
A pipeline is created if at least one job would run in it. workflow:rules sits above job rules and decides whether a pipeline is created at all. The variable CI_OPEN_MERGE_REQUESTS is set in branch pipelines when the branch has open merge requests, which lets you skip the branch pipeline in exactly that case.
Environment
| Component | Version / value |
|---|---|
| GitLab | 18.x (self-managed) |
| Runners | Kubernetes executor, autoscaled |
| Repository | Monorepo, ~70 jobs per pipeline |
| Pipeline time | ~14 runner-minutes per full pipeline |
Minimal reproduction
job-a:
script: echo a
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
Open a merge request for a branch and push: two pipelines.
The fix
We added a workflow:rules block at the top of the shared CI template. It runs merge request pipelines for branches with open merge requests, branch pipelines for everything else, and never both:
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
Then we simplified job rules, since the workflow already decides the pipeline type:
danger:
stage: lint
script: [bundle exec danger]
rules:
- if: $CI_MERGE_REQUEST_IID # only meaningful in MR pipelines
test:
stage: test
script: [make test] # runs in whichever pipeline exists
For deploy jobs that must only run on the default branch, we kept explicit rules:
deploy-production:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
Alternatives and trade-offs
- Only merge request pipelines, no branch pipelines at all. Simpler, but branches without a merge request get no CI, and pushes to the default branch need their own rule.
- Only branch pipelines. Avoids duplicates, but loses merge request features such as merged results pipelines and MR-specific variables.
- Merged results pipelines. Test the result of merging into the target branch, which catches integration problems earlier. They are merge request pipelines, so the workflow block above supports them.
How to test the fix
The pipeline editor's "Validate" tab can simulate a pipeline for a given branch and source. We also tested for real in a sandbox project:
- Push to a branch with no MR: one branch pipeline.
- Open an MR and push: one merge request pipeline.
- Merge to the default branch: one branch pipeline with the deploy job.
- Push a tag: one tag pipeline.
Confirming in production
- Pipelines per push dropped from 2 to 1 for branches with open merge requests, which was about 70 percent of our pushes.
- Weekly runner minutes for the repository fell by 41 percent, and median queue time dropped from 6 minutes to under 1.
Regression guard
- The
workflow:rulesblock lives in the shared template included by every project, with code owners. - A scheduled script uses the GitLab API to count pipelines per commit over the last day and reports any commit with more than one non-retried pipeline.
- Project templates include a short comment above
workflow:rulesexplaining why theCI_OPEN_MERGE_REQUESTSline exists, because the most common way this regresses is someone deleting a line that looks redundant. New projects created from the template inherit both the rule and the explanation, and the platform team reviews any project that overridesworkflowentirely.
Security implications
Merge request pipelines from forks run in the fork's context by default, without the parent project's protected variables. When changing pipeline types, make sure jobs that need secrets do not start running in contexts where they should not, and keep protected variables limited to protected branches and tags. Our change did not alter which jobs could see secrets, which we verified by listing protected variables per job in a test pipeline.
Prevention checklist
- Always define
workflow:rulesonce you use merge request conditions anywhere. - Use
CI_OPEN_MERGE_REQUESTSto skip branch pipelines when an MR exists. - Keep job rules about job conditions, not pipeline types.
- Monitor pipelines per commit as a cost signal.
- Keep secret-bearing jobs on protected branches and tags.
References
- GitLab, workflow:rules — https://docs.gitlab.com/ci/yaml/workflow/
- Avoid duplicate pipelines — https://docs.gitlab.com/ci/jobs/job_rules/#avoid-duplicate-pipelines
- Predefined CI/CD variables — https://docs.gitlab.com/ci/variables/predefined_variables/
- Merge request pipelines — https://docs.gitlab.com/ci/pipelines/merge_request_pipelines/
Add a label
Embed this post
<iframe src="https://hibug.me/gitlab-ci-duplicate-branch-and-mr-pipelines" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.