GitLab CI/CD `rules:` Debugging Prompt
Diagnose why a GitLab job did or didn't run — decode `rules:` evaluation, `only/except` legacy syntax, workflow rules, and complex `$CI_*` variable conditions.
- Target user
- DevOps engineers debugging job triggering in GitLab pipelines
- Difficulty
- Intermediate
- Tools
- Claude, ChatGPT
The prompt
You are a senior DevOps engineer with deep experience reading GitLab `rules:` blocks. You know that GitLab evaluates rules top-to-bottom and the FIRST match wins — and that this is the single most common source of "why didn't my job run?" confusion.
I will provide:
- The job(s) in question and whether they "should have run" or "shouldn't have run"
- The full `.gitlab-ci.yml` `workflow:` block (if present)
- The job's `rules:` (or `only:`/`except:`)
- The pipeline source (push, merge_request_event, schedule, web, api, trigger, parent_pipeline)
- The branch name, commit SHA, MR ID (if any)
- The values of any `$CI_*` variables the rules reference
- The actual pipeline outcome: pipeline created? job present? job skipped? job manual? job allow_failure?
Your job:
1. **Resolve the pipeline source** first. `rules:` interact with `$CI_PIPELINE_SOURCE`, which can be:
- `push` (default branch push, regular branch push)
- `merge_request_event` (only when MR pipelines are enabled and an MR exists)
- `schedule` (pipeline schedules)
- `web` (manually run from UI)
- `api`, `trigger`, `pipeline` (parent), `chat`, `external_pull_request_event`
- **Push pipelines run by default; MR pipelines only run when configured.** Many "why two pipelines?" questions are detached MR + branch pipelines.
2. **Evaluate workflow rules** (if any):
- `workflow:rules:` decide whether the WHOLE pipeline runs
- If no `workflow:rules:` rule matches AND there's a `workflow:`, the pipeline is rejected entirely
3. **For each job's `rules:` block**, evaluate top-to-bottom:
- First match wins. Subsequent rules are ignored.
- A rule with `if:` matching but no `when:` defaults to `when: on_success`
- `when: never` rule that matches skips the job
- `when: manual` rule that matches adds the job but as manual
- If no rule matches, the job is **excluded from the pipeline entirely**
4. **Common rule traps to surface**:
- **`rules: -if: '$CI_PIPELINE_SOURCE == "merge_request_event"' -if: '$CI_COMMIT_BRANCH'`** runs BOTH on MR AND on branch pushes — duplicate pipelines, common confusion
- **`only:` and `rules:` in the same job** → `rules:` wins; `only:` ignored
- **`changes:`** without a parent rule context → applies to the rule only it's nested under
- **`exists:`** evaluates against the FIRST matching commit's tree, may differ from working dir
- **Variable expansion in `if:`** uses `$VAR` notation; comparisons need quoting (`'$VAR == "foo"'`)
- **`$CI_COMMIT_BRANCH` is empty on tag pipelines AND on MR pipelines** — use `$CI_COMMIT_REF_NAME` if you need both
- **MR pipelines need `merge_request_event` source** — branch-name rules like `if: $CI_COMMIT_BRANCH == "main"` never match on MR pipelines
5. **For `workflow:rules:` debugging**:
- Default behavior (no workflow) creates pipelines on every push and on MR events if `Pipelines for merge requests` is enabled in project settings
- A `workflow:rules:` block REPLACES the default; you need to explicitly allow what you want
- Common pattern: prevent duplicate detached/branch pipelines:
```yaml
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
```
6. **Distinguish "job didn't run" from "job ran but no work happened"**:
- Not in pipeline at all: rules excluded it
- In pipeline as "skipped": rule matched with `when: never`
- In pipeline as "manual": rule matched with `when: manual`
- Ran, succeeded with no output: script issue, not rules
7. **Provide the FIRST rule that matches** in your output — explain why it matched and why subsequent rules don't apply.
Be exact about which `$CI_*` variable matters; many users mistype them.
---
Question: [should-have-run / should-NOT-have-run]
Pipeline context:
- Source: [push / merge_request_event / schedule / web / api / trigger]
- Branch / Tag / MR: [DESCRIBE]
- Project: [DESCRIBE]
Job(s) involved: [name]
`workflow:rules:` block (if present):
```yaml
[PASTE]
```
Job's `rules:` (or `only:`/`except:`):
```yaml
[PASTE]
```
Relevant `$CI_*` variable values at evaluation time:
```
[PASTE]
```
Pipeline outcome (UI screenshot text):
[DESCRIBE]
Why this prompt works
rules: evaluation is deterministic but unintuitive — first match wins, falling-off-the-end excludes the job, and the variables available change based on pipeline source. Most “my job didn’t run” debugging conversations boil down to: which rule matched first, and why. This prompt forces an explicit walkthrough.
How to use it
- Always include the pipeline source. A rule that works on push may not work on MR.
- Capture the
$CI_*variable values from the actual pipeline run. GitLab UI: pipeline → job → “Variables” tab. - Include
workflow:rules:even if you don’t think it’s relevant. If workflow rules reject the pipeline, no job rules matter. - For MR pipelines specifically: confirm “Pipelines for merge requests” is enabled in the project’s CI/CD settings.
Useful commands
# Get the variables for a pipeline run (admin or maintainer)
curl -s --header "PRIVATE-TOKEN: <token>" \
"https://gitlab.example.com/api/v4/projects/<id>/pipelines/<pid>/variables" | jq
# Get pipeline details (source, ref, etc.)
curl -s --header "PRIVATE-TOKEN: <token>" \
"https://gitlab.example.com/api/v4/projects/<id>/pipelines/<pid>" | jq
# Get all jobs in a pipeline (see what was included/excluded)
curl -s --header "PRIVATE-TOKEN: <token>" \
"https://gitlab.example.com/api/v4/projects/<id>/pipelines/<pid>/jobs" | \
jq -r '.[] | "\(.status)\t\(.name)\t\(.stage)"' | column -t
# Lint the yaml (locally)
# In project: CI/CD → Pipeline editor → "Validate" tab
curl -s --header "PRIVATE-TOKEN: <token>" --header "Content-Type: application/json" \
--data "{\"content\": $(cat .gitlab-ci.yml | jq -Rs)}" \
"https://gitlab.example.com/api/v4/ci/lint" | jq
# Get the merged YAML (after `include:` resolution) for debugging
# Project → CI/CD → Pipeline editor → "Full configuration" tab
Predefined variable cheatsheet (most-misused)
| Variable | Set on | Empty on |
|---|---|---|
$CI_COMMIT_BRANCH | Branch pipelines | MR pipelines, tag pipelines |
$CI_MERGE_REQUEST_IID | MR pipelines | Branch/tag pipelines |
$CI_COMMIT_TAG | Tag pipelines | Branch pipelines, MR pipelines |
$CI_COMMIT_REF_NAME | All — branch or tag name | Detached pipelines (rare) |
$CI_PIPELINE_SOURCE | All | Never (always set) |
$CI_DEFAULT_BRANCH | All — name of default branch | Never |
$CI_OPEN_MERGE_REQUESTS | When branch has open MR(s) | When branch has no open MR |
Common rule patterns
Run on default branch only
deploy-prod:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Run on MR only
test:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Run on MR OR on default branch — but never both
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never # skip branch pipeline if MR exists
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Run only on tag pipelines, with manual approval
release:
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
Run only when frontend changes
build-frontend:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- frontend/**/*
- package.json
Skip on docs-only commits
workflow:
rules:
- if: $CI_COMMIT_MESSAGE =~ /\[skip ci\]/i
when: never
- when: always
Common findings this catches
- Job has
only: mainANDrules:→rules:wins;only:is silently ignored. Removeonly:. rules: - if: $CI_COMMIT_BRANCH == "main"but pipeline is from MR event →$CI_COMMIT_BRANCHis empty on MR pipelines. Use$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main".workflow:rules:excludes branch pipelines, then someone wonders why pushing to a branch doesn’t run anything → workflow rules need explicit allow for branches.- Two pipelines per push when MR is open → standard duplicate-pipeline situation. Apply the workflow rule above.
rules:changes:on first commit of a branch → first commit “changes” everything; rule always matches. Usecompare_to:to compare against default branch.when: manualrule matches but pipeline shows the job as auto-running → there’s an earlier rule (above in the list) that matched first with defaultwhen: on_success. First match wins.
Migration: only/except → rules:
# OLD
only:
- main
- tags
except:
- schedules
# NEW
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
When to escalate
- Behavior that contradicts the rules-as-written → likely a GitLab version-specific change; check release notes (or your GitLab version’s CHANGELOG).
- “Pipelines for merge requests” toggle disabled at project level — administrative; rules referencing
merge_request_eventwon’t ever match. - Custom workflow with includes from a remote template — confirm the merged YAML, not just your local file.
Related prompts
-
GitLab CI/CD Variables Debugging Prompt
Diagnose why a GitLab CI/CD variable is missing, masked oddly, expanded wrong, or scoped to the wrong environment — protected, masked, file-type, inheritance, environment scope.
-
GitLab CI/CD Debugging Prompt
Diagnose failing GitLab CI/CD pipelines from job logs, .gitlab-ci.yml, and runner configuration.
-
GitLab Merge Request Pipeline Debug Prompt
Diagnose MR pipeline issues — pipeline not running, duplicate detached + branch pipelines, merge train failures, missing MR variables, ref:`merge` vs `head` SHA.