The configuration of GitLab CI/CD pipelines relies heavily on the precise control of job execution based on the context of the Git reference, the source of the pipeline, and the specific files modified during a push. For years, the only and except keywords served as the primary mechanism for developers to define these boundaries. However, the evolution of GitLab's CI/CD engine has shifted toward a more robust, albeit more complex, rules syntax. Understanding the legacy behavior of only, the nuances of its deprecated features, and the transition to rules:if and rules:changes is critical for maintaining stable, efficient, and predictable automation workflows.
In the landscape of modern DevOps, a pipeline that runs when it should not—or fails to run when it must—can lead to catastrophic deployment failures or significant resource wastage. This technical analysis explores the mechanics of pipeline triggering, the underlying logic of Git references, and the functional limitations encountered when attempting to combine path-based triggers with specific Git refs like tags.
The Mechanics of the Only Keyword and Git References
The only keyword is a job-level configuration used to restrict a job's execution to specific conditions. At its core, it evaluates the Git reference of the pipeline to determine if the job should be added to the pipeline graph.
When a developer configrypts a job with only, GitLab evaluates the incoming Git event against the provided list of allowed references. These references can include branches, tags, and merge requests.
The behavior of only is fundamentally tied to the concept of refs. A ref represents a pointer to a specific commit in the Git history.
If a job is configured without any specific keywords under only, the default behavior is set to both branches and tags. This means the job will appear in any pipeline triggered by a push to a branch or the creation of a tag.
The following table illustrates the behavior of different Git references and how they interact with standard pipeline triggers:
| CIPIPELINESOURCE | Branch | Tag | Merge Request | Scheduled |
|---|---|---|---|---|
| CICOMMITBRANCH | Yes | Yes | No | No |
| CICOMMITTAG | Yes | Yes (if configured) | No | No |
| push | Yes | Yes | No | No |
| schedule | Yes | No | No | Yes |
| mergerequestevent | No | No | Yes | No |
The CI_PIPELINE_SOURCE variable is the primary instrument for controlling these transitions. For instance, a pipeline source of api refers to pipelines triggered via the GitLab Pipelines API, while web refers to pipelines manually initiated through the GitLab User Interface via the "New pipeline" button in the Build > Pipelines section.
The impact of misconfiguring these references is significant. For example, if a job is intended only for the main branch but lacks an except: tags or a specific only: branches constraint, a developer pushing a tag could inadvertently trigger a deployment job meant only for branch-level integration tests. This could lead to unauthorized production deployments or the execution of heavy-weight integration suites on lightweight tag-based releases.
Deprecation and the Transition to Rules
The GitLab CI/CD ecosystem is moving away from the only and except keywords in favor of the rules syntax. This is not merely a cosmetic change; it is a fundamental shift toward a more unified logic engine.
Several specific uses of only and except are now officially deprecated:
only:variables: Using variables to control job addition viaonlyis deprecated.except:variables: Using variables to exclude jobs viaexceptis deprecated.only:changes: Using thechangeskeyword underonlyis deprecated.except:changes: Using thechangeskeyword underexceptis deprecated.
The replacement for these patterns is the rules:if and rules:changes syntax. The rules keyword provides a much more powerful way to evaluate complex conditional logic, allowing for the use of all rules keywords like if, changes, and exists within a single rule block.
The transition from only:variables to rules:if is particularly important for security and configuration management. Under the old system, a developer might use:
yaml
deploy:
script: cap staging deploy
only:
variables:
- $RELEASE == "staging"
- $STAGING
In the modern implementation, this should be refactored to use rules:if to ensure compatibility with future GitLab versions and to allow for more complex boolean logic (e.g., combining variable checks with path changes).
Furthermore, the deprecation of only:changes is a critical point for engineers managing monorepos. The changes keyword is highly sensitive to the context of the Git reference. When using only:changes, GitLab evaluates the files modified in the current push. However, as noted in technical discussions, there is a significant limitation when applying changes to tags.
The Logical Conflict of Tag-Based Path Changes
A frequent challenge encountered by DevOps engineers is the attempt to create a job that runs only when a specific folder has changed AND the pipeline is a tag.
A common problematic configuration looks like this:
yaml
test:
stage: test
when: manual
allow_failure: false
needs:
- build
only:
changes:
- tests/*
refs:
- tags
In the example above, the developer intends for the job to appear only if a tag is pushed and the tests/ directory has modified files. However, the actual behavior is an "OR" logic rather than an "AND" logic. In many cases, the job will appear even if the tests/ folder has not changed.
The technical reason for this lies in how Git tags function. A tag is a static reference to a specific commit. Unlike a branch, which is a moving pointer that tracks new commits, a tag does not inherently carry the "context" of what changed between itself and a previous state within the only:changes logic. Because a tag is a single point in time, GitLab's only:changes implementation often assumes that the changes clause evaluates to true for tag pipelines because there is no clear "diff" context provided by the tag reference itself in the same way a branch push provides one.
To solve this, engineers cannot rely on only:changes. Instead, they must implement a manual logic check within a script block. This involves:
- Identifying the current tag.
- Identifying the previous tag using Git commands.
- Running a
git diffbetween the two references. - Parsing the output to see if any files in the target directory (e.g.,
tests/*) are present in the diff.
This approach is significantly more complex and requires the runner to have sufficient Git access and depth (shallow clones can break this logic).
Advanced Pipeline Source Control
To master pipeline configuration, one must understand the full spectrum of CI_PIPELINE_SOURCE values. This variable allows for granular control over job execution based on how the pipeline was birthed.
The following table provides an exhaustive list of pipeline sources and their implications for job configuration:
| Value | Description |
|---|---|
| api | Pipelines triggered by the GitLab Pipelines API. |
| chat | Pipelines created via GitLab ChatOps commands. |
| external | Pipelines triggered by non-GitLab CI services. |
| externalpullrequest_event | Pipelines triggered by GitHub pull requests. |
| mergerequestevent | Pipelines for merge requests (required for merge trains). |
| ondemanddastscan | Pipelines for DAST on-demand scanning. |
| ondemanddastvalidation | Pipelines for DAST on-demand validation. |
| parent_pipeline | Pipelines triggered as children of a parent pipeline. |
| pipeline | Multi-project pipelines. |
| push | Git push events (includes branches and tags). |
| schedule | Pipelines triggered by a predefined schedule. |
| securityorchestrationpolicy | Pipelines for scheduled scan execution policies. |
| trigger | Pipelines created using a trigger token. |
| web | Pipelines created manually in the GitLab UI. |
| webide | Pipelines created via the GitLab Web IDE. |
Using these values in rules:if allows for the creation of highly specialized workflows. For example, one might want to ensure that a deployment job never runs on a schedule but always runs on a web trigger.
yaml
deploy_job:
script:
- echo "Deploying..."
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- if: $CI_PIPELINE_SOURCE == "web"
when: always
- if: $CI_PIPELINE_SOURCE == "push"
when: on_success
Avoiding the Dual-Pipeline Trap
One of the most dangerous pitfalls in GitLab CI/CD configuration is the mixing of only/except and rules within the same pipeline. While this might not trigger a YAML syntax error, it creates a logical nightmare that is extremely difficult to debug.
The primary issue is that only/except and rules have different default behaviors. For instance, jobs without any rules default to an implicit except: merge_requests. If one job in a pipeline uses rules and another uses the default behavior, a single push to a branch with an open merge request can trigger two separate pipelines:
- A branch pipeline running the job with no rules.
- A merge request pipeline running the job with
rules.
This redundancy wastes compute minutes and can lead to race conditions in deployment stages. To prevent this, engineers should use workflow: rules to define the global logic for when a pipeline should be created at all.
An example of a problematic configuration that creates double pipelines:
```yaml
job-with-no-rules:
script: echo "This job runs in branch pipelines."
job-with-rules:
script: echo "This job runs in merge request pipelines."
rules:
- if: $CIPIPELINESOURCE == "mergerequestevent"
```
To mitigate this, a workflow block should be implemented at the top level of the .gitlab-ci.yml file to unify the pipeline creation logic:
yaml
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAG
By centralizing the logic in workflow: rules, the engineer ensures that the decision to create a pipeline is made once, consistently, preventing the instantiation of duplicate pipelines for the same commit.
Summary of Best Practices for Modern Pipelines
To ensure high availability and reliability of CI/CD workflows, the following technical standards should be adhered to:
- Prioritize
rules:ifover the deprecatedonlyandexceptkeywords. - Use
workflow: rulesto prevent duplicate pipelines caused by overlappingpushandmerge_request_eventtriggers. - When monitoring file changes, use
rules:changesfor branches and merge requests, but implement manualgit difflogic for tag-based path detection. - Avoid mixing
only/exceptsyntax withrulessyntax in the same configuration file. - Leverage the
CI_PIPELINE_SOURCEvariable to explicitly define the allowed entry points for sensitive jobs (e.g., restrictingdeployjobs toweborapisources). - Utilize
!referencetags to promote the reuse of complex rule sets across multiple jobs, ensuring a single source of truth for deployment logic.
The complexity of GitLab's pipeline engine requires a deep understanding of the relationship between Git refs, pipeline sources, and the evolving syntax of the configuration file. By moving away from legacy only patterns and embracing the structured logic of rules, engineers can build pipelines that are not only automated but also resilient and transparent.