GitLab CI’s workflow:rules lets you define pipeline execution logic more granularly than ever before, but its real power lies in its ability to prevent pipelines from running when you don’t expect them to, saving you significant compute resources and developer time.
Let’s see it in action. Imagine a common scenario: you only want your CI pipeline to run for changes to your application code, not for documentation updates or changes to the CI configuration itself.
workflow:
rules:
- if: '$CI_COMMIT_BRANCH' # Default rule: run for any branch commit
when: on_success
- if: '$CI_COMMIT_TAG' # Also run for tags
when: on_success
- if: '$CI_MERGE_REQUEST_ID' # And for merge requests
when: on_success
- when: never # If none of the above match, do not run the pipeline
This basic setup ensures pipelines run for common scenarios. Now, let’s get specific. Suppose you have a docs/ directory and you never want a full CI pipeline to trigger when only files within docs/ are changed.
workflow:
rules:
- if: '$CI_COMMIT_BRANCH && $CI_COMMIT_MESSAGE !~ /^docs:/' # Run on branches, unless commit message starts with "docs:"
when: on_success
- if: '$CI_COMMIT_TAG'
when: on_success
- if: '$CI_MERGE_REQUEST_ID && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"' # Run for MRs targeting main
when: on_success
- if: '$CI_MERGE_REQUEST_ID && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME != "main" && $CI_MERGE_REQUEST_DIFF_SUMMARY !~ /docs\//' # Run for MRs not targeting main, IF no docs changes
when: on_success
- when: never
This is a common approach, but it has a flaw: it relies on developers remembering to prefix their commit messages with docs:. A more robust solution uses file path matching.
workflow:
rules:
- if: '$CI_COMMIT_BRANCH && $CI_MERGE_REQUEST_ID == null && ($CI_COMMIT_CHANGES =~ /^(?!.*docs\/)/)' # Run on branches, not MRs, and only if no files in docs/ are changed
when: on_success
- if: '$CI_COMMIT_TAG'
when: on_success
- if: '$CI_MERGE_REQUEST_ID && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"' # Run for MRs targeting main
when: on_success
- if: '$CI_MERGE_REQUEST_ID && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME != "main" && ($CI_MERGE_REQUEST_DIFF_SUMMARY =~ /^(?!.*docs\/)/)' # Run for MRs not targeting main, IF no docs changes
when: on_success
- when: never
Let’s break down the mental model. workflow:rules operates at the top level of your .gitlab-ci.yml file. It’s a list of rules, processed in order. The first rule that matches determines whether a pipeline is created. If no rules match, the pipeline is skipped. This "first match wins" behavior is critical.
The if condition uses GitLab’s predefined CI/CD variables. Some of the most useful here are:
$CI_COMMIT_BRANCH: The name of the branch for the current commit.$CI_COMMIT_TAG: The name of the tag for the current commit.$CI_MERGE_REQUEST_ID: The ID of the merge request, if the pipeline is running for an MR.$CI_MERGE_REQUEST_TARGET_BRANCH_NAME: The target branch of the merge request.$CI_COMMIT_MESSAGE: The commit message.$CI_COMMIT_CHANGES: A list of changed files for the current commit. This is a powerful variable for file-based rules.$CI_MERGE_REQUEST_DIFF_SUMMARY: A list of changed files for the current merge request.
The when keyword specifies what to do if the rule matches. on_success is the default and means the pipeline runs. never explicitly prevents a pipeline from running.
The regular expressions (=~ for match, !~ for no match) are applied to these variables. For example, ($CI_COMMIT_CHANGES =~ /^(?!.*docs\/)/) is a negative lookahead regex. It asserts that the entire $CI_COMMIT_CHANGES string does not contain docs/ anywhere within it. This effectively means "run only if no files in the docs/ directory have changed."
You can also use changes directly within a job, but workflow:rules controls whether the entire pipeline is created in the first place. This is crucial for efficiency. If you have a large project with many jobs, and a change to a docs/ file would trigger a pipeline that runs dozens of jobs unnecessarily, using workflow:rules prevents that initial pipeline creation entirely.
Consider the order of rules carefully. If you have a rule that says when: never before a rule that should actually trigger a pipeline, the pipeline will never run. The system evaluates the rules sequentially and stops at the first match.
The most overlooked aspect of workflow:rules is its interaction with the rules keyword at the job level. If workflow:rules determines a pipeline should run, then the individual job rules take over to decide if that specific job should run within that pipeline. However, if workflow:rules says when: never, no jobs will ever be evaluated, regardless of their own rules. This means you can have a very simple workflow:rules that allows pipelines for most commits, and then use job-level rules for fine-grained control over which jobs execute.
The next evolution is to integrate these rules with external triggers, like manual pipeline creation or API calls.