Rules & Conditional Pipelines

A complete guide to GitLab CI/CD rules and conditional pipelines. Learn rules, only/except, workflow rules, branch-specific pipelines, and advanced conditional logic.

rules only/except workflow rules Branch-specific
What are Rules and Conditional Pipelines?

Not every job needs to run on every commit, in every branch, or in every pipeline. The same codebase might have a pipeline that runs unit tests on every push, integration tests only on merge requests, security scans only on the default branch, and deploys only after manual approval. Rules are the mechanism that makes this possible — they let you control exactly when and how each job runs.

GitLab offers two systems for conditional execution: the legacy only/except keywords and the modern rules keyword. While only/except still works, rules is the recommended approach going forward. Rules are more expressive, more consistent, and easier to reason about. They also integrate better with other GitLab features like workflow rules and merge request pipelines.

At the highest level, there are two places to apply conditions in GitLab: the job level (where rules decide whether a specific job gets added to the pipeline) and the pipeline level (where workflow rules decide whether the pipeline itself gets created at all). Understanding both levels, and how they interact, is essential for building pipelines that run exactly when you want them to.

Key Concept: Rules are evaluated in order, and the first matching rule wins. If no rule matches, the job is not added to the pipeline (equivalent to when: never). This "first-match-wins" semantics means rule order matters.
Rules vs Only/Except

Both mechanisms serve the same purpose — controlling when a job runs — but they differ significantly in syntax, capability, and recommended usage. Understanding the differences helps you decide which to use and how to migrate existing pipelines.

The only/except keywords were introduced first. They check conditions like branch names, tags, and whether the pipeline source is a schedule or a push. They're simple and have served the community well, but they have limitations: the syntax is inconsistent across different condition types, they don't support when: manual with complex conditions, and they can't express some of the more sophisticated scenarios that modern pipelines require.

The rules keyword was introduced as a unified, more expressive alternative. It has a single syntax for all conditions, supports all the when values, integrates with variables and file changes, and works seamlessly with workflow rules. For new pipelines, rules should be your default choice. For existing pipelines, you can migrate incrementally — GitLab allows both to coexist in the same file, though mixing them in the same job isn't allowed.

Feature only/except rules
Syntax Multiple keywords (only:refs, only:variables, only:changes) Single unified syntax (if, changes, exists)
Condition Types refs, variables, changes, kubernetes if, changes, exists
when: manual Limited support Full support
when: delayed Not supported Fully supported
Rules for MRs Complex workarounds needed Native support
Variable expressions Basic Full expression support (&&, ||, ==, !=)
Workflow integration Limited Full integration
Recommended Legacy only Yes (modern approach)
# Side-by-side comparison # only/except (legacy) legacy_job: stage: deploy script: - deploy.sh only: - main - tags except: - schedules # rules (modern) modern_job: stage: deploy script: - deploy.sh rules: - if: $CI_COMMIT_BRANCH == "main" when: on_success - if: $CI_COMMIT_TAG when: on_success - when: never # only with multiple values only_multi: script: echo "Multi" only: - main - develop - /^release-.*$/ - tags # rules with equivalent behavior rules_multi: script: echo "Multi" rules: - if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_BRANCH == "develop" - if: $CI_COMMIT_BRANCH =~ /^release-.*$/ - if: $CI_COMMIT_TAG - when: never # only with variables only_vars: script: echo "Vars" only: variables: - $DEPLOY_ENV == "production" # rules with variables (equivalent) rules_vars: script: echo "Vars" rules: - if: $DEPLOY_ENV == "production" # Mixing rules and only in the same file: # This is allowed for different jobs job_a: script: echo "A" rules: - if: $CI_COMMIT_BRANCH == "main" job_b: script: echo "B" only: - develop # But NOT in the same job: # job_c: # script: echo "C" # rules: [...] # Error: cannot use both # only: [...]
Recommendation: Use rules for all new pipelines. Migrate existing only/except usage to rules when convenient. GitLab may deprecate only/except in the future.
Basic Rules

