Пайплайн GitLab CI на несколько сред: workflow rules и ручные деплои
Published: 2026-05-26
У нас четыре целевые среды деплоя — Development, Test (Staging), Pre-production и Production — и один общий .gitlab-ci.yml, который каждый backend-сервис подключает через include. Задача — сделать пайплайн достаточно умным, чтобы:
- feature-ветки запускали тесты, но не деплоились
- ветка
devавтоматически деплоилась в Development - ветки
rel/*автоматически деплоились в Test/Demo и вручную в Production - деплой в Production был ограничен конкретными людьми для критичных сервисов
Общий пайплайн через remote include
.gitlab-ci.yml каждого приложения содержит только специфичные для проекта переменные и подтягивает этапы из общего CI-проекта:
yaml# В репозитории приложения
variables:
CUSTOM_PROJECT_NAME: my-service
CHART_NAME: my-service
include:
- project: "ci/common-ci"
ref: dev
file: ci-cd/dotnet/.gitlab-ci.yml
Общий пайплайн определяет stages, workflow.rules и все переиспользуемые job. Репозиторий приложения может переопределять переменные, но job наследует целиком.
workflow.rules: что запускает пайплайн
Без workflow.rules GitLab создаёт пайплайн на каждый push и каждый MR — job дублируются, а ресурсы раннеров тратятся впустую. Мы разрешаем только значимые события:
yamlworkflow:
rules:
# Запускать на MR (для code review и тестов)
- if: $CI_MERGE_REQUEST_ID
# Запускать при push в ветку dev
- if: $CI_COMMIT_BRANCH == "dev"
# Запускать на feature/fix/release ветках
- if: $CI_COMMIT_BRANCH =~ /^feat\/.*/
- if: $CI_COMMIT_BRANCH =~ /^fix\/.*/
- if: $CI_COMMIT_BRANCH =~ /^rel\/.*/
# Запускать на MR в dev или release ветку
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^rel\/.*/
# Подавить дублирующий пайплайн когда для ветки открыт MR
- if: "$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS"
when: never
Последнее правило — критично: если для ветки есть открытый MR, не запускать branch-пайплайн — запускается только MR-пайплайн. Без этого каждый push в feature-ветку с открытым MR создаёт два пайплайна.
Stages
yamlstages:
- sonarqube # статический анализ, только на ветке dev
- test # dotnet test, на большинстве веток
- cover_compare # проверка регрессии покрытия, на MR
- build # docker build + push, на dev + rel/*
- deploy # helm upgrade по средам, для prod — вручную
- autotests # интеграционные тесты через API
Stages глобальные. Каждый job объявляет свой stage и собственные rules.
Test job: image tag в зависимости от ветки
Версия .NET SDK отличается от проекта к проекту. Вместо отдельного job на каждую версию SDK единственный test job переключает тег образа, переопределяя variables в 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 # значение по умолчанию
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:
# Переопределить на .net8 для нужных проектов
- if: $CI_PROJECT_PATH_SLUG =~ /^(my-api|other-api|...)/
variables:
DOCKER_TEST_IMAGE_TAG: net8
# Пропустить дублирующий пайплайн на ветке с открытым 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: по одному на среду
Все deploy job расширяют .deploy, где лежит сама логика helm upgrade. Каждый job только задаёт APP_ENV и переопределяет rules:
yamldeploy_dev:
extends: .deploy
before_script:
- *auth_jwt
variables:
APP_ENV: Development
rules:
- if: $CI_COMMIT_BRANCH == "dev"
# автоматически — каждый push в dev деплоится
deploy_test:
extends: .deploy
before_script:
- *auth_jwt
variables:
APP_ENV: Test
rules:
- if: $CI_COMMIT_BRANCH =~ /^rel\/.*/
# автоматически на release ветках
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 # постит аннотацию в Grafana после деплоя
before_script:
- *auth_jwt
variables:
APP_ENV: Pre-production
ENV_PREFIX: -preprod # суффикс имени helm release
deploy_prod:
extends:
- .deploy
- .grafana:notify
before_script:
- *auth_jwt
variables:
APP_ENV: Production
rules:
# Для критичных сервисов: ограничить конкретными пользователями
- 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
# Для остальных на release-ветке
- if: >
$CI_COMMIT_BRANCH =~ /^rel\/.*/ &&
$CI_PROJECT_PATH_SLUG !~ /^(critical-service-1|critical-service-2)$/
when: manual
С when: manual job не стартует сам — в UI пайплайна GitLab появляется кнопка. Деплой в Production всегда ручной: кто-то должен нажать.
Проверка регрессии покрытия
Job cover_compare — скрытый (с префиксом .); при необходимости его расширяют в конкретном проекте. Он через GitLab API сравнивает покрытие текущего пайплайна с последним успешным на целевой ветке:
yaml.cover_compare:
stage: cover_compare
image: alpine:latest
script:
- apk add --no-cache jq curl
# Получить покрытие текущего пайплайна
- 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)
# Получить покрытие последнего успешного пайплайна на целевой ветке
- 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)
# Уведомить в любом случае — job не падает, просто отправляет сообщение
- |
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 — падение покрытия не блокирует MR, это только информационное сообщение. Уведомление в Telegram идёт в выделенный QA-канал.
Аннотация деплоя в Grafana
Каждый деплой в production и pre-production постит аннотацию в Grafana. На дашбордах появляется отметка времени деплоя — изменения метрик легко соотнести с релизами:
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}\"]
}"
Job, которым это нужно, расширяют и .deploy, и .grafana:notify:
yamldeploy_prod:
extends:
- .deploy
- .grafana:notify
GitLab объединяет after_script из .grafana:notify в job. Аннотация появляется на каждом Grafana-дашборде как вертикальная линия с именем сервиса.
Сводка логики rules
| Ветка | Build | Test | Deploy |
|---|---|---|---|
feat/* |
Нет | Да | Нет |
MR → dev |
Нет | Да | Нет |
dev |
Да | Да | Dev (авто) |
rel/* |
Да | Да | Test/Demo (авто), Pre-prod/Prod (вручную) |
| MR → master | Нет | Нет | Нет |