# =============================================================
# 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 → нет propose-prod-image-bump → нет
#      auto-PR в 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:
    - 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"
    - PARENT_SHA=$(git log -1 --format=%h HEAD~1)
    - 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
  image: repo.nstart.cloud/library/node:22-alpine
  needs:
    - job: trigger-deploy-staging
      artifacts: false
  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
    - 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 открывает MR в
# ordinis-infra обновляющий `image.tag` в values-prod.yaml на новый
# immutable tag (e.g. v2-16-0-<sha>). MR — single review point: после
# approve и merge infra pipeline auto-катит этот же tag через deploy-prod
# (rules: changes: charts/**/*).
#
# Git становится единственным источником истины: HEAD ordinis-infra
# main всегда отражает что задеплоено на prod.
#
# Setup ONE-TIME (см. RELEASE.md):
#   1. Создать group-level Access Token в 2-6/2-6-4/terravault group:
#      - role: Developer на ordinis-infra (363)
#      - scopes: write_repository, api
#   2. Settings → CI/CD → Variables в ORDINIS project:
#      INFRA_PROJECT_TOKEN = <token>  (Masked, Protected)
#
# Trigger: только на semver tag (pipeline created semantic-release'ом).
# Allow_failure: true — manual deploy backup path остаётся если что.
propose-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_PROJECT_ID: "368"
    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 auto-PR creation."
        echo ""
        echo "Setup (one-time):"
        echo "  1. group 2-6/2-6-4/terravault → Settings → Access Tokens"
        echo "     scopes: write_repository + api, role: Developer"
        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
    - apk add --no-cache git curl jq
  script:
    - |
      set -euo pipefail

      IMAGE_TAG="${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
      VERSION="${CI_COMMIT_TAG}"
      BRANCH="auto/bump-prod-image-${VERSION}"
      INFRA_API="${CI_API_V4_URL}/projects/${INFRA_PROJECT_ID}"

      echo "Tag: $VERSION"
      echo "Image tag: $IMAGE_TAG"
      echo "Target branch: $BRANCH"
      echo ""

      # 1. Clone infra (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"

      # Branch может остаться от прошлой неудачной попытки — clean delete.
      git push origin --delete "$BRANCH" 2>/dev/null || true
      git checkout -b "$BRANCH"

      # 2. Patch values-prod.yaml + values-staging.yaml — заменяем image.tag.
      #
      # Patching ОБА файлов в одном MR. Зачем: до 2026-05-13 staging
      # values файл не задавал image.tag и default `:latest` + IfNotPresent
      # давал stale cache на нодах. Зафиксили (infra MR !22), теперь
      # staging тоже на immutable SHA. Чтобы оба env'а не drift'или —
      # auto-PR 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 + push branch.
      # 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"
      git push origin "$BRANCH"

      # 5. Create MR через GitLab API.
      MR_DESC=$(printf '%b' "Auto-generated ordinis CI release pipeline после semantic-release ${VERSION}.\n\n**Что меняется:** \`image.tag\` в \`charts/ordinis/values-prod.yaml\` И \`charts/ordinis/values-staging.yaml\` → \`${IMAGE_TAG}\` (immutable SHA tag, гарантирует что ноды pull'нут именно этот digest, и оба env'а на одном release).\n\n**Source:**\n- Ordinis commit: ${CI_PROJECT_URL}/-/commit/${CI_COMMIT_SHA}\n- Tag pipeline: ${CI_PIPELINE_URL}\n\n**Deploy после merge:** infra-pipeline auto-катит helm upgrade в \`ordinis-staging\` (auto) и \`ordinis-prod\` (manual-gated \`deploy-prod\` job).\n\n**Verify checklist:**\n- [ ] CI 'helm-lint' pipeline зелёный (validate charts)\n- [ ] После merge play 'deploy-prod' job (staging авто-катится)\n- [ ] \`curl https://ordinis.k8s.264.nstart.cloud/api/v1/version\` (prod) показывает \`commit: ${CI_COMMIT_SHORT_SHA}\` и \`tag: ${VERSION}\`\n- [ ] \`curl https://ordinis.k8s.265.nstart.cloud/api/v1/version\` (staging) тот же commit/tag\n- [ ] Pods Running на обоих env (no split-brain)")

      curl -sS --fail \
        --request POST \
        --header "PRIVATE-TOKEN: ${INFRA_PROJECT_TOKEN}" \
        --data-urlencode "source_branch=${BRANCH}" \
        --data-urlencode "target_branch=main" \
        --data-urlencode "title=chore(release): bump prod image к ${VERSION}" \
        --data-urlencode "description=${MR_DESC}" \
        --data-urlencode "remove_source_branch=true" \
        --data-urlencode "labels=release,auto-generated" \
        "${INFRA_API}/merge_requests" \
        | jq -r '"MR создан: " + .web_url'
  # Не блокирует tag pipeline если auto-PR не получился (token отсутствует,
  # transient API error и т.п.). Manual MR в infra остаётся всегда доступен.
  allow_failure: true
