DevOps & CI/CD Bug Report

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.

Ryan Pham Ryan Pham @ryan_bugs · February 18, 2026 · 6 min read · 7 Views
Banner: GitLab CI starting duplicate branch and merge request pipelines for each push

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 source values of push and merge_request_event, generated by GitLab itself.
  • Duplicate include files. 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_BRANCH is set.
  • A merge request pipeline: CI_PIPELINE_SOURCE == "merge_request_event", CI_MERGE_REQUEST_IID is set, CI_COMMIT_BRANCH is 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:rules block 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:rules explaining why the CI_OPEN_MERGE_REQUESTS line 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 overrides workflow entirely.

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:rules once you use merge request conditions anywhere.
  • Use CI_OPEN_MERGE_REQUESTS to 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

Add a label

#gitlab-ci #pipelines #merge-requests #ci-cost #rules
Written by
Ryan Pham @ryan_bugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.