DEV Community

Jordan Huang
Jordan Huang

Posted on

FAQ: The CI Lint API Never Saw Your Merge Request

The CI Lint button went green. Did GitLab create those jobs?
I still watch people merge on that screenshot alone.

That button never saw your merge request.
It saw a YAML string, maybe one branch ref.

This FAQ kills five claims I keep hearing.
Each myth gets evidence and a corrected model.

What this FAQ is not

This is not the scratch-box-as-CI argument.
I already spent that bullet on other posts.

This one is lint, includes, and rules evaluation.
Authoring confidence is the bug here.

Myth 1: valid: true means those jobs exist

That claim does not survive a real pipeline.
Default lint only asks if GitLab accepts YAML.

You get errors, or you get valid.
You usually do not get the materialized job list.

# Example: parse-only lint. This is not pipeline creation.
curl --fail-with-body \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --header "Content-Type: application/json" \
  --data "{\"content\": $(jq -Rs . < .gitlab-ci.yml)}" \
  "$GITLAB_URL/api/v4/projects/$PROJECT_ID/ci/lint"
Enter fullscreen mode Exit fullscreen mode

Read valid and errors in the JSON.
If job names are missing, you tested a parser.

Myth 2: dry_run=true equals a merge request pipeline

Dry-run is better, and it still is not an MR.
GitLab only simulates creation for one git ref.

A branch ref is not merge_request_event.
Predefined variables will not match your MR.

# Example: dry-run on main. MR rules may still diverge.
curl --fail-with-body \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --header "Content-Type: application/json" \
  --data "{\"content\": $(jq -Rs . < .gitlab-ci.yml), \"dry_run\": true, \"ref\": \"main\", \"include_jobs\": true}" \
  "$GITLAB_URL/api/v4/projects/$PROJECT_ID/ci/lint"
Enter fullscreen mode Exit fullscreen mode

Would $CI_PIPELINE_SOURCE be merge_request_event here?
If your rules:if keys on that, this dry-run misleads.

Would $CI_MERGE_REQUEST_TARGET_BRANCH_NAME even exist?
On a branch dry-run it usually does not.

Check those variables before you cite the result.
Otherwise you are reviewing a different pipeline type.

Myth 3: Laptop includes match GitLab includes

Your editor buffer is not GitLab's composer.
GitLab fetches includes when it creates the pipeline.

include:local still needs the rest of the repository.
include:project still needs read permission at creation.

include:component hits your catalog configuration.
A pasted leaf file never proves that catalog pin.

# Example: one file lints. GitLab still composes the rest.
include:
  - local: ci/base.yml
  - project: org/ci-templates
    file: /jobs/security.yml
    ref: v2
  - component: $CI_SERVER_FQDN/org/components/sast@1.4.0

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Enter fullscreen mode Exit fullscreen mode

Did you lint only the leaf file today?
Then you never proved ci/base.yml or the component.

Paste merged_yaml from a dry-run when arguing includes.
A screenshot of one tab is not composition proof.

Myth 4: rules:changes follows the files I edited

GitLab chooses the comparison range, not your editor.
Your buffer is not the merge request diff.

New branches often diff against the default branch.
Some pipeline types treat listed paths as always changed.

# Example: the glob is real. The diff base is GitLab's.
test:
  stage: test
  script:
    - pytest -q
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        - src/**/*
        - tests/**/*
        - pyproject.toml
Enter fullscreen mode Exit fullscreen mode

Did a chat session touch src/ this morning?
GitLab will not read that session. GitLab reads git.

So "I changed tests" is only a story.
The merge request diff is the record. Use that.

Myth 5: A passing script proves the GitLab job

A job is image, services, tags, and variables too.
The shell snippet is the cheapest piece of that job.

Your shell can be green. GitLab can skip the job.
Or it pulls another image. Or no runner matches tags.

# Example: script is the cheap part of the job.
integration:
  image: python:3.12-slim
  tags:
    - gpu-small
  services:
    - name: postgres:16
      alias: db
  variables:
    DATABASE_URL: "postgres://ci@db:5432/app"
  script:
    - pip install -q -r requirements.txt
    - pytest tests/integration -q
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Enter fullscreen mode Exit fullscreen mode

You ran pytest on a spare host. What is proven?
The test file. Not tags. Not image. Not rules.

Protected variables never leave protected refs.
A scratch host will not magically receive them.

The corrected mental model

Name three layers in every merge request.
Do not let them collapse into "CI looked fine."

  1. Parse — GitLab accepts this YAML text.
  2. Create — GitLab would materialize these jobs for this ref.
  3. Execute — a matching runner ran that exact job.

Lint is parse. Dry-run is a weak create.
Only a project pipeline covers create plus execute.

