diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 419bbef..8bdceb3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -67,6 +67,102 @@ default: .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 diff --git a/RELEASE.md b/RELEASE.md index 914cae1..8285e8b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -88,18 +88,65 @@ BREAKING CHANGE: /api/v1/* paths больше не поддерживаются. ## Skip release for specific commit Если хочешь смержить `feat:` или `fix:` но **не** делать release сейчас — -добавь `[skip ci]` в commit message ИЛИ используй non-release type -(`chore:`, `docs:`, etc). +используй non-release type (`chore:`, `docs:`, etc). ``` chore(deps): bump axios 1.7 -> 1.8 ``` +> ⚠ **НЕ используй `[skip ci]`** в commit message — см. раздел +> «Skip-ci marker trap» ниже. + ## Loop prevention -Release job создаёт commit `chore(release): vX.Y.Z [skip ci]`. `[skip ci]` — -стандартный GitLab marker, новый pipeline не триггерится. Версия package.json -+ CHANGELOG.md обновляются в этом коммите. +Release job создаёт commit `chore(release): vX.Y.Z`. Loop prevention работает +через releaseRules в `.releaserc.json` — chore-тип имеет `release: false` → +semantic-release на release commit'е detect'ит «no releasable commits» → +exit 0 → нет нового tag → нет loop'а. + +> **Historical note:** до MR !154 (2026-05-12) сюда добавлялось `[skip ci]`. +> Это работало для main pipeline'а, НО skip'ало и tag pipeline на том же SHA +> → images с tag'ом `v*-*-*-` не собирались → `propose-prod-image-bump` +> auto-PR не запускался → manual ручной MR в infra-repo. Удалено. + +## ⚠ Skip-ci marker trap + +GitLab pipeline pattern matcher honors `[skip ci]` в commit subject ИЛИ body — +**где угодно в message'е**. semantic-release auto-генерирует release notes +из subject+body всех commit'ов с момента последнего tag'а. + +**Trap:** если хоть один commit body содержит литеральное `[skip ci]` (даже +описывая фикс этого marker'а в комментариях/changelog), эта строка попадёт +в release commit body → GitLab пропустит tag pipeline → нет images → +manual escape через `glab api -X POST projects/367/pipeline?ref=vX.Y.Z`. + +**Реальный случай:** v2.16.1 (2026-05-12). Commit `fix(release): remove +[skip ci] из semantic-release commit message` — literal `[skip ci]` в +subject. Release notes v2.16.1 содержали эту строку → tag pipeline 6831 +skipped. Pipeline пришлось trigger вручную. + +**Защита:** CI job `commit-msg-lint` (см. `.gitlab-ci.yml`) fail'ит любой +commit с substring `[skip ci]` в subject или body. Запускается на MR +pipeline'ах и feature-branch push'ах. + +**Если нужно упомянуть skip-ci в commit message:** + +- ✅ `fix: remove skip-ci marker` (дефис вместо пробела) +- ✅ `docs: explain GitLab pipeline skip syntax` +- ✅ `chore: remove the skip-ci directive from .releaserc` +- ❌ `fix: remove [skip ci]` (literal — fails lint) +- ❌ `update [skip ci] policy` (literal — fails lint) + +## Manually trigger skipped tag pipeline + +Если tag pipeline всё-таки попал в trap (legacy commits до commit-msg-lint +или CI bypass через --no-verify): + +```bash +GITLAB_HOST=git.nstart.cloud glab api -X POST "projects/367/pipeline?ref=vX.Y.Z" +``` + +Создаёт новый pipeline на той же tag-ref. Escape hatch, не regular workflow. ## Branch policy