The GitLab CI/CD pipeline architecture has historically relied on a set of keywords to control the inclusion of jobs based on specific Git references, event triggers, and file changes. Among these, the only keyword served as the primary mechanism for defining job execution boundaries. However, as GitLab evolved toward a more flexible and powerful logic engine, the only and except keywords were superseded by the rules keyword. Understanding the nuanced behavior of only, its inherent limitations regarding boolean logic, and the migration path to rules is critical for maintaining stable, predictable deployment pipelines.
The Functional Mechanics of the Only Keyword
The only keyword is designed to restrict the execution of a job to specific scenarios, such as specific branches, tags, or pipeline sources. When a job is configured with only, GitLab evaluates the current Git reference to determine if the job should be added to the pipeline.
If a job is defined without only, except, or rules, the system applies a default behavior. By default, such jobs are configured to run on both branches and tags. This is functionally equivalent to explicitly stating:
yaml
only:
- branches
- tags
The only keyword supports several types of references and triggers, which are essential for segmenting pipelines based on the intent of the code push.
- branches: The job runs on push events to any branch.
- tags: The job runs when the Git reference for a pipeline is a tag.
- triggers: The job runs for pipelines created via a trigger token.
- web: The job runs when a pipeline is manually created via the GitLab UI (Build > Pipelines).
- merge_requests: The job runs specifically for merge request pipelines.
The impact of using these keywords is that it allows developers to isolate heavy-duty tasks, such as production deployments or comprehensive integration tests, so they do not run on every single feature branch commit, thereby saving runner minutes and reducing noise in the pipeline view.
Boolean Logic Limitations and the OR Behavior
A critical point of failure for many users is the misunderstanding of how only handles multiple conditions, specifically when combining refs and changes. In the legacy only syntax, providing multiple criteria often results in an "OR" logic rather than an "AND" logic.
For instance, consider a configuration intended to run a job only when a tag is pushed AND specific files in a folder (e.g., tests/*) have been modified:
yaml
only:
changes:
- tests/*
refs:
- tags
In this scenario, the user's intent is a logical intersection (AND). However, GitLab interprets this as a union (OR). The job will appear in the pipeline if the pipeline is a tag, regardless of whether the tests folder changed, or if the tests folder changed, regardless of whether it is a tag.
The real-world consequence of this behavior is catastrophic in pipelines where allow_failure: false is set. If a tag is pushed without changes to the specified folder, the job still appears due to the "OR" logic. Because the job is present but potentially lacks the context to run successfully, or is set to manual but required for the pipeline to progress, the pipeline becomes stuck. This forces developers to either manually skip jobs or weaken their failure constraints, compromising the integrity of the CI/CD process.
Deprecated Keywords and the Shift to Rules
GitLab has officially deprecated several aspects of the only and except keywords to streamline the YAML configuration and provide more granular control. Specifically, only:variables and except:variables are deprecated. These were previously used to control job inclusion based on the status of CI/CD variables.
The industry standard has shifted toward rules:if, which provides a far more robust way to handle conditional logic using predefined variables. The transition is not merely a change in syntax but a shift in how GitLab evaluates the pipeline graph.
| Legacy Keyword | Modern Replacement | Logic Capability |
|---|---|---|
only: refs |
rules: - if: $CI_COMMIT_BRANCH == ... |
Complex Boolean (AND/OR) |
only: changes |
rules: - changes: [...] |
Combined with if for AND logic |
only: variables |
rules: - if: $VARIABLE == "value" |
Full variable expression support |
except |
rules: - when: never |
Explicit exclusion |
The contextual layer here is that mixing only/except with rules in the same pipeline is strictly forbidden. While it may not trigger a YAML syntax error, it leads to inconsistent default behaviors. For example, jobs without rules default to except: merge_requests. If one job uses rules and another uses only, you may encounter "double pipelines" where both a branch pipeline and a merge request pipeline are triggered for the same commit, leading to redundant resource consumption and potential race conditions during deployment.
Advanced Implementation of Rules for Tag and Change Constraints
To achieve the "AND" logic that only fails to provide—specifically running a job only when a tag is present AND certain files have changed—developers must use the rules keyword. In rules, multiple conditions within a single list item are evaluated as a logical AND.
To implement a job that runs only on tags and only when files in tests/* are modified, the configuration should be:
yaml
test:
stage: test
rules:
- if: '$CI_COMMIT_TAG'
changes:
- tests/*
when: manual
- when: never
In this configuration:
1. The if: '$CI_COMMIT_TAG' ensures the pipeline is a tag pipeline.
2. The changes: - tests/* ensures the specified files were modified.
3. Both must be true for the when: manual trigger to apply.
4. The final - when: never ensures that if the above conditions are not met, the job is excluded entirely from the pipeline rather than defaulting to "on success".
This approach solves the "stuck pipeline" problem because the job simply will not exist in the pipeline graph unless both conditions are met, removing the need to toggle allow_failure.
Pipeline Source Variables and Trigger Control
The precision of rules depends heavily on the use of predefined CI/CD variables. The CI_PIPELINE_SOURCE variable is the primary tool for identifying how a pipeline was started.
The following table details the values of CI_PIPELINE_SOURCE and their implications:
| Value | Description | Impact on Job Execution |
|---|---|---|
api |
Pipelines triggered by the pipelines API | Allows external system integration to trigger jobs |
chat |
Pipelines created by GitLab ChatOps | Enables conversational triggers for CI tasks |
external |
CI services other than GitLab | Connects third-party CI providers |
external_pull_request_event |
External GitHub pull requests | Syncs GitHub PRs with GitLab pipelines |
merge_request_event |
MR created or updated | Isolates MR logic from branch logic |
push |
Standard git push | The most common trigger for branch/tag pipelines |
schedule |
Scheduled pipelines | Controls nightly or periodic tasks |
web |
Manually started via UI | Allows developers to trigger "on-demand" pipelines |
By utilizing these variables, a developer can create a sophisticated workflow:rules block at the top of the .gitlab-ci.yml file. This prevents the creation of duplicate pipelines. For example, to prevent a pipeline from running for merge requests while allowing all other types:
yaml
workflow:
rules:
- if: $CI_MERGE_REQUEST_ID
when: never
- when: always
This global configuration ensures that the pipeline doesn't trigger both for the push and for the associated merge request, which is a common source of confusion for users who find their jobs running twice.
Resolving Branch-Specific Pipeline Failures
A common point of confusion for "noobs" and enthusiasts is the scope of the .gitlab-ci.yml file. A frequent error is assuming that a single master .gitlab-ci.yml applies globally across all branches regardless of the branch's content. In reality, GitLab evaluates the version of the .gitlab-ci.yml file present on the branch being built.
If a user finds that a pipeline runs on the master branch but never on other branches (like dev or uat), it is often because the .gitlab-ci.yml file on those branches does not contain the necessary job definitions or has restrictive only keywords.
One workaround discovered by users is to maintain discrete .gitlab-ci.yml files on each branch. However, this is a maintenance nightmare. The professional approach is to make the configuration branch-agnostic using rules.
Example of a branch-agnostic publish job using rules:
yaml
publish:
stage: publish
rules:
- if: '$CI_COMMIT_BRANCH == "master"'
when: on_success
- if: '$CI_COMMIT_BRANCH == "uat"'
when: on_success
- if: '$CI_COMMIT_BRANCH == "test"'
when: on_success
- if: '$CI_COMMIT_BRANCH == "dev"'
when: on_success
- when: never
script:
- echo "Publishing ..."
This configuration replaces the legacy only: - master - uat - test - dev syntax. It explicitly checks the branch name and decides whether to run the job. By ending the list with - when: never, the developer ensures the job is excluded from any branch not listed (such as feature branches), maintaining a clean and efficient pipeline.
Analysis of Rule-Based vs. Only-Based Workflows
The transition from only to rules represents a move from a declarative "where to run" model to a conditional "when to run" model. The only keyword was essentially a shortcut for a few common scenarios. rules, however, provides a full logic engine.
The most significant advantage of rules is the ability to define the when state (manual, on_success, always, never) based on the evaluation of the if statement. In the legacy only system, the when keyword was global to the job. If you set when: manual with only, the job would always be manual if the only conditions were met. With rules, you can have a job that is automatic if it's a tag and a specific file changed, but manual in all other scenarios:
yaml
test:
stage: test
rules:
- if: '$CI_COMMIT_TAG'
changes:
- tests/*
when: on_success
- when: manual
script:
- echo "test"
This level of granularity is impossible with the only keyword. The impact for the user is a drastic reduction in manual intervention and a more automated "golden path" to production.