The rules keyword takes a list of rule objects. Each rule has one or more conditions (like if or changes) and optionally a when clause that specifies what to do when the condition matches. When GitLab evaluates a job's rules, it goes through them in order and stops at the first match. The when from that rule determines the job's behavior.

If a rule has a condition but no when, the default when is on_success — the job runs normally. If a rule has only a when and no condition, it matches unconditionally (a "catch-all"). This makes it easy to express things like "run this job only on main, and never otherwise" with just two rules.

Understanding the when values is critical. on_success (the default) runs the job. manual adds the job to the pipeline but requires a human to trigger it. always runs the job regardless of previous job outcomes. never excludes the job entirely. delayed schedules the job to run after a specified delay. on_failure runs only if a previous job failed.

# Basic rules # Run only on main branch deploy_main: script: - deploy.sh rules: - if: $CI_COMMIT_BRANCH == "main" # Run only on tags release: script: - release.sh rules: - if: $CI_COMMIT_TAG # Run on main or tags deploy: script: - deploy.sh rules: - if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_TAG # Never run (disable job) disabled_job: script: - echo "Disabled" rules: - when: never # Always run (catch-all) always_job: script: - echo "Always runs" rules: - when: always # Manual job on main manual_deploy: script: - deploy.sh rules: - if: $CI_COMMIT_BRANCH == "main" when: manual - when: never # Delayed job delayed_job: script: - echo "Delayed" rules: - if: $CI_COMMIT_BRANCH == "main" when: delayed start_in: 30 minutes # On failure on_failure_job: script: - echo "Previous job failed" rules: - if: $CI_COMMIT_BRANCH == "main" when: on_failure # Combined conditions (AND) combined_and: script: - deploy.sh rules: - if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push" # Combined conditions (OR) combined_or: script: - deploy.sh rules: - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_TAG # Negation negated: script: - echo "Not on main" rules: - if: $CI_COMMIT_BRANCH != "main" # Regex matching regex_rule: script: - echo "Release branch" rules: - if: $CI_COMMIT_BRANCH =~ /^release-.*$/ # Multiple rules with different when multi_when: script: - echo "Complex job" rules: # Manual on tags - if: $CI_COMMIT_TAG when: manual # Auto on main - if: $CI_COMMIT_BRANCH == "main" when: on_success # Manual on develop - if: $CI_COMMIT_BRANCH == "develop" when: manual # Never otherwise - when: never # Rules with variables variable_rule: script: - deploy.sh rules: - if: $DEPLOY_ENV == "production" when: manual - if: $DEPLOY_ENV == "staging" when: on_success # Rules with multiple conditions complex_rule: script: - echo "Complex" rules: - if: $CI_COMMIT_BRANCH == "main" && $DEPLOY_ENV == "production" when: manual - if: $CI_COMMIT_BRANCH == "main" && $DEPLOY_ENV == "staging" when: on_success - when: never

on_success

Default. Job runs normally if all previous jobs succeeded.

manual

Job is added to the pipeline but requires manual trigger.

always

Job runs regardless of previous job outcomes.

never

Job is not added to the pipeline (excluded).

delayed

Job runs after a specified delay.

on_failure

Job runs only if a previous job failed.
Rules with Changes: File-Based Conditions

The changes condition in rules lets you run jobs only when specific files are modified. This is one of the most powerful features of rules — it enables true "smart pipelines" that run exactly the jobs needed for each change. A documentation-only change doesn't need to run the full test suite; a backend-only change doesn't need to rebuild the frontend. This dramatically reduces pipeline duration and resource usage.

You can specify file patterns using glob syntax (** for any directory depth, * for any file). Multiple patterns can be listed, and the job runs if any of them match. You can also use compare_to to specify which reference to compare against (by default, the previous commit).

A common pattern is to combine changes with branch conditions. For example, you might run backend tests when backend files change, frontend tests when frontend files change, and both when shared files (like package.json) change. This granularity is what makes monorepos and large projects practical with GitLab CI/CD.

