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
devbranch auto-deploys to Developmentrel/*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 |