- Initial thoughts
- 1. The challenge: flexible pipelines without YAML edits
- 2. Labels and milestones in action
- 3. Labels as pipeline switches
- 4. Essential labels for every project
- 5. Milestones as environment selectors
- 6. Manual pipeline override
- 7. Pipeline naming for clarity
- 8. Complete workflow rules example
- Wrapping up
- Further reading
Initial thoughts
Large projects face two recurring challenges:
-
Multiple environments:
dev1,dev2,staging,qa... How do developers choose their target per MR? - Rigid pipelines: "Can you skip E2E tests? I just changed a button color and I've been staring at a progress bar for 20 minutes."
The traditional answers involve editing .gitlab-ci.yml, creating complex variable conditions, or requiring maintainer access. But GitLab offers elegant solutions hiding in plain sight: MR labels as pipeline switches and MR milestones as environment selectors. We'll start with concrete MR β pipeline examples, then unpack the YAML that drives them.
1. The challenge: flexible pipelines without YAML edits
| Need | Traditional approach | Problem |
|---|---|---|
| Deploy to specific env | Separate .gitlab-ci.yml per env |
Duplication nightmare |
| Skip slow tests | Edit CI config | Requires maintainer access |
| Force rebuild | Make fake changes | Wastes time, pollutes history |
| Override cache | Ask maintainer | Bottleneck |
Both milestones and labels solve these by using MR metadata that developers can modify themselves.
Why not just use manual pipeline variables? An MR typically triggers dozens of pipelines over its lifetime (each push, each rebase, each retry). Manually selecting the environment or toggling options every single time is tedious and error-prone. Labels and milestones are set once on the MR and automatically apply to every subsequent pipeline.
β οΈ Prerequisite: This pattern relies on MR pipelines (
$CI_MERGE_REQUEST_*variables). For non-MR cases (branches, tags), the manual launch with prefilled variables covers those scenarios (see section 6).
2. Labels and milestones in action
rules:changes already picks modules from the diff β labels are not a slower way to say the same thing. They cover what path matching cannot: scoped E2E suites, CI self-tests, cache/deploy knobs, and environment choice via milestone. Three MRs where the sidebar does real work β Labels + Milestone, then the pipeline that follows.
See the full job graph
The diff is only .gitlab-ci.yml. Module changes: rules correctly stay quiet β and that is exactly the problem when you need to see every job before merge. full-pipeline forces the whole graph:
That is the baseline DAG. The next two examples carve it down with more specific labels.
Cherry-pick E2E suites on a target env
E2E stays manual by default. The back is already in from changes: β we just want E2E automatic, but only Contact + Order β not the three-hour "run everything" tax β on staging:
e2e-tests flips to on_success (no Manual button). The job script reads $CI_MERGE_REQUEST_LABELS, keeps tags matching E2E|*, and passes them to Playwright/Cypress β Contact and Order only. Milestone sets TARGET_ENV=staging.
Bust a rotten cache on a pinned env
Yesterday's cache is haunted. The back is already selected by changes: β you just want a clean install and land on dev1 (E2E stays manual; nobody has to click it):
Three intents β full CI rehearsal, scoped E2E, cache/env surgery β stacked on top of rules that already do their job. Now the YAML behind it.
3. Labels as pipeline switches
GitLab exposes MR labels through $CI_MERGE_REQUEST_LABELS. This comma-separated string can be matched in rules:
build-backend:
script:
- npm run build
rules:
# Normal trigger: changes in backend files
- if: $CI_MERGE_REQUEST_ID
changes:
- src/backend/**/*
# Override: force-build-back label
- if: $CI_MERGE_REQUEST_LABELS =~ /force-build-back/
Labels are instantly visible in the MR interface, can be added/removed without commits, and trigger new pipelines automatically when changed.
4. Essential labels for every project
Force-build labels
Override changes: detection when automatic detection isn't enough:
| Label | Purpose |
|---|---|
force-build-back |
Force backend build |
force-build-front |
Force frontend build |
force-build-all |
Force all build jobs |
These labels also play a key role in automated testing of job rules, where they simulate module changes in test scenarios.
Pipeline testing
When we modify .gitlab-ci.yml, the MR diff usually contains only CI files β so changes: rules skip most jobs. We end up merging pipeline changes without ever seeing the full job graph run. SchrΓΆdinger's CI: the config is both correct and broken until production proves otherwise.
The full-pipeline label solves this: add it to the MR, and every job runs regardless of file changes. Perfect for validating pipeline modifications before merge.
Add the label as a fallback rule alongside your module-specific changes: and force-build-* rules:
.api-mr-rules:
rules:
- if: $CI_MERGE_REQUEST_ID
changes:
- src/backend/**/*
- if: $CI_MERGE_REQUEST_LABELS =~ /force-build-back/
- if: $CI_MERGE_REQUEST_LABELS =~ /full-pipeline/
For day-to-day CI maintenance, the label is enough. For systematic non-regression across every pipeline type (MR, tags, protected branches, scheduledβ¦), combine it with the approach described in Automated Testing of Job Rules β the force-build-* labels simulate per-module changes in test scenarios, while full-pipeline covers the "run everything" case.
Cache control
Stale caches cause mysterious build failures. The no-cache label lets developers opt out without editing the job definition:
build-job:
script:
- if [[ "$CI_MERGE_REQUEST_LABELS" =~ "no-cache" ]]; then
rm -rf node_modules/
fi
- npm ci
Deployment control
Not every MR should hit a running environment. These labels give developers control over deploy timing without touching YAML:
deploy:
rules:
- if: $CI_MERGE_REQUEST_LABELS =~ /skip-deploy/
when: never
- if: $CI_MERGE_REQUEST_LABELS =~ /manual-deploy/
when: manual
- when: on_success
Test control
E2E tests are expensive. By default they stay manual; labels let developers opt in, opt out, or narrow the suite per MR:
| Label | Purpose |
|---|---|
e2e-auto-trigger |
Run E2E tests automatically (normally manual) |
skip-e2e |
Skip E2E tests entirely |
| `E2E\ | CONTACT` |
| `E2E\ | ORDER` |
e2e-tests:
rules:
- if: $CI_MERGE_REQUEST_LABELS =~ /skip-e2e/
when: never
- if: $CI_MERGE_REQUEST_LABELS =~ /e2e-auto-trigger/
when: on_success
- when: manual
script:
# Collect E2E|* labels β Playwright/Cypress tags (CONTACT, ORDER, β¦)
- |
TAGS=$(echo "$CI_MERGE_REQUEST_LABELS" | tr ',' '\n' \
| sed -n 's/^E2E|//p' | paste -sd, -)
npm run e2e -- --grep "${TAGS:-.*}"
Stack e2e-auto-trigger with one or more E2E|* labels to auto-run only the suites you care about. No category labels means "all suites" (or your project default) β document that choice so developers are not surprised by a three-hour bill.
5. Milestones as environment selectors
Labels work great for toggling behaviors, but GitLab environment names do not support regular expressions. The solution: repurpose milestones as environment selectors. The $CI_MERGE_REQUEST_MILESTONE variable provides a dedicated, single-value field.
β οΈ Important: This approach only works if your project doesn't already use milestones for their original purpose (sprint/release tracking). You can't have both.
The workflow:
- Create a milestone for each environment:
dev1,dev2,staging... - Developer assigns their MR to the desired milestone
- Pipeline automatically picks up the environment configuration
Jobs reference these variables naturally:
deploy:
script:
- ./deploy.sh --environment=$TARGET_ENV
environment:
name: $TARGET_ENV
For specific pipeline configurations, keep environment details in a separate file and load them dynamically in your pipeline.
6. Manual pipeline override
Sometimes developers need to override both milestone and labels. Add a variable dropdown:
variables:
ENVIRONMENT:
value: ""
description: "Override target environment (leave empty to use milestone)"
options:
- ""
- dev1
- dev2
- staging
workflow:
rules:
# Manual override takes priority
- if: $ENVIRONMENT != ""
variables:
TARGET_ENV: $ENVIRONMENT
# Then milestone-based selection
- if: $CI_MERGE_REQUEST_MILESTONE
variables:
TARGET_ENV: $CI_MERGE_REQUEST_MILESTONE
# Fallback
- when: always
variables:
TARGET_ENV: default
The options: key creates a dropdown in the "Run pipeline" UI.
7. Pipeline naming for clarity
With multiple environments, pipeline lists become confusing. Use workflow:name:
workflow:
name: $TARGET_ENV β¬
οΈ $CI_COMMIT_REF_NAME π $CI_COMMIT_TITLE
This produces:
dev1 β¬
οΈ feature/login-page π Add OAuth support
staging β¬
οΈ develop π Merge branch 'feature/login-page'
At a glance, you know which environment each pipeline targets.
8. Complete workflow rules example
Here's a production-ready configuration combining milestones, labels, and manual override:
workflow:
name: $TARGET_ENV β¬
οΈ $CI_COMMIT_REF_NAME
rules:
# Tag with production override β Production
- if: $CI_COMMIT_TAG && $ENVIRONMENT == "production"
variables:
TARGET_ENV: production
DEPLOY_TIER: Release
# Tag β Pre-production (default)
- if: $CI_COMMIT_TAG
variables:
TARGET_ENV: preprod
DEPLOY_TIER: PreProd
# Manual pipeline with environment override
- if: $CI_PIPELINE_SOURCE == "web" && $ENVIRONMENT != ""
variables:
TARGET_ENV: $ENVIRONMENT
DEPLOY_TIER: Testing
# MR with milestone
- if: $CI_MERGE_REQUEST_MILESTONE
variables:
TARGET_ENV: $CI_MERGE_REQUEST_MILESTONE
DEPLOY_TIER: Testing
# Protected branches
- if: $CI_COMMIT_REF_PROTECTED == "true"
variables:
TARGET_ENV: $CI_COMMIT_REF_NAME
DEPLOY_TIER: Staging
# MR without milestone β default
- if: $CI_MERGE_REQUEST_ID
variables:
TARGET_ENV: default
DEPLOY_TIER: Testing
Jobs combine environment variables with label checks:
deploy:
script:
- echo "Deploying to $TARGET_ENV"
environment:
name: $TARGET_ENV
rules:
- if: $CI_MERGE_REQUEST_LABELS =~ /skip-deploy/
when: never
- if: $DEPLOY_TIER == "Testing"
when: on_success
- if: $DEPLOY_TIER == "Release"
when: manual
Wrapping up
Labels and milestones transform the developer experience with CI/CD:
| Feature | Labels | Milestones |
|---|---|---|
| Use case | Job behavior control | Environment selection |
| Native purpose | β Categorization | β οΈ Sprint planning (repurposed) |
| Visibility | MR sidebar | MR sidebar |
| Pipeline trigger | Auto-retriggers | Auto-retriggers |
Key principles:
- Labels for controlling how the pipeline behaves
- Milestones for choosing where to deploy (if not used for sprints)
- Variables for exceptional manual overrides
- Document everything so developers know their options
The investment in setup pays off every time a developer can solve their own pipeline problem without asking for help.
Illustrations generated locally by Draw Things using Flux.1 [Schnell] model
Further reading
This article was enhanced with the assistance of an AI language model to ensure clarity and accuracy in the content, as English is not my native language.








Top comments (0)