I paste that list into the MR template.
Reviewers then ask which layer you actually ran.

Artifact: a parse-versus-create gate

This helper talks to /ci/lint only.
Treat it as a proposal, not a runner.

Pin response fields on your own instance.
GitLab has renamed keys in that JSON before.

#!/usr/bin/env python3
"""ci_lint_gate.py — parse/create checklist. Not GitLab CI.

Unexecuted example. Export GITLAB_URL, PROJECT_ID, GITLAB_TOKEN.
Prints layers. Never prints "pipeline passed."
"""
from __future__ import annotations

import json
import os
import sys
import urllib.request

API = (
    os.environ["GITLAB_URL"].rstrip("/")
    + f"/api/v4/projects/{os.environ['PROJECT_ID']}/ci/lint"
)
TOKEN = os.environ["GITLAB_TOKEN"]
REF = os.environ.get("CI_LINT_REF", "main")
PATH = sys.argv[1] if len(sys.argv) > 1 else ".gitlab-ci.yml"


def lint(content: str, dry_run: bool) -> dict:
    payload = {
        "content": content,
        "dry_run": dry_run,
        "ref": REF,
        "include_jobs": True,
    }
    req = urllib.request.Request(
        API,
        data=json.dumps(payload).encode(),
        headers={
            "PRIVATE-TOKEN": TOKEN,
            "Content-Type": "application/json",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=60) as resp:
        return json.load(resp)


def main() -> int:
    content = open(PATH, encoding="utf-8").read()
    parse = lint(content, dry_run=False)
    create = lint(content, dry_run=True)
    jobs = create.get("jobs") or []
    print("LAYER parse valid:", parse.get("valid"))
    print("LAYER parse errors:", parse.get("errors") or [])
    print("LAYER create valid:", create.get("valid"))
    print("LAYER create warnings:", create.get("warnings") or [])
    print("LAYER create job count:", len(jobs) if isinstance(jobs, list) else "n/a")
    print("RAW create keys:", sorted(create.keys()))
    print("---")
    print("REF =", REF, "— not an MR unless you made it one.")
    print("Protected variables and runner tags remain unproven.")
    ok = bool(parse.get("valid")) and bool(create.get("valid"))
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Wire it like this on your machine.

export GITLAB_URL="https://gitlab.example.com"
export PROJECT_ID="123"
export CI_LINT_REF="main"
python3 ci_lint_gate.py .gitlab-ci.yml
Enter fullscreen mode Exit fullscreen mode

Copy the layer lines into the merge request.
Do not copy a lint-button screenshot as the whole story.

Decision table

Layer Surface Proves Does not prove
Parse /ci/lint with dry_run=false YAML accepted Job set, rules, include fetch
Create /ci/lint with dry_run=true + ref Jobs for that ref MR predefined vars, protected vars
Script smoke Same commands on a scratch host Shell behavior on that host image, services, runner tags
GitLab job Pipeline job on the project Merged config actually ran Next commit, prod, or policy

Missing a row? Shrink the claim immediately.
Do not promote a parse into an execute.

Where free model access actually helps

When I draft YAML with MonkeyCode, I keep it at parse.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Free model access is useful at the parse layer.
A free server option is useful for script smoke tests.

The model can suggest a rules:if I forgot.
The server can run the script I just wrote.

Neither one creates a GitLab pipeline.
If I cannot name the layer, I do not cite the run.

Draft YAML, then smoke the script, then discard that host.
The merge request still needs a job ID from GitLab.

Limitations

The /ci/lint payload follows your GitLab version.
Self-managed can lag SaaS. Read the raw keys.

Dry-run still misses live inputs you actually care about.
Protected variables. Environment stop jobs. Runner supply. Merge trains.

Private includes need a token that can read them.
A personal snippet lint will not fetch those files.

Treat the Python helper as unlabeled lab code.
It does not replace pipeline records in the UI.

Who should skip this

Skip lint-as-proof if auditors need pipeline IDs.
Curl output is not an evidence locker for them.

Skip a free server for protected-branch jobs.
Those jobs need protected variables and tagged runners.

Skip laptop composition when include:component pins a catalog.
Only your GitLab resolves that catalog as configured.

Using merge trains plus heavy workflow:rules?
Simulate on a real merge request. Not on main.

Review questions I now ask

I only need three answers in the merge request.

  1. Was this parse, create, or execute?
  2. Which ref and which CI_PIPELINE_SOURCE?
  3. What still needs a GitLab job ID?

If the answer is "lint was green," I bounce that MR.
Green lint is necessary. It is not sufficient.

Kill that myth in the template before review.
Do not kill it after production skipped test.

Top comments (0)