workflow:rules, Job rules, if Expressions, changes, exists, Pipeline Sources, and Conditional Execution: Concepts, Architecture, and Mental Model
A GitLab pipeline has two conditional-execution gates before a runner ever sees work: first GitLab decides whether a pipeline should exist, then it decides which compiled jobs belong in that pipeline. This lesson builds a precise mental model for workflow:rules, job rules, pipeline sources, refs, changed files, repository existence checks, expressions, and the evidence needed to explain an omitted or unexpectedly included job.
Learning objectives
- Separate pipeline creation from job inclusion and identify which GitLab state each decision can observe.
- Explain the evaluation order event/source → workflow:rules → compiled pipeline → job rules → job graph → runner execution.
- Use CI_PIPELINE_SOURCE, ref, SHA, branch/tag/MR metadata, changed files, and repository paths without confusing them with job-only state.
- Predict first-match rule behavior, omitted jobs, when/allow-failure effects, and the difference between no pipeline and an empty-looking job graph.
- Recognize duplicate-pipeline risk and prove a rule decision with non-secret evidence rather than guessing from branch names.
1. The problem: “Why did this run?” has two different answers
After Chapters 01–07 you can identify the source SHA, compiled
configuration, runner boundary, variables, and secret capability.
Conditional execution adds a decision layer before job
execution. A common debugging mistake is to look at a missing job
and ask “which runner rejected it?” when the job was never added to
the pipeline. An even earlier possibility is that
workflow:rules prevented the pipeline itself from being
created.
That distinction matters operationally. A missing pipeline has no job IDs or runner trace. An existing pipeline with an omitted job has a pipeline ID and a compiled rule decision, but there is still no job queue entry for the omitted job. A queued job has crossed both rule gates and belongs to the runner/executor layer. Treating all three as “CI did not run” destroys useful evidence.
2. Mental model: two gates before execution
Read the flow left to right. An event, API request, schedule, or
downstream trigger establishes a pipeline source plus ref/SHA.
GitLab loads and compiles the CI configuration.
workflow:rules then decides whether this configuration
should produce a pipeline for that context. Only if a pipeline
exists do job-level rules decide whether each job
enters the graph and with which conditional attributes. Stages/needs
and runners operate afterward.
flowchart TD
A[Event / API / schedule] --> B[Pipeline source + ref + SHA]
B --> C[Compile .gitlab-ci.yml + includes]
C --> D{workflow:rules}
D -->|no match / never| X[No pipeline]
D -->|allowed| E[Pipeline record]
E --> F{Each job rules list}
F -->|no match / never| O[Job omitted]
F -->|first matching include rule| G[Job graph: stage / needs / when]
G --> H[Queue + runner + executor]
H --> I[Logs / reports / artifacts / side effects]
3. Name the state before writing rules
| State | Owned by | Evidence | Do not confuse it with |
|---|---|---|---|
| Pipeline source/ref/SHA | GitLab pipeline request context |
CI_PIPELINE_SOURCE, ref variables, immutable
CI_COMMIT_SHA
|
Branch naming conventions or job-only state |
| Compiled configuration | GitLab CI compiler | CI Lint / merged configuration / pipeline config | Repository YAML text alone |
| Workflow decision | Pipeline-creation policy | Pipeline exists or does not exist for exact source/ref/SHA | A job being omitted |
| Job-rule decision | Job inclusion policy | Job present/absent and conditional attributes | Runner assignment |
| Changed paths | Git comparison chosen by pipeline context |
MR target diff, push comparison, or explicit
compare_to
|
Files currently in workspace |
| Existing paths | Repository tree at evaluated project/ref | rules:exists match |
Artifacts generated by earlier jobs |
| Runner/job state | GitLab Runner after job inclusion | Job ID, runner ID, executor, trace | Why the rule matched |
4. Pipeline source is an input, not a guess
CI_PIPELINE_SOURCE is a pre-pipeline variable and
should be the first discriminator when behavior differs by trigger
class. The current primary documentation defines sources such as the
following. The list is broader than “push versus merge request,”
which is why broad catch-all rules are risky.
| Source | Meaning | Rule-design implication |
|---|---|---|
push |
Git push, including branch and tag pipelines | Branch/tag and commit metadata; distinguish branch from tag explicitly. |
merge_request_event |
Merge request pipeline | Enables MR pipeline behavior; MR variables are available in this context. |
schedule |
Scheduled pipeline |
No ordinary push diff event; guard
changes assumptions.
|
web |
New pipeline from GitLab UI | Manual UI pipeline; do not assume a push event. |
api |
Pipelines API | Authorization/request accepted and pipeline creation are separate evidence. |
trigger |
Trigger token |
Different from downstream pipeline source.
|
pipeline |
Multi-project downstream pipeline | Use this source in the downstream project when appropriate. |
parent_pipeline |
Child pipeline | Expected inside child pipeline configuration. |
webide |
Web IDE pipeline | Treat separately if policy/cost differs from ordinary pushes. |
external_pull_request_event |
External pull request pipeline | Relevant to GitHub external-PR integration contexts. |
push covers both branch and
tag push pipelines. Test CI_COMMIT_TAG or
CI_COMMIT_BRANCH when the distinction matters.
5. Gate one: workflow:rules controls pipeline existence
workflow:rules is evaluated before job rules. If no
workflow rule allows the context, there is no pipeline, so a
job-level rule cannot “bring the pipeline back.” Keep workflow
policy small and legible: identify the pipeline classes your
repository intentionally supports, reject known duplicates or
untrusted contexts, then let jobs specialize inside the admitted
classes.
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"'
when: never
- if: '$CI_PIPELINE_SOURCE == "push"'
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- when: never
This is an explicit allowlist with a duplicate-suppression rule
before the ordinary push rule. It is not a universal template:
downstream/API/trigger pipelines are intentionally rejected here. If
your repository needs them, add those sources deliberately rather
than replacing the last line with when: always.
6. Gate two: job rules uses first-match semantics
For a job, GitLab evaluates rules in order. The first matching rule determines inclusion/exclusion and conditional attributes; later rules are not consulted. If no rule matches, the job is omitted. Order therefore expresses precedence. Put narrow exceptions before broad cases and make the final behavior explicit.
unit_tests:
stage: test
script:
- ./ci/run-unit-tests.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
when: never
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
paths:
- src/**/*
- tests/**/*
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
In a schedule the first rule excludes the job even if later conditions could have matched. In an MR the second rule requires both the source expression and a relevant diff. On a default-branch push the third rule includes the job. A feature-branch push that reaches no match omits the job.
7. if expressions: evaluate metadata, not shell code
rules:if is evaluated by GitLab during pipeline
creation. It is not a shell command and cannot inspect files created
by jobs. Current GitLab expressions support equality/inequality,
null/empty checks, Boolean composition, and RE2 regular-expression
matching. Treat user-provided pipeline values as untrusted data even
though the expression evaluator is not a shell: a rule match can
grant a privileged job access to protected runners, variables,
deployments, or expensive compute.
release_candidate_checks:
script: ./ci/check-candidate.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$/'
RUN_PRODUCTION=true be the only
condition that unlocks a privileged deployment job. Combine user
intent with trusted ref/environment/identity policy.
8. changes asks “what differs?” and the comparison base
matters
rules:changes is diff-sensitive. In merge request
pipelines GitLab compares with the target branch. In ordinary branch
push pipelines it compares with the previous commit. For a new
branch, and for pipeline types without an associated push event such
as schedule/manual/tag contexts, an unqualified
changes condition can evaluate true unexpectedly. Use
an explicit source guard and, when you need deterministic behavior
outside push/MR contexts, use compare_to.
docs_check:
script: ./ci/check-docs.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
paths:
- docs/**/*
- mkdocs.yml
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH'
changes:
compare_to: 'refs/heads/main'
paths:
- docs/**/*
- mkdocs.yml
changes/exists checks. Avoid huge broad
glob sets as a substitute for repository architecture.
9. exists asks “is this capability in the repository?”
rules:exists checks paths in repository content for the
evaluated project/ref. It is useful for capability detection such as
“does this service contain a package.json?” It is not a
runtime filesystem probe and cannot see an artifact generated by an
earlier job because rules are resolved before jobs execute.
node_tests:
script: npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
exists:
- package.json
When exists is used with configuration includes, the
lookup context can be the project/ref containing the include, not
necessarily the project running the pipeline. That distinction
becomes important in later reuse/component chapters.
10. Read-only inspection before changing anything
When conditional behavior surprises you, collect non-destructive evidence before editing YAML:
-
Record pipeline ID (if one exists),
source, ref, and SHA from the pipeline UI/API. - Open CI Lint / merged configuration and confirm the configuration GitLab compiled for the revision.
- List the jobs that actually exist in the pipeline. An omitted job has no job ID.
-
For
changes, state the comparison context: MR target, prior push commit, or explicitcompare_to. -
For
exists, state the project/ref whose repository tree is inspected. - Only after the job exists should you move to queue/runner/script evidence.
11. Mental-model summary
workflow:rules controls whether a pipeline record exists.
Job rules control whether each job is included and may set when/variables/needs-related attributes.
CI_PIPELINE_SOURCE is explicit trigger-class evidence; push is not synonymous with branch.
changes depends on comparison semantics; use source guards and compare_to deliberately.
exists checks repository content before jobs run, not artifacts or arbitrary runner files.
Preserve source/ref/SHA, compiled config, pipeline ID, job graph, and first rule hypothesis before reruns.
Knowledge check
A job is missing from an existing pipeline. Which gate has definitely already succeeded?
The workflow/pipeline-creation gate. Because the pipeline exists, investigate the job rules and compiled configuration before runner state.
Can a job-level rule create a pipeline that workflow:rules rejected?
No. Workflow is evaluated before jobs; when it rejects the context, no pipeline exists for job rules to populate.
Why can unguarded changes be surprising in scheduled or manual pipelines?
Those contexts do not have a normal push event; current GitLab behavior can make changes evaluate true unless you constrain the source or use compare_to.
Can rules:exists test whether an earlier job uploaded dist/app.tar.gz?
No. Rules are evaluated before jobs run. exists inspects repository content, not future artifacts.
Why is the first matching job rule important?
GitLab stops evaluating that job’s rule list after the first match, so ordering is policy precedence.
Version and compatibility note
GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.
Official references and version notes
Documentation verification date: 2026-09-11. GitLab CI/CD rule semantics evolve; self-managed installations should confirm their deployed GitLab version before relying on newer syntax.
-
workflowkeyword — pipeline-creation rules, duplicate-pipeline avoidance, branch-to-MR switching, and current source examples. -
Specify when jobs run with
rules— first-match evaluation,CI_PIPELINE_SOURCEvalues, expressions, schedules,changes, and duplicate-pipeline guidance. -
CI/CD YAML syntax reference
— authoritative
rules,rules:changes,compare_to,rules:exists,when, and workflow syntax. - Predefined CI/CD variables — variable availability phases and safe source/ref/SHA metadata.
-
Debugging CI/CD pipelines
— current guidance for unexpected
changesbehavior and duplicate pipelines. - Troubleshooting merge request pipelines — evidence and repair guidance for branch + MR duplicates.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.