GitLab CI workflow:rules: Stop the Duplicate Detached Pipeline Bug
Two pipelines on every push, double the runner minutes, confusing MR status. Here's how workflow:rules kills duplicate detached pipelines for good — verified, not guessed.
- #gitlab-cicd
- #ai
- #workflow
- #rules
- #pipelines
You open a merge request, push a commit, and GitLab runs your pipeline twice. Same SHA, same jobs, two pipeline records sitting side by side in the MR widget — one labeled “detached” and one a plain branch pipeline. Your runner minutes just doubled, the MR shows an ambiguous status, and someone on the team is about to ask why CI is “flaky” when it isn’t. It’s not flaky. You’ve hit the single most common GitLab CI footgun: duplicate pipelines, and the fix lives almost entirely in one top-level block called workflow:rules.
I’ve debugged this on enough projects that I now treat workflow as a required part of every .gitlab-ci.yml, not an optional nicety. Here’s what’s actually happening and how to make it stop.
Why you get two pipelines
GitLab can start a pipeline from several sources. The two that collide are:
- Branch pipelines — triggered when you
git pushto a branch. Source ispush. - Merge request pipelines (the “detached” ones) — triggered when a commit lands on a branch that has an open MR, if any job opts into MR pipelines. Source is
merge_request_event.
If even one job in your config has a rule like if: '$CI_PIPELINE_SOURCE == "merge_request_event"' or uses the older only: [merge_requests], GitLab enables MR pipelines for the project. From then on, a push to a branch with an open MR satisfies both triggers, and you get one branch pipeline plus one detached MR pipeline for the same commit. Nobody asked for two. GitLab just doesn’t know which one you wanted, so it runs both.
The “detached” label, by the way, means the MR pipeline ran against your source branch HEAD as-is, not against a merge result. That’s fine — it’s a normal MR pipeline. The problem is purely that there are two.
The canonical fix
workflow:rules is evaluated once, before any jobs, and decides whether the whole pipeline runs. The standard pattern tells GitLab: if this is a branch push and an MR is open for that branch, don’t run the branch pipeline — let the MR pipeline handle it.
workflow:
rules:
# An MR is open for this branch: run the MR pipeline, skip the branch one
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# A branch push that already has an open MR: don't run a duplicate
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
when: never
# Tags always run
- if: '$CI_COMMIT_TAG'
# Default branch and branches without an open MR
- if: '$CI_COMMIT_BRANCH'
The key line is the when: never rule. $CI_OPEN_MERGE_REQUESTS is populated only when there’s an open MR for the current branch, so that rule short-circuits the redundant branch pipeline. The MR pipeline (first rule) still runs and reports status into the MR. One commit, one pipeline.
GitLab ships a maintained version of this as a template named Workflows/MergeRequest-Pipelines. Including it is the lowest-effort path:
include:
- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
I usually start from the template, then inline it once I need project-specific tweaks like a scheduled-pipeline branch or a release-tag rule. Inlining keeps the logic visible in code review instead of hidden behind an include.
Make AI decode your current config — then verify it yourself
When you inherit a pipeline that’s misbehaving, the fastest diagnosis is to have an AI read your whole .gitlab-ci.yml and tell you why duplicates happen for your specific rules — but you check its reasoning against the run, never the other way around. A prompt I reach for:
Here is my
.gitlab-ci.yml. For a push to a feature branch that has an open merge request, list every pipeline GitLab will create and the trigger source for each. Identify whichrules:/only:clauses cause merge request pipelines to be enabled project-wide. Then propose a top-levelworkflow:rulesblock that runs exactly one pipeline per commit, preserving tag and default-branch behavior. Explain each rule.
A solid answer reads back something like this:
Your
testandlintjobs userules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"', which enables MR pipelines for the whole project. On a feature-branch push with an open MR you’ll get apushbranch pipeline and amerge_request_eventdetached pipeline. Add a top-levelworkflow:ruleswith awhen: neverrule guarded by$CI_OPEN_MERGE_REQUESTSto suppress the redundant branch pipeline.
Treat that as a hypothesis, not a verdict. Open a throwaway MR, push a commit, and read CI/CD → Pipelines filtered by your branch. Count the pipeline records and check the Source column. If you still see two, the AI missed a rule — usually a job still using legacy only: [merge_requests], or a workflow block that lets the push source through. The pipeline page is ground truth; the model’s explanation is just a faster way to find where to look.
Don’t let workflow:rules eat your scheduled and tag pipelines
The most common regression after adding workflow is accidentally blocking pipelines you actually want. Because workflow:rules gates the entire pipeline, an over-tight rule set means scheduled runs or tag pipelines silently never start — and silent is the dangerous word here. Cover every source explicitly:
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"' # nightly / cron
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
when: never
- if: '$CI_COMMIT_TAG' # releases
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' # main
- if: '$CI_COMMIT_BRANCH' # everything else
If a source isn’t matched by any rule and there’s no default when, the pipeline doesn’t run. So when “the nightly build stopped firing,” your first suspect is a workflow block that forgot the schedule source. I’ve watched a team chase a broken cron job for two days when the real cause was a single missing workflow rule.
You can also use workflow: to set pipeline-level metadata, like a readable name, which makes the Pipelines list far easier to scan when you do have legitimately different pipeline types:
workflow:
name: '$CI_PIPELINE_SOURCE pipeline for $CI_COMMIT_REF_NAME'
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
when: never
- if: '$CI_COMMIT_BRANCH'
workflow:rules vs. job-level rules
A point of confusion worth nailing down: workflow:rules decides whether the pipeline exists; job-level rules: decide which jobs run inside it. They are not interchangeable. Putting your duplicate-pipeline guard in a job’s rules: does nothing — the second pipeline still gets created, it just might run fewer jobs. The suppression has to happen at the workflow level because that’s the only place GitLab evaluates before deciding to create a pipeline at all.
So the mental model is two gates in sequence: workflow:rules is the bouncer at the door, job rules: are the room assignments once you’re inside. Fix duplicates at the door.
A checklist that keeps it fixed
- Every
.gitlab-ci.ymlgets a top-levelworkflow:rulesblock, even small ones. - Include the
when: never+$CI_OPEN_MERGE_REQUESTSrule to kill duplicate branch pipelines. - Explicitly list
scheduleand$CI_COMMIT_TAGrules so cron and release pipelines survive. - After any change, push to a branch with an open MR and count pipeline records in the UI — don’t trust the YAML alone.
- Migrate any lingering
only:/except:jobs torules:; mixing the two styles is where surprise MR-pipeline activation hides.
Duplicate pipelines feel like a GitLab bug the first time you see them, but they’re really just GitLab honoring two triggers you didn’t realize you’d both enabled. workflow:rules is how you tell it which one you meant. Let AI decode a gnarly inherited config and draft the block — then prove it with a real push, because the Pipelines page is the only thing that doesn’t lie.
For more on getting these conditions right, see the GitLab CI/CD guides, the companion rules:if predefined-variables cookbook, and the AI prompt library for ready-to-use pipeline-debugging prompts.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.