Why did one push start two GitLab pipelines?
The CI Lint result said the YAML was valid.
Valid YAML is not a pipeline event policy.
I am talking about one push, two pipelines.
One source is push. One source is merge_request_event.
They race. They waste runner minutes. They split the green check.
A chat log will not settle this.
A sandbox shell will not settle this either.
Read the pipeline source column. That is the fact.
What duplicate actually means
Open the merge request. Open the Pipelines tab.
Do you see two rows for the same SHA?
Then you do not have a mysterious GitLab bug.
You have two event policies living in one file.
needs: does not choose pipeline sources.
Job graphs do not choose pipeline sources.
workflow:rules does.
Myth: CI Lint proves you will not double-run
CI Lint checks YAML syntax.
It does not replay a git push.
It does not open a merge request.
It does not set CI_OPEN_MERGE_REQUESTS.
So a green lint badge answers one question only.
Can GitLab parse this file?
It does not answer the question you care about.
Will this event create a pipeline at all?
If you need proof, stay in the GitLab UI.
Read the source column, not the job name.
# Label: local reminder. This is not GitLab.
echo "lint != workflow:rules"
echo "trust the pipeline source column"
Myth: Job rules can replace workflow:rules
Job rules: pick jobs inside a pipeline.
workflow:rules decide if GitLab creates one.
Those layers are not interchangeable.
Why do people still invert them?
Here is the failure mode I still see.
Every job requires merge_request_event.
A branch push still creates a pipeline.
The pipeline contains no jobs. The empty row remains.
People then paste the YAML into a chat.
The chat adds more job rules:.
The empty branch pipeline still appears.
You edited the wrong layer.
Corrected order:
- Decide which git events deserve a pipeline.
- Write that policy in
workflow:rules. - Decide which jobs belong in each pipeline.
- Write that policy in job
rules:.
Do not invert those four steps.
Myth: only: merge_requests is the modern fix
I still see only: and except: in copied snippets.
Those keywords are the old selector.
rules: is the selector you should maintain now.
Mixing only: and rules: on one job is invalid.
Includes make this worse.
A template file may still speak only:.
Your jobs may already speak rules:.
GitLab then evaluates two dialects.
You wanted one policy.
You shipped two grammars.
If you own the file, migrate the job to rules:.
Keep workflow:rules at the top of the entry file.
Do not ask a chat to bolt only: onto a rules file.
Myth: A scratch host that ran the script proved the workflow
People copy script: onto a scratch host.
The script exits zero. They merge.
Which CI_PIPELINE_SOURCE did that host export?
None.
You ran a shell. You did not evaluate workflow:rules.
You did not mint GitLab predefined variables.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
A free MonkeyCode model can review YAML shape.
The free server option can run the classifier below.
That is a draft aid, not a GitLab runner.
It cannot mint CI_PIPELINE_SOURCE.
It cannot see CI_OPEN_MERGE_REQUESTS from GitLab.
Remove the product and the rule stays.
Local zero is not a pipeline source.
Myth: $CI_OPEN_MERGE_REQUESTS makes duplicates impossible
This variable is useful.
It is not a mutex.
It is not instant.
A common pattern:
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
What this tries to say:
- MR events get a pipeline.
- Branch pushes skip when an MR already exists.
- Other branches can still pipeline.
- Tags can still pipeline.
What it does not say:
- The first push before the MR exists is skipped.
- Fork MRs receive protected secrets.
- Scheduled pipelines are allowed.
-
webandapitriggers are allowed.
Push the branch. Open the MR two seconds later.
You can still get both pipelines.
GitLab evaluated the branch push first.
The MR did not exist yet.
That is timing, not a YAML typo.
Also add the sources you actually use:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "api"
Drop those lines and scheduled pipelines vanish.
That surprise is common.
Did you mean to kill nightlies?
Artifact: event decision table
Fill this table before you edit YAML.
It is a checklist, not a benchmark.
| Event you care about | Typical source | Open MR on this branch? | Create a pipeline? | Notes |
|---|---|---|---|---|
| Push to feature branch, no MR | push |
no | your call | First push cannot see a future MR |
| Push to feature branch, MR open | push |
yes | usually no | This is the duplicate you hate |
| Open or update the MR | merge_request_event |
yes | yes | Reviewers look here |
| Push to default branch | push |
sometimes | yes | Keep post-merge CI |
| Git tag |
push, with CI_COMMIT_TAG
|
n/a | yes if you ship tags | Match on $CI_COMMIT_TAG
|
| Pipeline schedule | schedule |
n/a | only if listed | Easy to drop by accident |
| Run pipeline button | web |
n/a | only if listed | Manual runs need an explicit rule |
| Child pipeline | parent_pipeline |
n/a | separate policy | Do not reuse MR-only workflow blindly |
Write the "Create a pipeline?" column for your repo.
Then write workflow:rules that match the column.
Do not write job rules: first.
Artifact: a minimal policy you can paste
Label: example YAML.
Verify it in your project.
I am not claiming a timed production test.
# .gitlab-ci.yml — example workflow policy
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
stages: [lint, test]
lint:
stage: lint
script:
- echo "lint $CI_PIPELINE_SOURCE on $CI_COMMIT_REF_NAME"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
test:
stage: test
script:
- echo "test $CI_PIPELINE_SOURCE"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Notice the split.
Workflow still allows some feature-branch pipelines.
Jobs then skip those pipelines.
That combo can still create an empty pipeline.
Align the two layers, or drop pre-MR branch pipelines.
Stricter variant if you do not want pre-MR CI:
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"
Pick one policy.
Do not mix both in the same file.
Artifact: a source classifier you can run anywhere
This script does not talk to GitLab.
It maps a story to a source string.
Use it in review. Then confirm the GitLab UI.
#!/usr/bin/env python3
"""Proposed helper. Not GitLab's evaluator."""
def classify(event):
kind = event["kind"]
if kind == "merge_request":
return "merge_request_event"
if kind == "schedule":
return "schedule"
if kind == "web":
return "web"
if kind == "api":
return "api"
if kind in ("tag", "branch_push"):
return "push"
raise ValueError(f"unknown kind: {kind}")
def want_pipeline(source, branch, default_branch, is_tag):
"""Mirrors the stricter example, not the pre-MR example."""
if source == "merge_request_event":
return True
if source in ("schedule", "web"):
return True
if is_tag:
return True
if source == "push" and branch == default_branch:
return True
return False
def demo():
cases = [
{"kind": "branch_push", "branch": "feat/x", "default": "main", "is_tag": False},
{"kind": "merge_request", "branch": "feat/x", "default": "main", "is_tag": False},
{"kind": "branch_push", "branch": "main", "default": "main", "is_tag": False},
{"kind": "schedule", "branch": "main", "default": "main", "is_tag": False},
]
for raw in cases:
source = classify(raw)
create = want_pipeline(source, raw["branch"], raw["default"], raw["is_tag"])
print(f"{raw['kind']:16} source={source:22} create={create}")
if __name__ == "__main__":
demo()
Expected teaching output:
branch_push source=push create=False
merge_request source=merge_request_event create=True
branch_push source=push create=True
schedule source=schedule create=True
If your GitLab UI disagrees, trust the UI.
Fix the YAML.
Do not rewrite the script to match a chat.
How I verify in GitLab, not in chat
Use this path on a throwaway branch:
- Push a feature branch with no MR. Count pipelines.
- Open the MR. Count pipelines again.
- Push a second commit. Count pipelines again.
- Read each pipeline source. Not the job name.
- Merge, then confirm the default branch still pipelines.
git switch -c faq/workflow-rules
# change a file, commit
git push -u origin faq/workflow-rules
# open the MR in the GitLab UI, then:
git commit --allow-empty -m "second push after MR exists"
git push
Then read the source column.
That column is the receipt.
Echo the same facts inside a job:
print-source:
script:
- echo "source=${CI_PIPELINE_SOURCE}"
- echo "branch=${CI_COMMIT_BRANCH}"
- echo "open_mrs=${CI_OPEN_MERGE_REQUESTS}"
- echo "tag=${CI_COMMIT_TAG}"
rules:
- when: always
when: always on a job does not override workflow:rules.
If workflow blocked the pipeline, this job never runs.
That is the point.
Limitations
This FAQ does not cover parent and child pipelines in depth.
It does not cover merge trains.
It does not cover forked MRs and secret restrictions.
It does not claim CI_OPEN_MERGE_REQUESTS is instant.
The Python classifier is a teaching stub.
It is not GitLab CE or EE.
It will drift when your policy drifts.
Do not paste masked variables into any model.
Do not paste tokens. Do not paste .env files.
Review YAML structure only.
Who should not use this approach
Do not use the stricter workflow if you need pre-MR branch CI.
Open an MR first, or keep the extra push rule.
Do not use a scratch host as proof of workflow:rules.
The host has no GitLab event.
Do not mix only: templates with rules: jobs.
Convert one dialect.
Do not skip default-branch pipelines.
You will lose post-merge CI.
If a compliance pipeline is attached at group level, your local file may not be the only workflow.
Check project and group policies too.
Corrected mental model
Ask one question first.
Which events deserve a pipeline?
Write that answer in workflow:rules.
Then write job rules:.
Then push a branch, open an MR, push again.
Read the source column.
CI Lint is a parser.
A chat is a parser with guesses.
A free shell is a shell.
GitLab is the only evaluator that matters here.
MonkeyCode provides free models that can run this workflow.
Top comments (0)