Files
zernie__vigiles/scripts/release-preset.test.ts
zernie f8280a93db fix(release)!: feat! не давал релиза ВООБЩЕ — ломающие уехали как патч (#152)
Замерено настоящим анализатором на настоящих сообщениях, а не по памяти.
При дефолтном пресете (angular) `analyzeCommits` на `feat!:` возвращает
**null** — не «минор вместо мажора», а НИ ОДНОГО релиза: пресет не
распознаёт `feat!` как тип, и ни одно правило не срабатывает.

| сообщение                                   | angular | conventionalcommits |
|---------------------------------------------|---------|---------------------|
| `feat!:` без футера (наш #149)               | null    | major               |
| `feat!:` + `BREAKING:` в теле (наш #147)     | null    | major               |
| `feat:` + футер `BREAKING CHANGE:`           | major   | major               |
| обычный `feat:`                              | minor   | minor               |
| обычный `fix:`                               | patch   | patch               |
| `docs:`                                      | null    | null                |

Следствие серьёзнее аномалии: код #147 (рейм публичного `skill()`) и
#149 (разделение экспортов) всё равно уехал на npm — его вынесли
следующие коммиты, под `v15.4.0` (minor) и `v15.4.1` (patch). То есть
ломающие изменения отгружены как ПАТЧ; кто стоит на `^15.4.0`, получил
их молча.

🔴 Что делает это ловушкой, а не опечаткой: `pr-title.yml` гоняет
`amannn/action-semantic-pull-request`, который `feat!` ПРИНИМАЕТ — джоб
`validate` был зелёным на обоих PR. Один механизм говорит автору, что
маркер валиден, второй его выбрасывает, и оба молчат.

Починка: пресет `conventionalcommits` у commit-analyzer и
release-notes-generator (второй — чтобы changelog и версия читали одну
грамматику). Плюс `conventional-changelog-conventionalcommits` в devDeps.

Тест `scripts/release-preset.test.ts` закрепляет всю таблицу, включая
пол (`docs:` → null): пресет, бампающий на каждом коммите, прошёл бы
верхние строки и всё равно был бы неверен. Прогнан мутацией — возврат к
bare-string конфигу роняет все шесть; сообщение ошибки при этом само
объясняет, что bare string означает angular.

Цена: `@semantic-release/commit-analyzer` в devDeps ради поведенческого
теста, дельта в node_modules замерена — 134 пакета (381 → 515).
Альтернатива «проверять форму конфига» отвергнута: она прошла бы и при
неверном имени пресета, и при отсутствующем пакете.

Гейты: npm run check 17/17 ✓ · vitest 2960 ✓.


Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-17 02:24:21 +05:00

94 lines
3.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { analyzeCommits } from "@semantic-release/commit-analyzer";
/**
* The release config decides what version a merge ships as. It is configuration,
* not code, so nothing else in this repo would notice it being wrong — and it WAS
* wrong, silently, for as long as anyone used the `!` breaking marker.
*
* 🔴 MEASURED 2026-08-17. Two PRs merged with `feat!:` titles (#147 renaming the
* public `skill()` builder, #149 splitting the exports by cost). Under the default
* ANGULAR preset, `analyzeCommits` returns **null** for `feat!:` — not "minor
* instead of major", but NO RELEASE AT ALL: the preset does not recognise `feat!`
* as a type, so no release rule matches. Their code still reached npm, carried by
* the next commits, under `v15.4.0` (minor) and `v15.4.1` (patch). Breaking
* changes shipped as a patch bump; anyone on `^15.4.0` took them silently.
*
* What makes it a trap rather than a typo: `.github/workflows/pr-title.yml` runs
* `amannn/action-semantic-pull-request`, which ACCEPTS `feat!` — the `validate`
* job was green on both PRs. One mechanism told the author the marker was valid
* while another dropped it, and neither said a word.
*
* These cases pin the whole table, not just the fix: a regression to the angular
* preset turns the first two rows null and fails here.
*/
describe("release preset", () => {
const root = resolve(import.meta.dirname, "..");
const ctx = { logger: { log: () => {} }, cwd: root, env: {} };
/** The plugin options actually shipped in `.releaserc.json`. */
function analyzerOptions(): Record<string, unknown> {
const rc = JSON.parse(
readFileSync(join(root, ".releaserc.json"), "utf8"),
) as { plugins: unknown[] };
for (const p of rc.plugins) {
if (Array.isArray(p) && p[0] === "@semantic-release/commit-analyzer") {
return p[1] as Record<string, unknown>;
}
}
throw new Error(
"commit-analyzer is not configured with options in .releaserc.json — " +
"a bare string entry means the default (angular) preset, under which " +
"`feat!:` produces no release at all.",
);
}
async function releaseType(message: string) {
return analyzeCommits(analyzerOptions(), {
...ctx,
commits: [{ hash: "0".repeat(40), message }],
});
}
it("`feat!:` alone is a MAJOR — the marker the PR-title gate accepts", async () => {
expect(await releaseType("feat!: split the public exports by COST\n")).toBe(
"major",
);
});
it("`feat!:` with a prose BREAKING: line is still a MAJOR", async () => {
// The real #147 body said `BREAKING:`, not the `BREAKING CHANGE:` footer.
// Under angular that combination was ALSO null — the prose line rescues
// nothing, which is why the body wording was never the real bug.
expect(
await releaseType(
"feat!: one instability marker\n\nBREAKING: renamed.\n",
),
).toBe("major");
});
it("a `BREAKING CHANGE:` footer without `!` is a MAJOR", async () => {
expect(
await releaseType("feat: split\n\nBREAKING CHANGE: input removed.\n"),
).toBe("major");
});
it("an ordinary `feat:` is a minor", async () => {
expect(await releaseType("feat(site): add docs links\n")).toBe("minor");
});
it("an ordinary `fix:` is a patch", async () => {
expect(await releaseType("fix(ci): repair the path filter\n")).toBe(
"patch",
);
});
it("`docs:` releases nothing", async () => {
// The floor matters as much as the ceiling: a preset that bumped on every
// commit would pass the rows above and still be wrong.
expect(await releaseType("docs: tweak a heading\n")).toBeNull();
});
});