DEV Community

Jordan Huang
Jordan Huang

Posted on

The CI Lint Passed. GitLab Still Opened Two Pipelines.

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"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Decide which git events deserve a pipeline.
  2. Write that policy in workflow:rules.
  3. Decide which jobs belong in each pipeline.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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.
  • web and api triggers 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Push a feature branch with no MR. Count pipelines.
  2. Open the MR. Count pipelines again.
  3. Push a second commit. Count pipelines again.
  4. Read each pipeline source. Not the job name.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)