Rules & Conditional Pipelines
rules
only/except
workflow rules
Branch-specific 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.
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.