Chapter 08Lesson 01~150 minutes

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.

workflow:rulesJob rulesPipeline sourceRule evaluationState model

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.

Causal rule-evaluation path
            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.
Important: 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]+$/'
Security boundary: do not let a freely supplied variable such as 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
Current docs also document performance limits for patterned 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:

  1. Record pipeline ID (if one exists), source, ref, and SHA from the pipeline UI/API.
  2. Open CI Lint / merged configuration and confirm the configuration GitLab compiled for the revision.
  3. List the jobs that actually exist in the pipeline. An omitted job has no job ID.
  4. For changes, state the comparison context: MR target, prior push commit, or explicit compare_to.
  5. For exists, state the project/ref whose repository tree is inspected.
  6. Only after the job exists should you move to queue/runner/script evidence.

11. Mental-model summary

Gate 1

workflow:rules controls whether a pipeline record exists.

Gate 2

Job rules control whether each job is included and may set when/variables/needs-related attributes.

Source

CI_PIPELINE_SOURCE is explicit trigger-class evidence; push is not synonymous with branch.

Diff

changes depends on comparison semantics; use source guards and compare_to deliberately.

Tree

exists checks repository content before jobs run, not artifacts or arbitrary runner files.

Evidence

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?

Can a job-level rule create a pipeline that workflow:rules rejected?

Why can unguarded changes be surprising in scheduled or manual pipelines?

Can rules:exists test whether an earlier job uploaded dist/app.tar.gz?

Why is the first matching job rule important?

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.

Next lesson

workflow:rules, Job rules, if Expressions, changes, exists, Pipeline Sources, and Conditional Execution: Guided Hands-On Workflow and Core Operations

Apply the two-gate model to push, merge-request, and manual contexts and prove each included/omitted job with evidence.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.