GitLab CI’s rules keyword is a superset of the older only and except keywords, offering more granular control over job execution.
Let’s see rules in action. Imagine a pipeline with a job that should only run on the main branch but also skip on merge requests targeting main.
stages:
- deploy
deploy_production:
stage: deploy
script:
- echo "Deploying to production..."
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
when: never
Here, the deploy_production job is configured to run only when the commit is on the main branch (if: '$CI_COMMIT_BRANCH == "main"'). However, it’s explicitly set to never run if it’s part of a merge request where the target branch is main (if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"', when: never). This prevents accidental deployments from merge requests.
The problem rules solves is the increasing complexity of job execution logic. As projects grow, simple only: [main] or except: [develop] quickly becomes insufficient. You need to specify conditions based on branches, tags, merge requests, file changes, and even environment variables. rules allows you to define a series of conditions, and the job will execute if any of these conditions are met, in order.
Internally, GitLab CI evaluates each rule in the order they appear in the YAML. The first rule that evaluates to true determines the job’s execution outcome. If a rule has a when keyword (e.g., on_success, manual, never), that when strategy is applied. If no rule matches, the job is skipped by default. This sequential evaluation is crucial: the order matters.
The core levers you control with rules are the if conditions and the when strategy. The if conditions can reference many predefined CI/CD variables. For example, you can check $CI_COMMIT_TAG, $CI_PIPELINE_SOURCE (e.g., merge_request_event, push), or even custom variables set in your CI/CD settings. The when strategies are on_success (default, runs if previous stages succeeded), on_failure (runs if previous stages failed), always (runs regardless of previous stage status), manual (requires manual trigger from UI), and never (never runs). You can also combine these with allow_failure: true for specific scenarios.
When migrating from only/except, you’ll often find yourself translating specific conditions. For instance, only: [main] becomes rules: - if: '$CI_COMMIT_BRANCH == "main"'. except: [develop] becomes rules: - if: '$CI_COMMIT_BRANCH != "develop"'. However, rules shines when you need to combine conditions. A job that should run on main or any tag might have been a complex only setup, but with rules it’s straightforward:
deploy:
stage: deploy
script:
- echo "Deploying..."
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
This job will run if the commit is on the main branch OR if it’s a tagged commit. The when: on_success is implied for both rules as it’s not specified.
One subtlety often missed is how rules interact with workflow rules. If a workflow rule explicitly prevents a pipeline from starting (e.g., workflow: rules: - if: '$CI_COMMIT_BRANCH == "main"', when: never), then no jobs within that pipeline, regardless of their individual rules, will ever execute. The workflow rules act as a global gatekeeper before any job-level rules are even considered. This means you can set up broad pipeline-level exclusions that override all job configurations.
The next logical step after mastering rules is understanding how to use them with needs for more complex, non-linear pipeline dependencies.