# Run only when specific files change frontend_test: script: - npm run test:frontend rules: - changes: - frontend/**/* - package.json # Combine branch and changes backend_deploy: script: - deploy-backend.sh rules: - if: $CI_COMMIT_BRANCH == "main" changes: - backend/**/* # Multiple file patterns docs_build: script: - build-docs.sh rules: - changes: - docs/**/* - README.md - mkdocs.yml # Changes with comparison reference compare_changes: script: - echo "Files changed since main" rules: - changes: - src/**/* compare_to: "refs/heads/main" # Exclude jobs when files change skip_docs: script: - echo "Not a docs change" rules: - changes: - docs/**/* when: never - when: on_success # Changes with any of multiple files multi_change: script: - echo "Any of these files changed" rules: - changes: - "*.md" - "*.txt" - "*.rst" # Change patterns with wildcards wildcard_changes: script: - echo "Config files changed" rules: - changes: - "config/**/*.yaml" - "config/**/*.yml" - "config/**/*.json" # Monorepo pattern: run only affected services service_a_test: script: - test.sh service-a rules: - changes: - services/service-a/**/* - shared/libs/**/* service_b_test: script: - test.sh service-b rules: - changes: - services/service-b/**/* - shared/libs/**/*
Changes Best Practices:
  • Use **/* to match all files in a directory recursively
  • Include shared files (like lock files or config) in the patterns
  • Combine with branch conditions for precise control
  • Use when: never before changes to exclude jobs when specific files change
  • Always include a catch-all rule at the end
Rules with Exists: File Presence Conditions

The exists condition is similar to changes but checks whether files exist in the repository, rather than whether they've changed in the current commit. This is useful for jobs that should only run when certain files are present — for example, a Docker build job that should run only if a Dockerfile exists, or a Helm deploy job that should run only if a chart directory is present.

Because exists checks the repository state rather than the diff, it doesn't depend on the current commit's changes. This makes it more predictable for "does this project use technology X" checks. For example, if your repository has both a Dockerfile and a serverless.yml, you can use exists to run the Docker build only if the Dockerfile is present, and the serverless deploy only if the serverless config is present.

# Run only if Dockerfile exists docker_build: script: - docker build -t myapp . rules: - exists: - Dockerfile # Run only if Kubernetes manifests exist k8s_deploy: script: - kubectl apply -f k8s/ rules: - exists: - k8s/**/*.yaml - k8s/**/*.yml # Combine exists with branch condition helm_deploy: script: - helm upgrade --install my-app ./charts/my-app rules: - if: $CI_COMMIT_BRANCH == "main" exists: - charts/my-app/Chart.yaml # Multiple exists patterns (OR logic) any_manifest: script: - echo "Found manifest" rules: - exists: - k8s/*.yaml - helm/Chart.yaml - docker-compose.yml # Combine exists and if (AND logic) conditional_docker: script: - docker build -t myapp . rules: - if: $CI_COMMIT_BRANCH == "main" exists: - Dockerfile # exists with changes exists_and_changes: script: - echo "Both conditions must be true" rules: - exists: - Dockerfile changes: - Dockerfile # Negation: run if file does NOT exist # Not directly supported; use inverted logic no_dockerfile: script: - echo "No Dockerfile present" rules: - exists: - Dockerfile when: never - when: on_success # exists vs changes: # - exists: Does the file exist in the repo? # - changes: Was the file modified in this commit? # # Use exists when: # - You want a job to run for any commit if the file exists # - You want to conditionally enable features # # Use changes when: # - You want a job to run only when specific files change # - You want to skip jobs when files haven't changed
Exists vs Changes:
  • exists: Checks if file is present in the repository
  • changes: Checks if file was modified in this commit
  • Use exists for feature toggling (is this project Docker-based?)
  • Use changes for optimization (did this service change?)
  • You can combine both for maximum precision
Workflow Rules: Pipeline-Level Conditions

Job rules control whether a specific job runs. Workflow rules control whether the entire pipeline is created. This is a crucial distinction: if a workflow rule excludes a pipeline, no jobs run at all — the pipeline simply doesn't exist. This is useful for preventing pipelines in situations where they'd be wasteful or harmful — like duplicate pipelines, pipelines on forks, or pipelines for documentation-only changes.

