DEV Community

Cover image for 🦊 GitLab CI: The Secret MR Labels That Make Your Pipeline Do What You Want
Benoit COUETIL πŸ’« for Zenika

Posted on

🦊 GitLab CI: The Secret MR Labels That Make Your Pipeline Do What You Want

Initial thoughts

Large projects face two recurring challenges:

  1. Multiple environments: dev1, dev2, staging, qa... How do developers choose their target per MR?
  2. 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:

Diagram

Diagram

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:

Diagram

Diagram

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):

Diagram

Diagram

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/
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:-.*}"
Enter fullscreen mode Exit fullscreen mode

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.

Indiana fox

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:

  1. Create a milestone for each environment: dev1, dev2, staging...
  2. Developer assigns their MR to the desired milestone
  3. Pipeline automatically picks up the environment configuration

Jobs reference these variables naturally:

deploy:
  script:
    - ./deploy.sh --environment=$TARGET_ENV
  environment:
    name: $TARGET_ENV
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This produces:

dev1 ⬅️ feature/login-page πŸ”€ Add OAuth support
staging ⬅️ develop πŸ”€ Merge branch 'feature/login-page'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Indiana fox

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)