Files
mdm-ordinis/.gitlab-ci.yml
T
2026-06-08 10:59:15 +00:00

878 lines
42 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================
# Ordinis — build & push images.
# Path-aware CI: backend и frontend jobs запускаются только когда меняются
# соответствующие пути. Trigger downstream передаёт BACKEND_IMAGE_TAG и/или
# FRONTEND_IMAGE_TAG; infra-pipeline применяет helm upgrade с per-service
# overrides — сервисы которые не пересобирались не катятся.
# =============================================================
stages:
- test
- build
- publish
- trigger-deploy
- release
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn"
MAVEN_CLI_OPTS: "-B -ntp --batch-mode"
IMAGE_TAG: $CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA
REGISTRY: repo.nstart.cloud
IMAGE_REPO: terravault
DOCKER_BUILDKIT: "1"
cache:
key:
files: ["pom.xml"]
paths:
- .m2/repository
default:
# Pin builds to nstart-tagged runners. Без тегов job'ы попадали в shared
# pool и в часы пик висели по 20+ мин в waiting_for_resource (см.
# pipeline 6553 docker-app — 1464s до cancel). Tag nstart берёт наши
# выделенные runners.
tags:
- nstart
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
- script_failure
# Backend changes триггерят maven + docker для всех Java-сервисов + миграции.
.backend-changes: &backend-changes
- "ordinis-app/**/*"
- "ordinis-read-api/**/*"
- "ordinis-projection-writer/**/*"
- "ordinis-rest-api/**/*"
- "ordinis-domain/**/*"
- "ordinis-validation/**/*"
- "ordinis-events-api/**/*"
- "ordinis-outbox/**/*"
- "ordinis-auth/**/*"
- "ordinis-cuod-bundle/**/*"
- "ordinis-migrations/**/*"
- "pom.xml"
- ".gitlab-ci.yml"
# Frontend changes — только admin-ui.
.frontend-changes: &frontend-changes
- "ordinis-admin-ui/**/*"
# Documentation changes — Diplodoc-built integrator guide.
# docs-internal/ исключено явно: оно НЕ build'ится Diplodoc'ом и не должно
# триггерить docs job.
.docs-changes: &docs-changes
- "docs/**/*"
# ─── LINT ──────────────────────────────────────
# Commit-message lint: ловит literal `[skip ci]` в subject/body.
#
# Контекст trap'ы:
# 1. semantic-release auto-генерирует release notes из commit'ов с момента
# последнего tag'а — включает subject + body всех commit'ов.
# 2. Если хотя бы один из этих commit'ов содержит литеральное `[skip ci]`
# (даже описывая что фиксят его, e.g. 'fix: remove [skip ci] из...'),
# эта строка попадает в release commit body.
# 3. Release commit body содержит `[skip ci]` → GitLab матчит → пропускает
# tag pipeline (semantic-release/gitlab plugin создаёт tag, но pipeline
# на этот tag-ref скипается).
# 4. Без tag pipeline нет images → нет commit-prod-image-bump → нет
# bump'а в infra → manual escape потребуется.
#
# Реальный случай: v2.16.1 (2026-05-12) — fix(release): 'remove [skip ci] из
# semantic-release commit message'. Auto-changelog содержал эту строку →
# v2.16.1 tag pipeline skipped → пришлось trigger вручную через API.
#
# Защита: fail CI на любом commit'е (subject ИЛИ body) который содержит
# substring `[skip ci]`. Когда нужно реально skip — использовать иные
# формулировки: `skip-ci marker`, `the skip-ci directive`, etc.
#
# Не блокирует chore(release) commit'ы semantic-release'а потому что
# .releaserc.json message template уже не содержит `[skip ci]` (MR !154).
#
# Skipping этот job сам: rules ниже исключают chore(release) commit'ы
# (subject matches 'chore(release):' regex), потому что body этих commit'ов
# содержит весь changelog со всеми историческими commit'ами — false positive.
commit-msg-lint:
stage: test
image: repo.nstart.cloud/library/alpine:3.20
script:
# Runner не имеет outbound к dl-cdn.alpinelinux.org (Permission denied) —
# переадресуем apk на internal Nexus mirror. Это нужно во всех Alpine
# jobs (commit-msg-lint, build-docs ниже, commit-prod-image-bump).
- sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://repo.nstart.cloud/repository/alpine|g' /etc/apk/repositories
# apk update обязателен ПОСЛЕ repo swap: image приходит с pre-cached
# APKINDEX (старые package versions). Без update apk ищет старые .apk
# файлы которых нет в нашем mirror → HTTP 404. См. job 36025 история.
- apk update
- apk add --no-cache git grep
- |
set -e
# Сравниваем с last common ancestor relative к main для MR pipeline'ов,
# с last 10 commit'ами для branch pushes.
if [ -n "$CI_MERGE_REQUEST_DIFF_BASE_SHA" ]; then
RANGE="$CI_MERGE_REQUEST_DIFF_BASE_SHA..HEAD"
elif [ -n "$CI_COMMIT_BEFORE_SHA" ] && [ "$CI_COMMIT_BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then
RANGE="$CI_COMMIT_BEFORE_SHA..HEAD"
else
RANGE="HEAD~10..HEAD"
fi
echo "Checking commit messages in range: $RANGE"
BAD=0
while IFS= read -r SHA; do
SUBJECT=$(git log -1 --format=%s "$SHA")
# Скипаем release-bot commit'ы — semantic-release пишет changelog с
# историей commit'ов и может legitimately содержать литеральное
# `[skip ci]` если pre-MR-!154 commits в истории.
case "$SUBJECT" in
chore\(release\):*) echo " $SHA SKIP (release commit): $SUBJECT"; continue ;;
esac
MSG=$(git log -1 --format=%B "$SHA")
if echo "$MSG" | grep -q '\[skip ci\]'; then
echo " $SHA FAIL: содержит литеральное [skip ci]"
echo " Subject: $SUBJECT"
BAD=$((BAD+1))
else
echo " $SHA OK: $SUBJECT"
fi
done < <(git rev-list "$RANGE")
if [ "$BAD" -gt 0 ]; then
echo ""
echo "════════════════════════════════════════════════════════════"
echo "ERROR: $BAD commit(s) содержат литеральное \`[skip ci]\`."
echo ""
echo "Trap: semantic-release включит эту строку в release notes →"
echo "тег pipeline на release commit'е будет skip'нут GitLab'ом."
echo ""
echo "Если нужно описать skip-ci marker, используй:"
echo " - 'skip-ci marker' (с дефисом)"
echo " - 'the skip-ci directive'"
echo " - 'GitLab pipeline skip syntax'"
echo ""
echo "См. RELEASE.md → раздел 'Skip-ci marker trap'."
echo "════════════════════════════════════════════════════════════"
exit 1
fi
echo ""
echo "All commits clean."
rules:
# Запускается ТОЛЬКО на MR pipeline'ах. Branch-push pipeline'ы скипаем
# потому что HEAD~10 range пикапит historical commits (e.g. b7e1f12 из
# MR !154 — он сам и привёл к созданию этого lint job'а), false-fail'ит
# на legitimately-bad legacy commits которые уже в main.
#
# MR pipeline использует CI_MERGE_REQUEST_DIFF_BASE_SHA..HEAD range —
# сканит только новые commit'ы данного MR'а. Это правильный gate:
# contributor открывает MR → lint валидирует → fail/pass.
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# ─── TEST ──────────────────────────────────────
maven-test:
stage: test
image: repo.nstart.cloud/library/maven:3.9.9-eclipse-temurin-21
script:
- mvn $MAVEN_CLI_OPTS verify
artifacts:
when: always
reports:
junit: '**/target/surefire-reports/TEST-*.xml'
# 3 days — junit reports нужны для debugging recent failures; longer keep
# съедает storage (см. pipeline 6502 500 на artifact upload — storage was full).
expire_in: 3 days
rules:
- changes: *backend-changes
# Е2Е тесты через Testcontainers (Postgres+Kafka). Профиль `e2e` в
# ordinis-app/pom.xml включает их (default surefire excludes e2e директорию).
# Требует docker:dind service для testcontainers — обычный maven-test runner
# не имеет Docker.
maven-e2e:
stage: test
image: repo.nstart.cloud/library/maven:3.9.9-eclipse-temurin-21
services:
- name: repo.nstart.cloud/library/docker:29.1.2-dind
alias: docker
command: ["--tls=false"]
variables:
DOCKER_HOST: "tcp://docker:2375"
DOCKER_TLS_CERTDIR: ""
TESTCONTAINERS_HOST_OVERRIDE: "docker"
TESTCONTAINERS_RYUK_DISABLED: "true"
script:
- for i in $(seq 1 30); do docker -H tcp://docker:2375 info >/dev/null 2>&1 && break; sleep 1; done
- mvn $MAVEN_CLI_OPTS -pl ordinis-app -am -P e2e test
artifacts:
when: always
reports:
junit: '**/target/surefire-reports/TEST-*.xml'
expire_in: 3 days
rules:
- changes: *backend-changes
# Можно сделать allow_failure: true если e2e будут флакать — пока нет.
# ─── BENCH (manual JMH) ────────────────────────
# Manual trigger only — slow (~5-10 min) + noisy в CI history. Trigger через
# pipeline run UI с variable RUN_BENCH=true. Catches алгоритмическую регрессию
# на hot paths (parseRef, OnCloseAction, schema traversal).
maven-bench:
stage: test
image: repo.nstart.cloud/library/maven:3.9.9-eclipse-temurin-21
script:
- mvn $MAVEN_CLI_OPTS -P bench -pl ordinis-bench -am package -DskipTests
# Quick smoke: 1 fork, 2 warmups, 3 measurements × 2s. Total ~5 min.
# Если нужно надёжнее — bump до -f 2 -wi 5 -i 5 -r 5 (15 min).
- java -jar ordinis-bench/target/benchmarks.jar -f 1 -wi 2 -i 3 -w 1 -r 2
-rf json -rff bench-results.json | tee bench-results.txt
artifacts:
paths:
- bench-results.json
- bench-results.txt
expire_in: 5 days
rules:
- if: '$RUN_BENCH == "true"'
when: on_success
- if: '$CI_PIPELINE_SOURCE == "web"'
when: manual
allow_failure: true
# ─── BUILD (Maven jars) ────────────────────────
# maven-package всегда запускается: docker backend jobs нуждаются в jar даже
# при frontend-only PR (иначе backend образы не пересобрать → ImagePullBackOff
# в кластере при rolling update). BuildKit layer cache делает повторную сборку
# идентичных jar'ов ~30 sec.
maven-package:
stage: build
image: repo.nstart.cloud/library/maven:3.9.9-eclipse-temurin-21
script:
- mvn $MAVEN_CLI_OPTS package -DskipTests
artifacts:
paths:
- "**/target/*.jar"
- ordinis-migrations/src/main/resources/db/changelog/**
expire_in: 1 day
# ─── BUILD: Integrator Guide (sanity check) ────
# Sanity-check build для PRs и non-main branches. Полный image build
# идёт в publish stage (docker-docs). Здесь только verify что yfm
# не выдаёт errors на изменения docs/**.
build-docs:
stage: build
image: repo.nstart.cloud/library/node:20-alpine
before_script:
# npm install pnpm — corepack может hang на CI runner integrity checks.
- npm install -g --silent pnpm@9.15.4
script:
- cd docs
- pnpm install --frozen-lockfile
- pnpm build
- echo "Built $(find _dist -name '*.html' | wc -l) HTML pages, total size:"
- du -sh _dist
artifacts:
name: "ordinis-docs-$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
paths:
- docs/_dist
# 2 days — docs/_dist используется только для PR sanity check, deployed
# docs идут через docker-docs image (Nexus registry, not artifact).
expire_in: 2 days
rules:
- if: '$CI_COMMIT_BRANCH != "main"'
changes: *docs-changes
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: *docs-changes
# ─── PUBLISH (Docker images) ───────────────────
# resource_group: serializes docker-* jobs across all concurrent pipelines на
# одном runner'е. 5 parallel docker builds (app/read-api/projection-writer/
# admin-ui/migrations) каждый поднимает свой DinD container с buildx — суммарно
# выжирали runner disk (~25Gi+) → "no space left on device" в /var/lib/docker
# на тяжёлых stages (см. pipeline 6066). Serialize'я добавляет ~10 min к
# pipeline'у, но вместо retry-roulette мы получаем стабильно зелёный CI.
# При scaling до multi-runner setup можно увеличить parallelism per-runner и
# переименовать group в per-runner identifier.
.docker_base:
stage: publish
image: repo.nstart.cloud/nstart/docker-cli-buildx:1.0.0
services:
- name: repo.nstart.cloud/library/docker:29.1.2-dind
alias: docker
command: ["--tls=false"]
# docker-builds группа залипла в GitLab Sidekiq после force-cancel зомби
# docker-app job 29772 на pipeline 6553. Все последующие docker-* job'ы
# (pipelines 6565/6570/6571) висели в waiting_for_resource — стейл lock
# никто не release'ил, sidekiq promote'р не срабатывал. Переименование
# создаёт fresh resource_group со чистым state. Старая docker-builds
# пусть умирает естественно.
resource_group: docker-builds-v2
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
# Docker сборки только на main. На tag pipeline вместо rebuild — `retag-*`
# jobs (см. .retag_base) которые pull'ят `:main-{parent-sha}` и tag'ают
# как `:v2-x-y-{sha}`. Same git SHA → same image content, нет смысла
# rebuild'ить дважды (раньше было ~5-7 min × 6 services × release).
# На feature-branch / MR pipeline'ах docker сборки тоже не запускаются —
# deploy происходит только после merge в main, MR валидируется тестами.
# docker-docs переопределяет свой rules: блок (path-filter), этот rules:
# затрагивает app/read-api/projection-writer/migrations/admin-ui.
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
.publish_script: &publish_script |
for i in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 1; done
# Lighter cleanup: dangling layers only, build cache сохраняем чтобы BuildKit
# переиспользовал между serialized jobs. Раньше был `prune -af --volumes`
# после Nexus blob loss recovery (см. ниже) — сжирал inline cache, каждая
# сборка с нуля. Сейчас Nexus стабилен → cache сохраняем.
docker image prune -f >/dev/null 2>&1 || true
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
IMG="$REGISTRY/$IMAGE_REPO/$SVC"
# Cache strategy:
# - --cache-from $IMG:latest — BuildKit читает inline cache из previously
# pushed manifest'а. На повторных сборках cached слои переиспользуются,
# полная сборка падает с ~70-90s до 15-25s.
# - --build-arg BUILDKIT_INLINE_CACHE=1 — записывает cache metadata в
# pushed image manifest для следующего pipeline'а.
# - --pull — Nexus docker-proxy лениво re-fetch'ит base image с Docker Hub.
#
# Раньше был --no-cache как temporary recovery после Nexus blob loss
# incident (pipeline 6066). Nexus blob storage стабилизирован, --no-cache
# убран → -2 мин/service × 5 services = -10 мин на pipeline.
# Если Nexus снова потеряет blob refs — симптом: "blob unknown to registry"
# на manifest push. Recovery: вернуть --no-cache temporarily, сделать
# `nexus-cli script run cleanup-orphan-blobs` (или ручной touch через UI),
# потом убрать --no-cache обратно.
#
# Git info passed как build args чтобы Spring Boot build-info goal получил
# реальные значения (внутри Docker context .git/ исключён через .dockerignore,
# git-commit-id-maven-plugin не может прочесть). Backend Dockerfile'ы
# используют эти ARG'и в `mvn -Dgit.commit.id.abbrev=... -Dgit.branch=...`.
# Frontend / docs / migrations Dockerfile'ы их игнорируют (no-op в context'е).
docker pull "$IMG:latest" >/dev/null 2>&1 || true
docker build --pull -f "$DOCKERFILE" \
-t "$IMG:$IMAGE_TAG" -t "$IMG:latest" -t "$IMG:$CI_COMMIT_REF_SLUG" \
--cache-from "$IMG:latest" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--build-arg GIT_COMMIT="${CI_COMMIT_SHORT_SHA:-unknown}" \
--build-arg GIT_BRANCH="${CI_COMMIT_REF_NAME:-unknown}" \
--build-arg GIT_TAG="${CI_COMMIT_TAG:-none}" \
--build-arg GIT_TIME="${CI_COMMIT_TIMESTAMP:-unknown}" \
"$BUILD_CONTEXT"
docker push "$IMG:$IMAGE_TAG"
docker push "$IMG:latest"
docker push "$IMG:$CI_COMMIT_REF_SLUG"
docker-app:
extends: .docker_base
needs: [maven-package]
variables:
SVC: ordinis-app
DOCKERFILE: ordinis-app/Dockerfile
BUILD_CONTEXT: .
script:
- *publish_script
docker-read-api:
extends: .docker_base
needs: [maven-package]
variables:
SVC: ordinis-read-api
DOCKERFILE: ordinis-read-api/Dockerfile
BUILD_CONTEXT: .
script:
- *publish_script
docker-projection-writer:
extends: .docker_base
needs: [maven-package]
variables:
SVC: ordinis-projection-writer
DOCKERFILE: ordinis-projection-writer/Dockerfile
BUILD_CONTEXT: .
script:
- *publish_script
docker-migrations:
extends: .docker_base
needs: [maven-package]
variables:
SVC: ordinis-migrations
DOCKERFILE: ordinis-migrations/Dockerfile
BUILD_CONTEXT: ordinis-migrations/
script:
- *publish_script
docker-admin-ui:
extends: .docker_base
needs: []
variables:
SVC: ordinis-admin-ui
DOCKERFILE: ordinis-admin-ui/Dockerfile
BUILD_CONTEXT: ordinis-admin-ui/
# docs/changelog.md — single source of truth для public changelog (read'ится
# mkdocs/Diplodoc для /docs/changelog.html AND admin-ui WhatsNewButton).
# admin-ui Docker build context = ordinis-admin-ui/ (sibling, не parent) —
# copy file в src/ перед docker build.
#
# Suffix `-public` — case-insensitive FS macOS dev: `changelog.md` ≡
# `CHANGELOG.md` (existing internal file) → overwrite. Linux CI обычно
# case-sensitive, но единообразное имя минимизирует local/CI drift.
before_script:
- cp docs/changelog.md ordinis-admin-ui/src/changelog-public.md
script:
- *publish_script
# Раньше был path-filter, но это создавало дыру: если backend менялся, а
# frontend нет — резолвили общий тег и helm пытался катить admin-ui:<commit>
# которого не существовало. Layer-cache делает повторную сборку ~30 sec.
# Integrator Guide (Diplodoc) — multi-stage Dockerfile (yfm build → nginx serving).
# Path-filtered: rebuild только при изменении docs/. Если docs не менялся,
# helm берёт previous tag через --reset-then-reuse-values.
docker-docs:
extends: .docker_base
needs: []
variables:
SVC: ordinis-docs
DOCKERFILE: docs/Dockerfile
BUILD_CONTEXT: docs/
script:
- *publish_script
rules:
# На main собираем БЕЗ path-filter (gives `:main-{sha}` image для каждого
# commit). На tag pipeline вместо rebuild — `retag-docs` (см. .retag_base).
# Layer cache при отсутствии docs/* changes делает rebuild ~30 sec —
# приемлемая цена за гарантию что `:main-{sha}` существует для retag.
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_PIPELINE_SOURCE == "web"'
when: manual
# ─── RETAG ON TAG PIPELINE ────────────────────
# Tag pipeline pull'ит `:main-{parent-sha}` (built на main pipeline предыдущего
# commit) и tag'ает как `:v2-x-y-{sha}` + `:latest` + `:$CI_COMMIT_REF_SLUG`.
# Сохраняет ~5-7 min × 6 services × release vs full rebuild — same git SHA →
# same image, deterministic.
#
# Parent SHA recovery: semantic-release создаёт chore commit с [skip ci]
# (нет main pipeline для него). Tag указывает на chore commit. `git log
# HEAD~1` → parent commit = original main commit что триггерил build.
# `:main-{parent-sha}` гарантированно существует в registry.
#
# GIT_DEPTH=10 — нужно walk back до parent commit (default depth=20 хватит).
.retag_base:
stage: publish
image: repo.nstart.cloud/nstart/docker-cli-buildx:1.0.0
services:
- name: repo.nstart.cloud/library/docker:29.1.2-dind
alias: docker
command: ["--tls=false"]
resource_group: docker-builds-v2
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
GIT_DEPTH: "10"
rules:
- if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'
script:
- for i in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 1; done
- echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
- IMG="$REGISTRY/$IMAGE_REPO/$SVC"
# `git log %h` динамически растягивает abbrev (8 → 9 chars) когда git
# детектит SHA-коллизию в локальном clone'е. Docker push в main pipeline
# использует CI_COMMIT_SHORT_SHA (фиксированно 8 chars per GitLab spec),
# поэтому при mismatch'е retag не находит main-<9chars> образ →
# "failed to resolve reference ... not found" → job failed, и за ним
# commit-prod-image-bump skipped. v2.41.3 (MR !261) попал на эту race.
# Используем rev-parse + cut чтобы гарантировать 8 chars независимо
# от состояния clone'а.
- PARENT_SHA=$(git rev-parse HEAD~1 | cut -c1-8)
- SRC="$IMG:main-$PARENT_SHA"
- echo "Re-tag $SRC → $IMG:$IMAGE_TAG (+ :latest, :$CI_COMMIT_REF_SLUG)"
- docker pull "$SRC"
- docker tag "$SRC" "$IMG:$IMAGE_TAG"
- docker tag "$SRC" "$IMG:latest"
- docker tag "$SRC" "$IMG:$CI_COMMIT_REF_SLUG"
- docker push "$IMG:$IMAGE_TAG"
- docker push "$IMG:latest"
- docker push "$IMG:$CI_COMMIT_REF_SLUG"
retag-app:
extends: .retag_base
needs: []
variables:
SVC: ordinis-app
retag-read-api:
extends: .retag_base
needs: []
variables:
SVC: ordinis-read-api
retag-projection-writer:
extends: .retag_base
needs: []
variables:
SVC: ordinis-projection-writer
retag-migrations:
extends: .retag_base
needs: []
variables:
SVC: ordinis-migrations
retag-admin-ui:
extends: .retag_base
needs: []
variables:
SVC: ordinis-admin-ui
retag-docs:
extends: .retag_base
needs: []
variables:
SVC: ordinis-docs
# ─── TRIGGER DEPLOY ───────────────────────────
# Single resolve-tag (always runs) → один UPSTREAM_IMAGE_TAG. Path-filter
# работает на уровне docker-* jobs (backend skip если бэк не менялся,
# layers cache hit делает rebuild ~30 sec). Полное per-service splitting
# через dual dotenv не работает с GitLab trigger var interpolation.
resolve-tag:
stage: trigger-deploy
image: repo.nstart.cloud/library/alpine:3.20
script:
- echo "RESOLVED_IMAGE_TAG=$IMAGE_TAG" > resolved.env
# DOCS_IMAGE_TAG может быть пустым если docker-docs не пересобирался
# (path-filter no-match). Helm в infra оставит старый tag через
# --reset-then-reuse-values если переменная пустая.
- echo "RESOLVED_DOCS_TAG=$IMAGE_TAG" >> resolved.env
- cat resolved.env
artifacts:
reports:
dotenv: resolved.env
# 6 hours — dotenv нужен только для trigger-deploy-* в этом же pipeline;
# дольше держать смысла нет, downstream consumers свой artifact не качают.
# Без явного expire_in defaults to GitLab project policy (30 дней) →
# каждый main commit накапливает stale artifact.
expire_in: 6 hours
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
# На tag-пайплайне $CI_COMMIT_BRANCH пустой — но trigger-deploy-prod
# (тоже tag-only) needs: resolve-tag. Без этого правила prod deploy
# не запускается с ошибкой "resolve-tag does not exist in the pipeline".
- if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'
trigger-deploy-staging:
stage: trigger-deploy
needs: [resolve-tag]
trigger:
project: 2-6/2-6-4/terravault/ordinis-infra
branch: main
strategy: depend
variables:
UPSTREAM_IMAGE_TAG: $RESOLVED_IMAGE_TAG
DOCS_IMAGE_TAG: $RESOLVED_DOCS_TAG
DEPLOY_ENV: staging
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
trigger-deploy-prod:
stage: trigger-deploy
needs: [resolve-tag]
trigger:
project: 2-6/2-6-4/terravault/ordinis-infra
branch: main
strategy: depend
variables:
UPSTREAM_IMAGE_TAG: $RESOLVED_IMAGE_TAG
DOCS_IMAGE_TAG: $RESOLVED_DOCS_TAG
DEPLOY_ENV: prod
rules:
- if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'
when: manual
# =============================================================
# Release automation — semantic-release.
# =============================================================
# Анализирует conventional commits с последнего tag'а, решает bump (patch/
# minor/major), генерирует CHANGELOG.md, bump'ит package.json, создаёт git tag
# vX.Y.Z и GitLab Release. Tag pipeline далее автоматически собирает images
# и ждёт manual gate на trigger-deploy-prod.
#
# Запускается ТОЛЬКО на main, ТОЛЬКО после успешного staging deploy.
# Если нет релизных commits (только chore/test/ci/docs/style) — exit 0 без
# tag'а. Loop prevention: коммит `chore(release): vX.Y.Z [skip ci]` —
# `[skip ci]` стандартный GitLab marker, новый pipeline не триггерится.
#
# Setup ONE-TIME (см. RELEASE.md):
# 1. Settings → Access Tokens → create Project Access Token
# - role: Maintainer
# - scopes: write_repository, api
# - expiry: 1 год (renew раз в год)
# 2. Settings → CI/CD → Variables → add:
# GITLAB_TOKEN = <token> (Masked, Protected)
#
# Tuning: чтобы НЕ релизить feat: автоматом — добавь в commit message
# `[skip release]` и semantic-release пропустит этот commit (см. parserOpts).
release:
stage: release
# Pinned к alpine3.20 (не latest) — Nexus mirror APKINDEX для v3.23
# рассинхронизирован с filesystem (claims old version, only newer files exist).
# v3.20 mirror consistent, apk add работает. См. ci/apk-update-fix история.
image: repo.nstart.cloud/library/node:22-alpine3.20
# Раньше `needs: trigger-deploy-staging` блокировал release когда staging
# deploy hang'ался (helm pending-upgrade cascade — каждый retry оставлял
# stuck revision, helm wait timeout 15m → rollback → next pipeline стартует
# с new pending). Release job skipped → tag не создаётся → prod не катит.
# MR !290 / !291 / !292 / !294 застряли в "merged but no release".
#
# Убираем dep: release runs независимо от staging health. Tag создаётся,
# tag pipeline → retag images → infra auto-bump → deploy-prod катит
# через MR !95 fallback (image.tag из values.yaml).
#
# Trade-off: staging может оставаться на старой версии если deploy hang'ает.
# Это OK — staging это testing env, prod важнее. Manual staging recovery
# через helm rollback + delete pending secrets когда нужно.
needs:
- job: resolve-tag
artifacts: true
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
variables:
# semantic-release требует full git history для commit analysis.
GIT_DEPTH: "0"
# GL_TOKEN — @semantic-release/gitlab plugin requires this name specifically.
GL_TOKEN: $GITLAB_TOKEN
before_script:
# Fail fast если token не настроен. Job всё равно allow_failure: true —
# pipeline не покраснеет, но в logs понятная причина (вместо cryptic
# "Authentication failed").
- |
if [ -z "$GITLAB_TOKEN" ]; then
echo "═══════════════════════════════════════════════════════════════"
echo "GITLAB_TOKEN is empty — release job will skip."
echo ""
echo "Setup (one-time):"
echo " 1. Settings → Access Tokens → create Project Access Token"
echo " scopes: write_repository + api, role: Maintainer"
echo " 2. Settings → CI/CD → Variables → add GITLAB_TOKEN"
echo " flags: Masked + Protected"
echo ""
echo "См. RELEASE.md в репозитории для подробностей."
echo "═══════════════════════════════════════════════════════════════"
exit 0
fi
# Internal Alpine mirror — см. commit-msg-lint выше для контекста.
- sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://repo.nstart.cloud/repository/alpine|g' /etc/apk/repositories
- apk update
- apk add --no-cache git ca-certificates
# Configure git remote с токеном для push. CI default remote — HTTPS с
# CI_JOB_TOKEN scope, который НЕ может push'ить tags/commits. Переопределяем
# на Project Access Token (GITLAB_TOKEN).
- git remote set-url origin "https://gitlab-ci-token:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
- git config user.email "${GITLAB_USER_EMAIL:-ci@ordinis.local}"
- git config user.name "semantic-release"
- npm install -g --silent pnpm@9.15.4
- cd ordinis-admin-ui
- pnpm install --frozen-lockfile --silent
script:
- npx semantic-release
# Не блокирует следующие pipelines если release fail'нул (e.g. нет
# релизных commits, нет GITLAB_TOKEN, transient network error).
allow_failure: true
# =============================================================
# Auto-PR: bump image tag в ordinis-infra/values-{prod,staging}.yaml.
# =============================================================
# Зачем. До этого job'а prod deploys работали через две runtime механики:
#
# 1. Auto path. Ordinis tag pipeline → trigger-deploy-prod (manual gate)
# → downstream infra pipeline с UPSTREAM_IMAGE_TAG=v2-16-0-<sha>
# → helm upgrade --set writer.tag=$UPSTREAM ... → деплой.
# 2. Manual path. Помержить что-то в infra repo → infra pipeline →
# deploy-prod job (manual) → helm upgrade БЕЗ --set tag override →
# использует значения из values-prod.yaml.
#
# Проблема. (1) и (2) использовали разные источники истины для image tag:
#
# - (1) — runtime var UPSTREAM_IMAGE_TAG от triggering pipeline.
# - (2) — статика в values-prod.yaml (раньше unset → fall to global
# image.tag=latest → IfNotPresent кэш → split-brain pods, см.
# ordinis-infra MR !10 fix).
#
# После любого release manual path возвращался к старому tag'у который
# зафиксирован в values-prod.yaml — что divergировало с реальностью на
# кластере. Disaster-recovery `helm upgrade ... -f values-prod.yaml`
# rolled back prod на несколько версий назад.
#
# Решение. На каждый успешный release tag ordinis CI коммитит обновление
# `image.tag` в values-prod.yaml + values-staging.yaml на новый immutable
# tag (e.g. v2-16-0-<sha>) ПРЯМО в ordinis-infra main.
#
# Промежуточного MR нет. Раньше bump шёл через auto-MR — но его никто не
# мерджил: накопилось 5+ открытых MR, values отставали на несколько
# релизов, и infra `deploy-prod` (helm upgrade -f values-prod.yaml)
# откатывал prod на старый образ. Direct commit убирает ручной шаг —
# нет MR, нет 405-квирка при merge вечно-manual инфра-пайплайна.
#
# Git становится единственным источником истины: HEAD ordinis-infra
# main всегда отражает что задеплоено на prod.
#
# Setup ONE-TIME (см. RELEASE.md):
# 1. Создать group-level Access Token в 2-6/2-6-4/terravault group:
# - role: Maintainer на ordinis-infra (нужен push в protected main;
# Developer-роль push в protected branch не имеет)
# - scopes: write_repository
# 2. Settings → CI/CD → Variables в ORDINIS project:
# INFRA_PROJECT_TOKEN = <token> (Masked, Protected)
# 3. ordinis-infra → Settings → Repository → Protected branches → main:
# "Allowed to push" должен покрывать этот токен (роль Maintainer
# покрывает по умолчанию).
#
# Trigger: только на semver tag (pipeline created semantic-release'ом).
# Allow_failure: true — manual deploy backup path остаётся если что.
commit-prod-image-bump:
stage: release
image: repo.nstart.cloud/library/alpine:3.20
needs:
# На tag pipeline images приходят через retag-* (не docker-*) после
# !205 (ci/retag-on-tag-pipeline). Поэтому needs ссылается на retag-*.
- job: retag-app
artifacts: false
- job: retag-admin-ui
artifacts: false
- job: retag-read-api
artifacts: false
- job: retag-projection-writer
artifacts: false
- job: retag-migrations
artifacts: false
rules:
# Только на tag pipeline (semantic-release завершил → новый tag → новый
# pipeline на ref=tag). Images должны быть собраны в этом pipeline'е
# (см. needs выше). На main pipeline tag ещё не существует — нечего
# bump'ать.
- if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'
when: on_success
variables:
INFRA_REPO_PATH: "2-6/2-6-4/terravault/ordinis-infra"
before_script:
- |
if [ -z "$INFRA_PROJECT_TOKEN" ]; then
echo "═══════════════════════════════════════════════════════════════"
echo "INFRA_PROJECT_TOKEN is empty — skipping image bump."
echo ""
echo "Setup (one-time):"
echo " 1. group 2-6/2-6-4/terravault → Settings → Access Tokens"
echo " scope: write_repository, role: Maintainer (push в protected main)"
echo " 2. ordinis project (367) → Settings → CI/CD → Variables"
echo " add INFRA_PROJECT_TOKEN = <token>"
echo " flags: Masked + Protected (tag pipelines is Protected ref)"
echo ""
echo "См. RELEASE.md для подробностей."
echo "═══════════════════════════════════════════════════════════════"
exit 0
fi
# Internal Alpine mirror — см. commit-msg-lint выше для контекста.
- sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://repo.nstart.cloud/repository/alpine|g' /etc/apk/repositories
- apk update
- apk add --no-cache git
script:
- |
set -euo pipefail
IMAGE_TAG="${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
VERSION="${CI_COMMIT_TAG}"
echo "Tag: $VERSION"
echo "Image tag: $IMAGE_TAG"
echo "Target: ordinis-infra main (direct commit)"
echo ""
# 1. Clone infra main (shallow для скорости).
git clone --depth=10 --branch=main \
"https://oauth2:${INFRA_PROJECT_TOKEN}@${CI_SERVER_HOST}/${INFRA_REPO_PATH}.git" infra
cd infra
git config user.email "ci@ordinis.local"
git config user.name "ordinis-ci-bot"
# 2. Patch values-prod.yaml + values-staging.yaml — заменяем image.tag.
#
# Patching ОБА файла. Зачем: до 2026-05-13 staging
# values файл не задавал image.tag и default `:latest` + IfNotPresent
# давал stale cache на нодах. Зафиксили (infra MR !22), теперь
# staging тоже на immutable SHA. Чтобы оба env'а не drift'или —
# bump'им обе values файла lock-step.
#
# Awk c stateful scope-tracking: ищем `image:` block (top-level, без
# leading spaces), затем заменяем ` tag: ...` строку. Не трогаем
# writer.tag / readApi.tag / etc — у них другая indentation level.
patch_values_file() {
local file=$1
awk -v new_tag="$IMAGE_TAG" -v file="$file" '
BEGIN { in_image_block = 0; replaced = 0 }
/^image:[[:space:]]*$/ { in_image_block = 1; print; next }
# Top-level keys (no indent, ends with `:`) reset scope.
/^[a-zA-Z][a-zA-Z0-9_-]*:/ && !/^image:/ { in_image_block = 0; print; next }
# Заменяем ` tag:` ТОЛЬКО внутри image: block. 2 spaces indent.
in_image_block && /^ tag:/ {
print " tag: \"" new_tag "\""
replaced = 1
next
}
{ print }
END { if (!replaced) { print "ERROR: image.tag не найдена в " file > "/dev/stderr"; exit 1 } }
' "$file" > "${file}.new"
mv "${file}.new" "$file"
}
patch_values_file charts/ordinis/values-prod.yaml
patch_values_file charts/ordinis/values-staging.yaml
# 3. Diff sanity: changed только эта строка(и).
if ! git diff --quiet charts/ordinis/values-prod.yaml charts/ordinis/values-staging.yaml; then
echo "=== Diff ==="
git diff charts/ordinis/values-prod.yaml charts/ordinis/values-staging.yaml
else
echo "INFO: tag уже $IMAGE_TAG в обоих values файлах — no-op exit."
exit 0
fi
# 4. Commit прямо в main.
# printf '%b' разворачивает \n в реальные newlines внутри одной shell
# строки — избегаем multi-line YAML quoting issues (literal block scalar
# ломается на unindented continuation lines).
git add charts/ordinis/values-prod.yaml charts/ordinis/values-staging.yaml
COMMIT_MSG=$(printf '%b' "chore(release): bump prod+staging image к ${IMAGE_TAG}\n\nAuto-generated ordinis-CI release pipeline. Patches values-prod.yaml AND values-staging.yaml в lock-step.\n\nSource: ${CI_PROJECT_URL}/-/commit/${CI_COMMIT_SHA}\nPipeline: ${CI_PIPELINE_URL}\nTag: ${VERSION}")
git commit -m "$COMMIT_MSG"
# 5. Push в main. До 5 попыток с rebase — если main подвинулся
# между clone и push (параллельный release, ручной коммит).
attempt=0
until [ "$attempt" -ge 5 ]; do
if git push origin HEAD:main; then
echo "✓ Bump запушен в ordinis-infra main."
break
fi
attempt=$((attempt + 1))
echo "Push отклонён (main подвинулся?) — rebase, попытка ${attempt}/5."
git fetch origin main
git rebase origin/main
done
if [ "$attempt" -ge 5 ]; then
echo "ERROR: push в main не прошёл после 5 попыток." >&2
exit 1
fi
echo ""
echo "Деплой: новый infra main pipeline auto-катит staging,"
echo "deploy-prod остаётся manual-gated."
# Не блокирует tag pipeline если bump не прошёл (token отсутствует,
# transient git error и т.п.). Manual bump в infra остаётся доступен.
allow_failure: true