One of the most common uses of workflow rules is preventing duplicate pipelines. Consider a project that has both a branch pipeline (for the feature branch) and a merge request pipeline (for the MR). When a developer pushes to a feature branch that has an open MR, GitLab might create two pipelines — one for the branch and one for the MR. Workflow rules can suppress the branch pipeline when an MR exists, keeping your pipeline list clean and avoiding duplicate work.

Workflow rules use the same syntax as job rules — a list of rule objects with if conditions and when values. The when in workflow rules typically uses always (create the pipeline) or never (skip it entirely). The when: always is the default if you don't specify otherwise, but explicit is better.

# Workflow rules: control pipeline creation workflow: rules: # Run pipelines for merge requests - if: $CI_PIPELINE_SOURCE == "merge_request_event" # Run pipelines for main - if: $CI_COMMIT_BRANCH == "main" # Run pipelines for tags - if: $CI_COMMIT_TAG # Don't run for other branches - when: never # Avoid duplicate pipelines workflow: rules: # Don't run branch pipeline if MR exists - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS when: never # Run MR pipelines - if: $CI_PIPELINE_SOURCE == "merge_request_event" # Run branch pipelines for main - if: $CI_COMMIT_BRANCH == "main" # Run only on schedules workflow: rules: - if: $CI_PIPELINE_SOURCE == "schedule" # Run on push, MR, schedule, web workflow: rules: - if: $CI_PIPELINE_SOURCE == "push" - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_PIPELINE_SOURCE == "schedule" - if: $CI_PIPELINE_SOURCE == "web" - when: never # Auto-cancel redundant pipelines workflow: auto_cancel: on_new_commit: interruptible on_job_failure: none rules: - if: $CI_COMMIT_BRANCH == "main" when: always - if: $CI_COMMIT_TAG when: always - if: $CI_PIPELINE_SOURCE == "merge_request_event" when: always - when: never # Prevent pipelines on forks workflow: rules: - if: $CI_PROJECT_PATH != $CI_PROJECT_PATH_SLUG when: never - if: $CI_COMMIT_BRANCH - if: $CI_COMMIT_TAG # Run pipelines only when specific files change workflow: rules: - changes: - src/**/* - tests/**/* - Dockerfile - .gitlab-ci.yml - when: never # Combined workflow rules workflow: rules: # Skip documentation-only changes - changes: - docs/**/* - "*.md" when: never # Run for MRs - if: $CI_PIPELINE_SOURCE == "merge_request_event" # Run for main - if: $CI_COMMIT_BRANCH == "main" # Run for tags - if: $CI_COMMIT_TAG # Run for schedules - if: $CI_PIPELINE_SOURCE == "schedule" # Never otherwise - when: never # Available pipeline sources: # - push # - web # - schedule # - api # - external # - chat # - webide # - merge_request_event # - external_pull_request_event # - parent_pipeline # - pipeline # - ondemand_dast_scan # - ondemand_dast_validation

$CI_PIPELINE_SOURCE

The trigger for the pipeline: push, web, schedule, merge_request_event, etc.

$CI_COMMIT_BRANCH

The branch name (undefined for tag pipelines).

$CI_COMMIT_TAG

The tag name (defined only for tag pipelines).

$CI_OPEN_MERGE_REQUESTS

True if there are open merge requests for this branch.

$CI_MERGE_REQUEST_ID

The MR ID for merge request pipelines.

when: never

Prevents pipeline creation when matched.
Workflow Rules Best Practices:
  • Always end with when: never to explicitly exclude unmatched sources
  • Use $CI_OPEN_MERGE_REQUESTS to prevent duplicate pipelines
  • Combine changes with workflow rules to skip pipelines entirely for docs-only changes
  • Use auto_cancel to cancel redundant pipelines
  • Test workflow rules by pushing to different branch types
Branch-Specific Pipelines

One of the most common uses of rules is running different jobs on different branches. Development branches might run quick unit tests, while main might run full integration tests and deploy. Feature branches might run linting but not deploy, while release branches might produce artifacts and deploy to staging.

