Пайплайн 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 Нет Нет Нет