From 4db64382458b5a290e026810a6879c880708fcb8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 21 May 2026 17:54:41 +0700 Subject: [PATCH] remove commitlint configuration and update release documentation to enforce single scope in commit messages --- .commitlintrc.json | 3 -- .cursor/rules/conventional-commits.mdc | 2 + .gitea/workflows/ci.yaml | 2 + commitlint.config.cjs | 22 ++++++++ docs/releasing.md | 17 ++++++ scripts/commit/verify-release-commits.mjs | 64 +++++++++++++++++++++++ 6 files changed, 107 insertions(+), 3 deletions(-) delete mode 100644 .commitlintrc.json create mode 100644 commitlint.config.cjs create mode 100644 scripts/commit/verify-release-commits.mjs diff --git a/.commitlintrc.json b/.commitlintrc.json deleted file mode 100644 index c30e5a9..0000000 --- a/.commitlintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["@commitlint/config-conventional"] -} diff --git a/.cursor/rules/conventional-commits.mdc b/.cursor/rules/conventional-commits.mdc index 1b88fc9..ac64b6e 100644 --- a/.cursor/rules/conventional-commits.mdc +++ b/.cursor/rules/conventional-commits.mdc @@ -142,6 +142,8 @@ feat(web): add module create dialog on /modules | `.cursor/` | `chore` | | прочее в корне | `chore` | +**Запрещено:** несколько scope через запятую (`refactor(web, httpapi): …`) — semantic-release не распознает `type`, релиз не будет (см. [docs/releasing.md](../../docs/releasing.md)). + `type` определять по **содержимому diff**, не только по пути. ## Multi-change diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e2a4f04..4c9c61e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -303,6 +303,8 @@ jobs: cache-dependency-path: package-lock.json - name: Install release tooling run: npm ci + - name: Verify releasable commit messages + run: node scripts/commit/verify-release-commits.mjs - name: Semantic release run: npx semantic-release env: diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..497fd8b --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,22 @@ +/** @type {import('@commitlint/types').UserConfig} */ +module.exports = { + extends: ['@commitlint/config-conventional'], + plugins: [ + { + rules: { + 'scope-no-commas': ({ scope }) => { + if (scope && scope.includes(',')) { + return [ + false, + 'scope must not contain commas (semantic-release will not parse the commit type)' + ]; + } + return [true]; + } + } + } + ], + rules: { + 'scope-no-commas': [2, 'always'] + } +}; diff --git a/docs/releasing.md b/docs/releasing.md index 6e8c367..bf92e0b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,6 +11,8 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi | `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) | | `docs`, `chore`, `test` | без релиза | +**Scope:** один идентификатор **без запятых** (`web`, `httpapi`, `api`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит). Подробнее — раздел «Scope и semantic-release» ниже. + `refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor). Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`). @@ -62,6 +64,21 @@ API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build Web UI показывает версию из API (footer sidebar, страница «Мониторинг»). +## Scope и semantic-release + +Парсер [conventional-commits-parser](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser) (его использует semantic-release) **не понимает запятые в scope**: + +| Заголовок | Парсится | Релиз | +|-----------|----------|-------| +| `refactor(web): fix layout` | да, `refactor` | patch | +| `refactor(NetworkOverviewTab, NetworkSpeakersCard): fix layout` | **нет**, `type: null` | **нет** | + +Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …). + +На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — при непарсящихся коммитах CI упадёт до semantic-release. + +Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`). + ## CHANGELOG Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history. diff --git a/scripts/commit/verify-release-commits.mjs b/scripts/commit/verify-release-commits.mjs new file mode 100644 index 0000000..c854f6e --- /dev/null +++ b/scripts/commit/verify-release-commits.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Warns about commits since the last tag that semantic-release cannot parse. + * Exit 0 always — semantic-release still decides release/no-op. + */ +import { execSync } from 'node:child_process'; +import parser from 'conventional-commits-parser'; + +const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']); + +function lastTag() { + try { + return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} + +function commitsSince(ref) { + const range = ref ? `${ref}..HEAD` : 'HEAD'; + const out = execSync(`git log ${range} --format=%H%x09%s`, { encoding: 'utf8' }).trim(); + if (!out) return []; + return out.split('\n').map((line) => { + const [hash, subject] = line.split('\t'); + return { hash: hash.trim(), subject: subject.trim() }; + }); +} + +const tag = lastTag(); +const commits = commitsSince(tag); +const unparseable = []; +const releasable = []; + +for (const { hash, subject } of commits) { + const parsed = parser.sync(subject); + if (!parsed.type) { + unparseable.push({ hash: hash.slice(0, 7), subject }); + continue; + } + if (RELEASABLE.has(parsed.type)) { + releasable.push({ hash: hash.slice(0, 7), subject, type: parsed.type }); + } +} + +if (commits.length === 0) { + console.log(`No new commits since ${tag || 'initial'}.`); + process.exit(0); +} + +if (unparseable.length > 0) { + console.warn('::warning:: Commits not parseable by semantic-release (no version bump):'); + for (const b of unparseable) { + console.warn(` ${b.hash} ${b.subject}`); + } + console.warn('Fix: single scope without commas, e.g. refactor(web): summary'); +} + +if (releasable.length > 0) { + console.log(`Releasable since ${tag}: ${releasable.length} commit(s).`); +} else { + console.warn('::warning:: No releasable commits since last tag — release job will no-op.'); +} + +process.exit(0);