Branch-specific rules can be implemented in many ways. The simplest approach is to check $CI_COMMIT_BRANCH directly, but you can also use $CI_COMMIT_REF_NAME (which is the branch name for branch pipelines and the tag name for tag pipelines) or regex patterns. GitLab also provides $CI_DEFAULT_BRANCH, which is the default branch name — useful when the default branch might be main or master depending on the project.

For complex projects with many branches, it's often cleaner to use workflow rules to determine which pipelines to run, then use job rules to determine which jobs to run within those pipelines. This layered approach keeps your configuration readable and avoids duplication.

# Branch-specific jobs # Run only on main deploy_production: stage: deploy script: - deploy.sh production rules: - if: $CI_COMMIT_BRANCH == "main" # Run only on develop deploy_staging: stage: deploy script: - deploy.sh staging rules: - if: $CI_COMMIT_BRANCH == "develop" # Run only on feature branches feature_tests: stage: test script: - npm run test:quick rules: - if: $CI_COMMIT_BRANCH =~ /^feature\/.*$/ # Run only on release branches release_build: stage: build script: - npm run build:release rules: - if: $CI_COMMIT_BRANCH =~ /^release\/.*$/ # Run only on tags tag_release: stage: deploy script: - npm publish rules: - if: $CI_COMMIT_TAG # Different behavior on different branches environment_deploy: stage: deploy script: - echo "Deploying to $DEPLOY_ENV" - deploy.sh $DEPLOY_ENV rules: - if: $CI_COMMIT_BRANCH == "main" variables: DEPLOY_ENV: production - if: $CI_COMMIT_BRANCH == "develop" variables: DEPLOY_ENV: staging - if: $CI_COMMIT_BRANCH =~ /^feature\/.*$/ variables: DEPLOY_ENV: review - when: never # Using default branch variable default_branch_job: script: - echo "Running on default branch" rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Regex patterns for branch families all_release_branches: script: - echo "Release branch" rules: - if: $CI_COMMIT_BRANCH =~ /^release-\d+\.\d+$/ # Wildcard branch matching wildcard_branch: script: - echo "Wildcard match" rules: - if: $CI_COMMIT_BRANCH =~ /^hotfix.*/ # Branch-specific with file changes backend_on_main: script: - test-backend.sh rules: - if: $CI_COMMIT_BRANCH == "main" changes: - backend/**/* # Combine multiple branch conditions multi_branch: script: - echo "Multi branch" rules: - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop" # Not on specific branches not_on_main: script: - echo "Not on main" rules: - if: $CI_COMMIT_BRANCH != "main" - if: $CI_COMMIT_TAG # Branch-specific variables staging_deploy: stage: deploy variables: KUBE_NAMESPACE: staging script: - kubectl apply -f k8s/ -n $KUBE_NAMESPACE rules: - if: $CI_COMMIT_BRANCH == "develop" production_deploy: stage: deploy variables: KUBE_NAMESPACE: production script: - kubectl apply -f k8s/ -n $KUBE_NAMESPACE rules: - if: $CI_COMMIT_BRANCH == "main" when: manual
Branch-Specific Best Practices:
  • Use $CI_DEFAULT_BRANCH instead of hardcoding "main" or "master"
  • Use regex patterns for branch families (feature/*, release/*)
  • Keep different environments in separate jobs for clarity
  • Use variables for environment-specific configuration
  • Combine branch conditions with file changes for monorepos
Complete Example: Rules in a Real Pipeline
# Complete .gitlab-ci.yml with rules stages: - build - test - security - deploy variables: NODE_VERSION: "18" default: image: node:$NODE_VERSION cache: key: files: - package-lock.json paths: - node_modules/ # Workflow rules: control pipeline creation workflow: auto_cancel: on_new_commit: interruptible rules: # Skip docs-only changes - changes: - docs/**/* - "*.md" - LICENSE when: never # Run for MRs - if: $CI_PIPELINE_SOURCE == "merge_request_event" # Run for main - if: $CI_COMMIT_BRANCH == "main" # Run for tags - if: $CI_COMMIT_TAG # Run for scheduled pipelines - if: $CI_PIPELINE_SOURCE == "schedule" # Never otherwise - when: never # Build stage build: stage: build script: - npm ci - npm run build artifacts: paths: - dist/ expire_in: 1 day rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_TAG # Test stage: unit tests (always) test_unit: stage: test script: - npm ci - npm run test:unit artifacts: reports: junit: test-results/unit.xml parallel: 4 rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_TAG # Test stage: integration tests (MR and main only) test_integration: stage: test script: - npm ci - npm run test:integration rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" - when: never # Test stage: e2e tests (main and tags only) test_e2e: stage: test script: - npm ci - npm run test:e2e rules: - if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_TAG - when: never # Security stage sast: stage: security image: registry.gitlab.com/security-products/sast:latest script: - /analyzer run artifacts: reports: sast: gl-sast-report.json rules: - if: $CI_COMMIT_BRANCH == "main" - if: $CI_PIPELINE_SOURCE == "merge_request_event" - when: never dependency_scanning: stage: security image: registry.gitlab.com/security-products/dependency-scanning:latest script: - /analyzer run artifacts: reports: dependency_scanning: gl-dependency-scanning-report.json rules: - if: $CI_COMMIT_BRANCH == "main" - if: $CI_PIPELINE_SOURCE == "merge_request_event" - when: never # Deploy stage: staging (automatic on main) deploy_staging: stage: deploy environment: name: staging url: https://staging.example.com script: - deploy.sh staging rules: - if: $CI_COMMIT_BRANCH == "main" when: on_success - when: never # Deploy stage: production (manual on tags) deploy_production: stage: deploy environment: name: production url: https://example.com script: - deploy.sh production rules: - if: $CI_COMMIT_TAG when: manual - if: $CI_COMMIT_BRANCH == "main" when: manual - when: never # Feature branch: review app review_app: stage: deploy environment: name: review/$CI_COMMIT_REF_SLUG url: https://$CI_COMMIT_REF_SLUG.review.example.com on_stop: stop_review script: - deploy.sh review rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH =~ /^feature\/.*$/ stop_review: stage: deploy environment: name: review/$CI_COMMIT_REF_SLUG action: stop script: - teardown.sh review when: manual rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH =~ /^feature\/.*$/ # Cleanup: always run cleanup: stage: .post script: - cleanup.sh when: always rules: - when: always
What This Example Demonstrates:
  • Workflow rules skip docs-only pipelines
  • Auto-cancel for redundant pipelines
  • Unit tests run on all pipelines
  • Integration tests only on MRs and main
  • E2E tests only on main and tags
  • Security scans on main and MRs
  • Staging deploy automatic on main
  • Production deploy manual on tags/main
  • Review apps for feature branches
  • Cleanup always runs last
Frequently Asked Questions
What's the difference between rules and only/except?
rules is the modern, unified syntax. only/except is the legacy syntax. Rules support all condition types with a single syntax, integrate with workflow rules, and support all when values. Use rules for new pipelines.
Can I use rules and only/except together?
Yes, but not in the same job. Different jobs can use different systems. For consistency, migrate all jobs to rules.
What happens if no rule matches?
If no rule matches, the job is not added to the pipeline (equivalent to when: never). Always end with a catch-all rule to make behavior explicit.
What's the difference between changes and exists?
changes checks whether files were modified in the current commit. exists checks whether files are present in the repository. Use changes for optimization, exists for feature toggling.
How do I prevent duplicate pipelines for MRs?
Use workflow rules with $CI_OPEN_MERGE_REQUESTS: if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS with when: never. This suppresses branch pipelines when an MR exists.
Can rules reference variables?
Yes, rules can reference any variable, including predefined and custom ones. You can compare to literal values, use regex, or check for existence.
What are the supported when values?
on_success (default), on_failure, always, manual, delayed, and never. Each has specific semantics and use cases.
How do I debug rules?
Use the CI Lint tool to validate syntax. Push to different branches to see which rules match. Use $CI_DEBUG_TRACE=true for verbose output. Check the pipeline graph to see which jobs ran.
Previous: Variables & Secrets Next: GitLab Runners

Rules are the key to building efficient, purpose-built pipelines. Master them to make your CI/CD run exactly what you need, when you need it.