GitLab CI multi-environment pipeline: workflow rules and deploy gates

Published: 2026-05-26

We run four deployment targets — Development, Test (Staging), Pre-production, and Production — from a single .gitlab-ci.yml that every backend service inherits via include. The challenge is making the pipeline smart enough that:

  • feature branches run tests but don't deploy
  • dev branch auto-deploys to Development
  • rel/* branches deploy to Test/Demo automatically and Production manually
  • Production deploy is gated to specific people for critical services

Shared pipeline via remote include

Each application repo's .gitlab-ci.yml contains only the project-specific bits and pulls stages from the common CI project:

yaml# In the application repo
variables:
  CUSTOM_PROJECT_NAME: my-service
  CHART_NAME: my-service

include:
  - project: "ci/common-ci"
    ref: dev
    file: ci-cd/dotnet/.gitlab-ci.yml

The common pipeline defines stages, workflow.rules, and all reusable jobs. The application repo can override variables but inherits all jobs.


workflow.rules: what triggers a pipeline

Without workflow.rules, GitLab creates a pipeline for every push and every MR, which doubles job counts and wastes runner time. We allow only meaningful events:

yamlworkflow:
  rules:
    # Run on MR (for code review and test)
    - if: $CI_MERGE_REQUEST_ID
    # Run on dev branch push
    - if: $CI_COMMIT_BRANCH == "dev"
    # Run on feature/fix/release branches
    - if: $CI_COMMIT_BRANCH =~ /^feat\/.*/
    - if: $CI_COMMIT_BRANCH =~ /^fix\/.*/
    - if: $CI_COMMIT_BRANCH =~ /^rel\/.*/
    # Run on MR targeting dev or release branch
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^rel\/.*/
    # Suppress duplicate pipeline when MR is open for a branch
    - if: "$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS"
      when: never

The last rule is critical: when a branch has an open MR, don't run the branch pipeline — only the MR pipeline runs. Without it, every push to a feature branch with an open MR creates two pipelines.


Stages

yamlstages:
  - sonarqube    # static analysis, runs on dev branch only
  - test         # dotnet test, runs on most branches
  - cover_compare # coverage regression check, runs on MR
  - build        # docker build + push, runs on dev + rel/*
  - deploy       # helm upgrade per environment, manual for prod
  - autotests    # integration tests triggered via API

Stages are global. Each job declares which stage it belongs to and its own rules.


Test job: branch-aware image tag

The .NET SDK version differs by project. Rather than one job per SDK version, a single test job switches the image tag via variables override in rules:

yamltest:
  stage: test
  image: ${DOCKER_TEST_IMAGE}
  variables:
    DOCKER_TEST_IMAGE: registry.example.com/ci-images/dotnet-build:${DOCKER_TEST_IMAGE_TAG}
    DOCKER_TEST_IMAGE_TAG: net6   # default
  script:
    - dotnet build -c Release src/
    - dotnet test -c Release --no-build --filter Category!~IntegrationExternal src/
    - dotnet publish -c Release -o app/ src/ --no-build
  artifacts:
    paths:
      - app/
    expire_in: 100 days
  rules:
    # Override to .net8 image for projects that need it
    - if: $CI_PROJECT_PATH_SLUG =~ /^(my-api|other-api|...)/
      variables:
        DOCKER_TEST_IMAGE_TAG: net8
    # Skip duplicate pipeline on branch with open MR
    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_OPEN_MERGE_REQUESTS == "true"'
      when: never
    - if: $CI_COMMIT_BRANCH == "dev"
    - if: $CI_COMMIT_BRANCH =~ /^rel\/.*/
    - if: $CI_COMMIT_BRANCH =~ /^feat\/.*/
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^rel\/.*/

Deploy jobs: one per environment

All deploy jobs extend .deploy, which contains the actual helm upgrade logic. Each job only sets APP_ENV and overrides rules:

yamldeploy_dev:
  extends: .deploy
  before_script:
    - *auth_jwt
  variables:
    APP_ENV: Development
  rules:
    - if: $CI_COMMIT_BRANCH == "dev"
      # automatic — every push to dev auto-deploys

deploy_test:
  extends: .deploy
  before_script:
    - *auth_jwt
  variables:
    APP_ENV: Test
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^rel\/.*/
      # automatic on release branches

