The architecture of GitLab CI/CD pipelines relies heavily on the ability to control job execution based on specific environmental triggers, git references, and file system changes. Historically, this control was exerted through the only and except keywords. However, as the GitLab ecosystem has evolved toward a more complex, multi-source pipeline model—incorporating merge requests, scheduled pipelines, and API-driven triggers—the logic required to manage these intersections has grown exponentially. The transition from the legacy only/except syntax to the modern rules syntax represents a fundamental shift from simple inclusion/exclusion lists to a sophisticated conditional evaluation engine. Understanding the nuances of this transition is critical for DevOps engineers to prevent common pitfalls such as duplicate pipelines, stuck manual jobs, and the "always true" evaluation trap in tag-based pipelines.
The Legacy Mechanics of Only and Except
The only and except keywords serve as the original gatekeepers for job execution in GitLab CI/CD. These keywords allow developers to define which git references, branches, or tags should trigger a specific job. When a job is configured with only, it is added to the pipeline only if the current git reference meets the specified criteria.
The behavior of these keywords is deeply tied to how GitLab identifies a pipeline's origin. A pipeline can be triggered by a push to a branch, the creation of a tag, a web trigger via the GitLab UI, or a trigger token. The only keyword acts as a filter for these references.
For instance, a job can be restricted to specific branches or even regex-based patterns. If a developer defines a job with only: - main, that job will only exist in pipelines where the commit belongs to the main branch. This can be extended to complex patterns using regular expressions:
yaml
job1:
script: echo "Running on main or issue branches"
only:
- main
- /^issue-.*$/
- merge_requests
Conversely, the except keyword provides the inverse logic, allowing jobs to be excluded from specific events. A job might be allowed to run on everything except the main branch or certain stable branches:
yaml
job2:
script: echo "Running everywhere except main and stable branches"
except:
- main
- /^stable-branch.*$/
- schedules
It is important to note that if a job does not explicitly use only, except, or rules, GitLab applies a default configuration. In this default state, the job is effectively configured with only: branches and only: tags. This means the job will run on any branch push or any tag creation. This default behavior is a frequent source of confusion for engineers who assume a job will run on all pipeline types without explicit configuration.
Furthermore, there is a functional equivalence between certain shorthand and explicit syntaxes. For example, the configuration only: - branches is identical to only: refs: - branches. This equivalence stems from the fact that both ultimately point to the git reference type.
The Deprecation of Variable-Based Logic
As GitLab's pipeline engine moved toward the rules system, several specific usages of only and except were officially deprecated. The most significant of these is the use of only:variables and except:variables. These keywords were previously used to control job execution based on the status or presence of CI/CD variables.
The impact of this deprecation is significant for legacy pipeline maintenance. In older pipelines, a developer might have used a variable to decide whether a deployment job should run. Under the modern standard, this must be refellavered using the rules:if syntax. Using the deprecated syntax may lead to unpredictable behavior or failure to execute in future GitLab versions.
The modern approach requires the use of the rules keyword combined with the if clause to evaluate variable states. This allows for much more complex logic, such as checking if a variable matches a string, if a variable is present, or if multiple conditions are met simultaneously.
| Deprecated Keyword | Modern Replacement | Logic Type |
|---|---|---|
| only:variables | rules:if (variable check) | Conditional Variable Evaluation |
| except:variables | rules:if (variable negation) | Conditional Variable Exclusion |
The shift to rules also extends to the kubernetes keyword. Previously, certain configurations for Kubernetes-based deployments were handled directly under the kubernetes section. Specifically, environment:kubernetes:namespace and coniguration:kubernetes:flux_resource_path are now deprecated when used directly under the kubernetes key. To maintain compatibility and leverage the modern dashboard features, these should be moved to environment:kubernetes:dashboard:namespace and environment:kubernetes:dashboard:flux_resource_path.
The Logic Trap of Tag-Based Changes
One of the most complex challenges in GitLab CI/CD configuration involves the intersection of git tags and the changes clause. A common requirement is to run a job only when a specific folder (e.g., tests/) has changed AND the pipeline is running on a tag.
The only:changes syntax, while intuitive, suffers from a fundamental limitation regarding tags. In a git-based workflow, a tag is a static reference to a specific commit. Unlike a branch, which has a moving head and a clear lineage of recent commits, a tag does not inherently provide the context of "what changed" between the current state and a previous state within the pipeline's execution context.
Because of this lack of context, GitLab assumes that when a changes clause is evaluated during a tag pipeline, the changes condition evaluates to true. This is a critical realization for engineers. If a developer attempts to use the following configuration:
yaml
test:
stage: test
when: manual
only:
changes:
- tests/*
refs:
- tags
They will find that even if no files in the tests/ folder were modified in the commit associated with the tag, the job will still appear in the pipeline. This happens because the only keyword treats the changes and refs as an "OR" condition rather than an "AND" condition. The job appears if it is a tag OR if there are changes. If it is a tag, the job is included regardless of the folder status.
The consequence of this is that manual jobs can become "stuck" in a pipeline. If allow_failure: false is set, and the job appears but the developer does not want it to run because the folder hasn't changed, the pipeline cannot proceed to subsequent stages until that manual job is manually triggered or bypassed.
The only viable workaround for this specific "AND" logic requirement involves moving away from only and utilizing rules. A more robust implementation uses rules:if to check for the existence of the tag variable and rules:changes to monitor the directory:
yaml
test:
stage: test
rules:
- if: '$CI_COMMIT_TAG'
changes:
- tests/*
when: manual
- when: never
script:
- echo "This job only appears if it's a tag AND the tests folder changed"
In this configuration, the rules engine evaluates the conditions sequentially. If the CI_COMMIT_TAG is present and the files in tests/* have changed, the job is added as a manual job. If those conditions are not met, the when: never clause ensures the job is excluded entirely.
Pipeline Source Variables and Complex Triggering
To master modern pipeline configuration, one must understand the CI_PIPELINE_SOURCE predefined variable. This variable is the cornerstone of the rules:if logic, allowing for granular control over different pipeline origins.
The following table details the primary pipeline sources and their impact on job availability:
| Variable Value | Description | Branch Pipeline | Tag Pipeline | Merge Request Pipeline | Scheduled Pipeline |
|---|---|---|---|---|---|
push |
Standard git push | Yes | Yes | No | No |
merge_request_event |
Created/updated MR | No | No | Yes | No |
schedule |
Triggered by a schedule | Yes | Yes (if configured) | No | Yes |
web |
Triggered via GitLab UI | Yes | Yes | No | No |
api |
Triggered via API | Yes | Yes | No | No |
chat |
Triggered via ChatOps | Yes | Yes | No | No |
external |
External CI services | Yes | Yes | No | No |
A critical risk in modern GitLab configurations is the "double pipeline" phenomenon. This occurs when a single push triggers both a branch pipeline and a merge request pipeline. This is typically caused by having a job that responds to push and another job that responds to merge_request_event within the same pipeline configuration.
To prevent this, developers should implement a workflow:rules block at the top of their .gitlab-ci.yml. This global ruleset acts as a filter for the entire pipeline, deciding whether a pipeline should be created at all.
yaml
workflow:
rules:
- if: $CI_MERGE_REQUEST_ID
when: never
- when: always
By using when: never for CI_MERGE_REQUEST_ID, you ensure that if a merge request is active, GitLab does not start a separate, redundant branch pipeline.
Furthermore, mixing only/except and rules within the same pipeline is highly discouraged. While it may not result in a YAML syntax error, the different default behaviors—specifically how only defaults to branches and tags while rules requires explicit definition—can create "ghost" jobs that run unexpectedly. For example, a job with no rules will run in branch pipelines but will be excluded from merge request pipelines because the default behavior of jobs with no rules is except: merge_requests.
Advanced Rule Reuse and Maintenance
As CI/CD configurations grow in complexity, maintaining repetitive rule blocks becomes impossible. GitLab provides the !reference tag to facilitate the reuse of configuration fragments. This allows an engineer to define a standard set of deployment rules in one place and inject them into multiple jobs.
```yaml
.deployrules:
rules:
- if: '$CICOMMITTAG'
changes:
- deploy/*
when: onsuccess
- when: never
deployjob:
stage: deploy
!reference [.deployrules, rules]
script:
- echo "Deploying..."
```
This approach reduces the surface area for errors and ensures that changes to the deployment logic (such as updating a directory path) only need to be made in a single location.
Conclusion
The evolution from only and except to rules is not merely a syntax change but a transition to a more robust, conditional programming model for DevOps. The primary lesson for engineers is the recognition of the logical differences between these systems. The only keyword's "OR" logic is inherently incapable of handling complex "AND" requirements for tag-based file changes, necessitating the move to rules. Furthermore, the management of CI_PIPELINE_SOURCE and the implementation of workflow:rules are essential to prevent the technical debt of duplicate pipelines and unmanageable manual jobs. Mastering these nuances ensures that pipelines remain predictable, efficient, and scalable within a modern microservices architecture.