The configuration of GitLab CI/CD pipelines represents the backbone of modern DevOps orchestration, dictating exactly how, when, and under what conditions software is built, tested, and deployed. For years, the only and except keywords served as the primary mechanism for controlling job inclusion within a pipeline. However, as GitLab's CI/CD engine has evolved to support more complex logic, such as merge request pipelines and intricate trigger scenarios, these legacy keywords have transitioned into a state of obsolescence. The introduction of the rules keyword has fundamentally changed how engineers define pipeline logic, moving away from simple branch/tag filtering toward a powerful, conditional evaluation engine. Understanding the nuances of this transition is critical for maintaining pipeline integrity, avoiding the dreaded "double pipeline" phenomenon, and ensuring that automation behaves predictably across branches, tags, merge requests, and scheduled executions.
The Mechanics of the Legacy Only and Except Keywords
The only and except keywords are job-level keywords designed to filter job execution based on Git references, triggers, or specific file changes. In their simplest form, they act as a whitelist or blacklist for pipeline creation.
When an engineer defines an only clause, they are specifying the precise conditions under which a job is permitted to enter the pipeline. If no only, except, or rules keyword is present in a job configuration, GitLab applies a default behavior where the job is set to run on both branches and tags. This default state is an important consideration for engineers who may inadvertently trigger heavy deployment jobs on every single Git ref.
The functionality of these keywords extends beyond mere branch names. They can be configured to react to specific Git references, including:
.tags: This ensures that a job is only added to the pipeline when the Git reference for that specific pipeline is a tag.triggers: This allows a job to run when a pipeline is initiated via a trigger token, which is essential for cross-project pipeline orchestration.web: This targets pipelines created manually through the GitLab user interface, specifically from the Build > Pipelines section of the project.
The syntax also allows for the use of regular expressions to create more dynamic filtering. For instance, an engineer might want a job to run on the main branch, any branch starting with issue-, or any merge request.
Example of legacy configuration:
yaml
job1:
script: echo
only:
- main
- /^issue-.*$/
- merge_requests
Conversely, the except keyword acts as the inverse, allowing a job to run everywhere except for the specified criteria. This is particularly useful for excluding specific branches or preventing jobs from running during scheduled maintenance windows.
Example of exclusion logic:
yaml
job2:
script: echo
except:
- main
- /^stable-branch.*$/
- schedules
It is important to note a specific behavioral quirk regarding scheduled pipelines. Because scheduled pipelines are configured to run on specific branches, any job configured with only: branches will naturally execute during a schedule. To prevent this, an engineer must explicitly add except: schedules to the job configuration to decouple scheduled automation from standard branch-based automation.
A further nuance involves the shorthand usage of these keywords. If only or except are used without any accompanying sub-keywords, they are functionally equivalent to only: refs or except: refs. This means the following two configurations are identical in their execution pattern:
yaml
job1:
script: echo
only:
- branches
yaml
job2:
script: echo
only:
refs:
- branches
Deprecation and the Shift Toward Rules
The GitLab development trajectory has moved toward a more robust logic engine. Consequently, several specific uses of only and except are now deprecated. Most notably, the use of only:variables and except:variables—which were used to control job inclusion based on the status of CI/CD variables—is no longer recommended. Engineers should instead migrate to the rules:if syntax.
The transition is not merely a matter of syntax preference but a requirement for architectural stability. The rules keyword provides a sophisticated evaluation engine that can handle complex boolean logic. However, this transition introduces a significant restriction: the rules keyword and the only/except keywords cannot be used together in the same job. Attempting to mix them will result in a YAML error, specifically a key may not be used with rules error.
When migrating from a legacy trigger-based configuration to a modern rules-based one, the logic must be remapped using the CI_PIPELINE_SOURCE predefined variable.
If an engineer previously used:
yaml
only:
- triggers
The modern, equivalent implementation using rules would be:
yaml
rules:
- if: '$CI_PIPELINE_SOURCE == "trigger"'
when: on_success
- when: never
In this modern structure, the rules list is evaluated sequentially. The first rule that matches determines the outcome. The inclusion of - when: never at the end of the list is a defensive programming practice. It ensures that if the pipeline source does not match the trigger criteria, the job is explicitly excluded. Without this, the job might fall back to a default behavior that could be unintended.
Advanced Logic with the Rules Keyword
The rules keyword is a powerful tool available across all GitLab tiers (Free, Premium, and Ultimate) and works seamlessly across GitLab.com, GitLab Self-Managed, and GitLab Dedicated instances. The engine evaluates rules in order until a match is found.
The rules syntax allows for several types of conditional checks:
if: Evaluates a boolean expression, often using predefined variables like$CI_PIPELINE_SOURCE.exists: Checks for the presence of a specific file or directory within the repository.changes: Monitors changes to specific files or patterns to trigger jobs.
A critical limitation to remember is that rules are evaluated before any jobs in the pipeline actually run. Therefore, it is impossible to use dotenv variables created during a job's script phase within a rules:if statement. The pipeline structure is determined during the initial parsing phase, long before the runner begins executing the shell commands.
To prevent the creation of duplicate pipelines—a common issue when both branch and merge request pipelines are active—engineers should utilize workflow: rules. Without proper workflow management, a single push to a branch with an open merge request can trigger two separate pipelines: one for the branch and one for the merge request.
Example of a problematic configuration:
yaml
job:
script: echo "This job creates double pipelines!"
rules:
- if: $CI_PIPELINE_SOURCE == "push"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
To mitigate this, the workflow: rules directive should be used to define the global logic for when a pipeline should be created at all.
Furthermore, the rules keyword allows for fine-grained control over the when attribute. An engineer can define that a job should run manually for merge requests but automatically for scheduled pipelines:
yaml
job:
rypt: echo "Hello, Rules!"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
allow_failure: true
- if: $CI_PIPELINE_SOURCE == "schedule"
In this scenario, if the pipeline is a merge request event, the job is added as a manual step that does not block the pipeline if skipped. If the pipeline is a schedule, the job is added as a standard running job.
Monitoring Pipeline Sources via CIPIPELINESOURCE
The CI_PIPELINE_SOURCE variable is the cornerstone of modern pipeline control. It provides the context necessary to differentiate between various automation triggers.
The following table outlines the primary values of CI_PIPELINE_SOURCE and their impact on pipeline behavior:
| Value | Description |
|---|---|
| api | Pipelines triggered via the GitLab Pipelines API. |
| chat | Pipelines created via GitLab ChatOps commands. |
| external | Pipelines initiated by non-GitLab CI services. |
| externalpullrequest_event | Pipelines triggered by updates to external pull requests (e.g., GitHub). |
| mergerequestevent | Pipelines triggered by the creation or update of a merge request. |
| push | Pipelines triggered by a standard git push to a branch. |
| schedule | Pipelines triggered by a pre-configured schedule. |
| trigger | Pipelines triggered by a pipeline trigger token. |
| web | Pipelines manually started via the GitLab UI. |
By leveraging these values, engineers can create highly specialized pipelines. For example, a job might be configured to run only for merge requests and schedules, while explicitly ignoring standard branch pushes:
yaml
job1:
script:
- echo "Running for MR or Schedule only"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PI_SOURCE == "push"
when: never
File-Based Triggers with the Changes Keyword
The changes keyword, often used within only:changes or rules:changes, allows for a "path-aware" CI/CD process. This is essential for monorepos where a change in a frontend/ directory should not trigger a rebuild of the backend/ microservice.
The changes keyword resolves to true if any matching files are modified. It supports several globbing patterns:
- Directory recursion:
path/to/directory/**/* - Single directories:
path/to/directory/ - Wildcard glob paths:
*.md - Multiple extensions:
path/to/directory/*.{rb,py,sh} - Root-level wildcards:
"**/*.json"
However, there are significant caveats to using changes with certain Git references. If the pipeline is running on a reference other than branches, external_pull_requests, or merge_requests, GitLab cannot accurately determine if a file is "new" or "old" compared to a previous state. In these cases, the changes evaluation will default to true, potentially causing unnecessary job executions.
Furthermore, if only:changes is used in conjunction with other specific references, the job might ignore the change detection entirely and run on every execution.
Example of a complex only:changes configuration:
yaml
docker build:
script: docker build -t my-image:$CI_COMMIT_REF_SLUG .
only:
refs:
- branches
changes:
- Dockerfile
- docker/scripts/*
- dockerfiles/**/*
- more_scripts/*.{rb,py,sh}
- "**/*.json"
Kubernetes Integration and Deprecated Patterns
In environments utilizing the Kubernetes executor, specific legacy patterns exist that require attention. The only:kubernetes syntax is used to control whether a job is added to the pipeline based on whether the Kubernetes service is active in the project.
The kubernetes strategy specifically accepts the active keyword:
yaml
deploy:
only:
kubernetes: active
Engineers must also be wary of deprecated environment configurations. Specifically, the following paths are no longer recommended for direct use under the kubernetes section:
environment:kubernetes:namespaceenvironment:kubernetes:flux_resource_path
To ensure future compatibility and to correctly configure dashboard settings, these should be migrated to the nested structure:
environment:kubernetes:dashboard:namespaceenvironment:kubernetes:dashboard:flux_resource_path
Conclusion: The Engineering Imperative for Modernization
The evolution from only/except to rules represents a fundamental shift in the philosophy of CI/CD configuration. The legacy system was built for a world of simple branch-based deployments, whereas the rules system is built for the era of complex, multi-layered microservices and automated pull-request workflows.
For the DevOps engineer, the implications are twofold. First, there is a technical requirement to refactor existing pipelines to prevent the accumulation of technical debt and to avoid the instability of deprecated keywords like only:variables. Second, there is a strategic requirement to master the CI_PIPELINE_SOURCE variable and the rules:if evaluation logic to create pipelines that are both highly efficient and resistant to the "double pipeline" error.
A well-configured pipeline does more than just run scripts; it acts as a sophisticated gatekeeper. By utilizing rules, workflow, and precise changes patterns, organizations can significantly reduce compute costs, decrease deployment lead times, and ensure that every commit is validated within the correct context—whether it be a feature branch, a merge request, or a scheduled production release. Failure to adhere to these modern standards results in pipelines that are difficult to troubleshoot, prone to redundancy, and ultimately, a hindrance to the velocity of the software development lifecycle.