deploy_demo:
  extends: .deploy
  before_script:
    - *auth_jwt
  variables:
    APP_ENV: Demo
  rules:
    - if: $CI_COMMIT_BRANCH =~ /^rel\/.*/

deploy_pre_prod:
  extends:
    - .deploy
    - .grafana:notify   # posts Grafana annotation after deploy
  before_script:
    - *auth_jwt
  variables:
    APP_ENV: Pre-production
    ENV_PREFIX: -preprod   # helm release name suffix

deploy_prod:
  extends:
    - .deploy
    - .grafana:notify
  before_script:
    - *auth_jwt
  variables:
    APP_ENV: Production
  rules:
    # For critical services: restrict to specific usernames
    - if: >
        $CI_COMMIT_BRANCH =~ /^rel\/.*/ &&
        ($GITLAB_USER_LOGIN == "alice" || $GITLAB_USER_LOGIN == "bob") &&
        $CI_PROJECT_PATH_SLUG =~ /^(critical-service-1|critical-service-2)$/
      when: manual
    # Everyone else on a release branch
    - if: >
        $CI_COMMIT_BRANCH =~ /^rel\/.*/ &&
        $CI_PROJECT_PATH_SLUG !~ /^(critical-service-1|critical-service-2)$/
      when: manual

when: manual means the button appears in the GitLab pipeline UI but doesn't run automatically. For Production this is always manual — someone has to click it.


Coverage regression check

The cover_compare job is a hidden job (prefixed with .) extended by a per-project job when needed. It calls the GitLab API to compare the current pipeline's coverage against the last successful pipeline on the target branch:

yaml.cover_compare:
  stage: cover_compare
  image: alpine:latest
  script:
    - apk add --no-cache jq curl
    # Get current pipeline coverage
    - latest=$(curl -sH "PRIVATE-TOKEN: $ACCESS_TOKEN_READ"
        "$CI_API_V4_URL/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}"
        | jq -r '.coverage // "0"' | cut -d. -f1)
    # Get last successful pipeline coverage on the target branch
    - success_id=$(curl -sH "PRIVATE-TOKEN: $ACCESS_TOKEN_READ"
        "$CI_API_V4_URL/projects/${CI_PROJECT_ID}/pipelines?ref=${CI_COMMIT_REF_SLUG}&status=success"
        | jq -r '.[0].id')
    - prev=$(curl -sH "PRIVATE-TOKEN: $ACCESS_TOKEN_READ"
        "$CI_API_V4_URL/projects/${CI_PROJECT_ID}/pipelines/${success_id}"
        | jq -r '.coverage // "0"' | cut -d. -f1)
    # Notify regardless of direction — fail silently, just send message
    - |
      if [ "$latest" -ge "$prev" ]; then
        msg="${CI_PROJECT_PATH_SLUG}: coverage ${latest}% >= ${prev}% OK"
      else
        msg="${CI_PROJECT_PATH_SLUG}: coverage dropped ${latest}% < ${prev}%"
      fi
      curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
        -d chat_id="${TG_CHAT_ID}" -d text="$msg"
  rules:
    - if: $CI_MERGE_REQUEST_ID
  allow_failure: true

allow_failure: true so a coverage drop doesn't block the MR — it's informational only. The Telegram notification goes to a dedicated QA channel.


Grafana deploy annotation

Every production and pre-production deploy posts an annotation to Grafana. This marks the deployment time on dashboards so you can correlate metric changes with deploys:

yaml.grafana:notify:
  after_script:
    - >
      curl -sX POST ${GRAFANA_URL}/api/annotations
      -H "Content-Type: application/json"
      -H "Authorization: Bearer ${GRAFANA_TOKEN}"
      -d "{
        \"text\": \"Deployed ${CI_PROJECT_PATH_SLUG} ${CI_COMMIT_BRANCH}\",
        \"tags\": [\"deployment\", \"env:${APP_ENV}\"]
      }"

Jobs that need this extend both .deploy and .grafana:notify:

yamldeploy_prod:
  extends:
    - .deploy
    - .grafana:notify

GitLab merges the after_script from .grafana:notify into the job. The annotation appears on every Grafana dashboard as a vertical line labeled with the service name.


Summary of rules logic

Branch Build Test Deploy
feat/* No Yes No
MR → dev No Yes No
dev Yes Yes Dev (auto)
rel/* Yes Yes Test/Demo (auto), Pre-prod/Prod (manual)
MR → master No No No