ci: use conventionalcommits preset for semantic-release #5
Loading…
Reference in a new issue
No description provided.
Delete branch "ci/semantic-release-conventionalcommits"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The Release run for PR #3's merge (run 12133) went green but released nothing:
release.config.mjsused the commit-analyzer's default angular preset, whose header pattern cannot parse the Conventional Commits!marker. Thefeat!:squash subject therefore scored as no release instead of major — semantic-release exited 0, the workflow stayed green, and v2.0.0 was silently never cut. Registry, tags, and Forgejo releases all still say 1.0.0.This switches both the commit-analyzer and the release-notes-generator to
preset: "conventionalcommits"and adds theconventional-changelog-conventionalcommitspackage it loads, pinned to^9.3.1: release-notes-generator 14 loadsconventional-changelog-writer8, and the v10 preset emits the writer-9 API against it —generateNotesthen renders only the version heading, with no sections and no error. Verified against the exact installed versions (commit-analyzer 13.0.1, notes generator 14.1.1, preset 9.3.1) by invokinganalyzeCommitsandgenerateNotesdirectly on the real squash subject:null(reproduces the silent skip)"major", but notes render as the bare2.0.0compare heading — empty release notes, no error"major", and notes render in full (BREAKING CHANGES section included)The knip
ignoreDependenciesentry is needed because knip's semantic-release plugin extracts only plugin names from the config, never apresetoption, so the dynamically-loaded preset package is invisible to it.Merging this triggers the Release workflow, which re-analyzes every commit since v1.0.0 — including the
feat!:squash — and cuts v2.0.0.Follow-up outside this repo: the align template
templates/repo/release-config.mjs.hbshas the same gap, so every aligned repo silently skips releases onfeat!:/fix!:squash subjects; the identical preset change (with the same pin) landed in j4k-align PR #169 so this file stops counting as drift and the fleet gets the fix.Approach review: The approach is correct, minimal, and the canonical fix. No material alternative worth taking.
I verified the load-bearing claims against the exact pinned versions rather than taking the description at its word:
conventional-changelog-angular@8.3.1(the version the lockfile resolves for both plugins) exposesheaderPattern: /^(\w*)(?:\((.*)\))?: (.*)$/and nobreakingHeaderPattern. Afeat!:subject therefore parses totype: nullwith zero notes, so neither{breaking: true}nor{type: "feat"}in commit-analyzer's default release rules matches, andanalyzeCommitsreturnsnull. The diagnosis of the silent green run is right, and the!gap is genuinely in the preset, not in the workflow or the config wiring.@semantic-release/commit-analyzer@13.0.1(lib/load-parser-config.js) and@semantic-release/release-notes-generator@14.1.1(lib/load-changelog-config.js) resolve apresetasconventional-changelog-<preset>viaimportFrom.silent(__dirname, …) || importFrom(cwd, …). Under pnpm's isolated store the package is not reachable from the plugin directory, so it has to be resolvable from the project root — the directdevDependenciesentry is required, not incidental..forgejo/workflows/release.ymlinstalls withpnpm install --frozen-lockfile(full dev tree), so it will be present at release time. The non-silentimportFromalso means a future drop of the dependency fails the release loudly rather than reverting to the same silent skip.feat!:inconsistently between the version bump and the rendered notes.The strongest argument for
conventionalcommitsover the surgical alternatives is consistency with what the repo already enforces..forgejo/workflows/commit-msg.ymlvalidates the PR title (the squash subject) withconventional-commit-msg, which accepts the spec's!marker. The gate and the analyzer were speaking different dialects; this change makes the release side honor the same spec the merge side already requires, instead of encoding a second, hand-maintained interpretation of it.Alternatives I considered and would not recommend:
parserOpts: { breakingHeaderPattern: … }. Smaller diff, but it hand-rolls a regex that has to track the spec and would drift from the commit-msg gate again. Strictly worse for a config that is heading into a fleet template.releaseRulesentry for breaking changes. It cannot work: the rules run on the parser's output, and the header never parses in the first place.knip.jsonescape hatch by importing the preset statically. Not possible — both thepresetandconfigplugin options take a package-name string that the plugin imports dynamically, so knip's semantic-release plugin has nothing to extract.ignoreDependenciesis the correct mechanism here, and the inline comment onrelease.config.mjsrecords the non-obvious why for the preset choice itself.One optional, non-blocking simplification: semantic-release passes root-level options to every plugin, so a single
preset: "conventionalcommits"at the top ofreleaseConfigwould cover both plugins and remove the possibility of the two drifting apart. The explicit per-plugin form is equally defensible and arguably clearer as template material, so this is a preference, not a defect.Approach review by Claude Code Opus (opus)
Approach review: The diagnosis and the shape of the fix are right. I reproduced both halves against the exact resolved versions (commit-analyzer 13.0.1, release-notes-generator 14.1.1, writer 8.4.0):
conventional-changelog-angular@8.3.1really does ship aheaderPatternwith nobreakingHeaderPattern, soanalyzeCommits({}, ...)returnsnullfor thefeat!:squash subject and returns"major"withpreset: "conventionalcommits". Switching the preset (rather than patchingparserOpts.breakingHeaderPattern, which also yieldsmajor) is the better of the two — it fixes parsing and notes rendering together and matches what the fleet template should carry.One material problem with the version chosen:
conventional-changelog-conventionalcommits@^10is ahead of what semantic-release 25 can render. Preset 10 emits@conventional-changelog/templatefunction partials, but@semantic-release/release-notes-generator@14.1.1(the current latest) depends onconventional-changelog-writer@^8, which is handlebars-based. The analyzer still returnsmajor, so v2.0.0 gets cut — but the release notes silently collapse to the version heading with every commit section dropped. Under@9.3.1the same input renders Features / Bug Fixes / BREAKING CHANGES correctly. Details and repro are in the inline comment.Everything else reads fine: the
knip.jsonignore entry is the right escape hatch for a dynamically loaded preset, and both plugins being switched (not just the analyzer) is correct. Given this class of bug is silent — green workflow, wrong or empty output — a small vitest case that runsanalyzeCommits/generateNotesfromrelease.config.mjsagainst afeat!:subject would have caught both the original miss and the preset-10 notes regression; worth considering alongside the j4k-align rollout, since the version pin propagates to every aligned repo.Approach review by Claude Code Opus (opus)
@ -50,6 +50,7 @@"@total-typescript/ts-reset": "^0.6.1","@types/node": "^25.9.2","@vitest/coverage-v8": "^4.1.8","conventional-changelog-conventionalcommits": "^10.2.1",^10.2.1is ahead of what semantic-release 25 can render, and the failure is silent.Preset 10 builds its
writerOptspartials with@conventional-changelog/template(function partials), but@semantic-release/release-notes-generator@14.1.1— the latest published version — depends onconventional-changelog-writer@^8, which is handlebars-based, and the lockfile here resolvesconventional-changelog-writer@8.4.0. The commit-analyzer half is unaffected (majoreither way), so v2.0.0 still gets cut; the notes half quietly loses every section.Reproduced with commit-analyzer 13.0.1 + release-notes-generator 14.1.1 on commits
feat!: breaking thing,feat: plain thing,fix: a bug:10.2.1,generateNotesreturns only## [2.0.0](...compare/v1.0.0...v2.0.0) (date)— no Features, no Bug Fixes, no BREAKING CHANGES9.3.1, it returns the full body:### ⚠ BREAKING CHANGES,### Features(both feat commits),### Bug FixesSuggest
"conventional-changelog-conventionalcommits": "^9.3.1"until release-notes-generator moves to writer 9.^9also won't drift into the incompatible major on its own. Worth carrying the same pin into the j4k-align template, otherwise every aligned repo cuts correct versions with empty release notes.Summary: The core diagnosis in this PR is correct and I reproduced it: with the exact pinned versions,
analyzeCommitsreturnsnullfor thefeat!:squash subject under the default angular preset (conventional-changelog-angular@8.3.1has nobreakingHeaderPattern) and"major"withpreset: "conventionalcommits". The lockfile additions and integrity hashes match the registry, and the knipignoreDependenciesentry is justified.One blocking problem: the chosen preset version,
^10.2.1, is a major ahead of what the semantic-release stack can consume. Preset v10 emits theconventional-changelog-writer@9function-template API, while@semantic-release/release-notes-generator@14.1.1depends on writer^8(Handlebars strings). The bump would be computed correctly, but the v2.0.0 release notes/Forgejo release body would contain only the version heading — noFeatures,Bug Fixes, or⚠ BREAKING CHANGESsections. Pinning^9.3.1produces the full notes with the same"major"bump.Code review by Claude Code Opus (opus)
@ -50,6 +50,7 @@"@total-typescript/ts-reset": "^0.6.1","@types/node": "^25.9.2","@vitest/coverage-v8": "^4.1.8","conventional-changelog-conventionalcommits": "^10.2.1",🟠 High:
conventional-changelog-conventionalcommits@10is incompatible with the release-notes-generator in this lockfile, and the result is a release body containing only the version heading — every### Features/### Bug Fixes/### ⚠ BREAKING CHANGESsection is silently dropped.Root cause: preset v10 emits the writer v9 API (function-valued
template/headerPartial/commitPartial/footerPartial, via its new@conventional-changelog/templatedependency), but@semantic-release/release-notes-generator@14.1.1depends onconventional-changelog-writer@^8, which is Handlebars-string based (loadTemplateslooks formainTemplate, andcompileTemplatesregisters the partials with Handlebars). Handlebars happens to accept a function as a partial, so the preset'sheaderPartial(context)still renders the heading — which is why the2.0.0compare heading in the PR description looked right — butcommitPartialis then invoked as(commit, handlebarsOptions)instead of(context, commit)and returns'', andfooterPartialreturns''unconditionally, so the notes body is the heading and nothing else. This is also why v10 needs nocompare-funcwhile writer-v8-compatible presets (conventional-changelog-angular@8.3.1,conventional-changelog-conventionalcommits@9.x) do.Reproduced with the exact versions in this lockfile (commit-analyzer 13.0.1, release-notes-generator 14.1.1), calling
generateNotes({preset:"conventionalcommits"}, ctx)onfeat!: ...,feat: ...andfix: ...commits:analyzeCommitsis unaffected — it only consumesparseropts, and returns"major"under both 9.3.1 and 10.2.1, so the version bump this PR is fixing works either way; only the notes break.Fix: pin
"conventional-changelog-conventionalcommits": "^9.3.1"(last writer-v8-compatible line) and refresh the lockfile. Upgrading the writer instead is not an option — release-notes-generator's latest release still declaresconventional-changelog-writer: ^8.0.0. Dropping the preset from the notes generator is also not an option: the angular parser discards thefeat!:commit entirely, so its notes lose that commit too.@ -5,0 +3,4 @@// The default angular preset cannot parse the Conventional Commits "!" marker,// so a "feat!:" squash subject silently produces no release instead of a major.["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],🟠 High: This is the line affected by the preset-version problem flagged on
package.json— withconventional-changelog-conventionalcommits@10.2.1, this plugin renders only the version heading and drops every commit section and the⚠ BREAKING CHANGESsection (preset v10 targetsconventional-changelog-writer@9's function templates; release-notes-generator 14.1.1 pins writer^8, which is Handlebars-string based). Line 5 (commit-analyzer) is fine either way, since it consumes onlyparseropts. Pinning the preset to^9.3.1makes both lines correct.Summary: Found 1 high-severity issue.
I reproduced the PR's diagnosis with the exact installed versions (
@semantic-release/commit-analyzer@13.0.1,@semantic-release/release-notes-generator@14.1.1) against the realfeat!:squash subject of66c8896: the default angular preset (conventional-changelog-angular@8.3.1, whoseheaderPatternis/^(\w*)(?:\((.*)\))?: (.*)$/with nobreakingHeaderPattern) yieldsnull, andpreset: "conventionalcommits"yieldsmajor. The analyzer half of this change is correct and will cut v2.0.0.The pinned preset version is the problem:
conventional-changelog-conventionalcommits@10.2.1ships writer-v9-style JS function templates, whilerelease-notes-generator@14.1.1still depends onconventional-changelog-writer@^8(handlebars string templates). The combination renders only the version heading — every Features / Bug Fixes / BREAKING CHANGES section is silently dropped from the release body. Pinning^9.3.1keeps themajorverdict and restores full notes; details in the inline comment.Code review by Claude Code Opus (opus)
@ -50,6 +50,7 @@"@total-typescript/ts-reset": "^0.6.1","@types/node": "^25.9.2","@vitest/coverage-v8": "^4.1.8","conventional-changelog-conventionalcommits": "^10.2.1",🟠 High:
^10.2.1of this preset is incompatible with the release-notes-generator thatsemantic-release@25bundles, so the v2.0.0 release body will contain nothing but the version heading.conventional-changelog-conventionalcommits@10.2.1exports writer-v9-style JS function templates (src/templates.jsexportstemplate/headerPartial/commitPartialas functions), but@semantic-release/release-notes-generator@14.1.1depends onconventional-changelog-writer@^8, which expects handlebars template strings and readswriterOpts.mainTemplate(nottemplate). The header partial happens to render as a function partial; the commit and note groups do not.Verified with the exact installed versions, same commit objects, only the preset version differing:
10.2.1→## [2.0.0](…/compare/v1.0.0...v2.0.0) (2026-07-31)and nothing else (93 bytes)9.3.1→ the same heading plus### ⚠ BREAKING CHANGES,### Features,### Bug Fixeswith the commit links (436 bytes for the realfeat!:squash)analyzeCommitsreturnsmajorunder both preset versions, so downgrading costs nothing on the half this PR is actually fixing. Upgrading the generator is not an option —@semantic-release/release-notes-generator@latestis still14.1.1on writer^8.Fix:
"conventional-changelog-conventionalcommits": "^9.3.1"(and refreshpnpm-lock.yaml; v9 drops the@conventional-changelog/templatedep and addscompare-func). Worth carrying into thej4k-aligntemplate change mentioned in the PR body, since every aligned repo would otherwise get empty release notes.@ -5,0 +3,4 @@// The default angular preset cannot parse the Conventional Commits "!" marker,// so a "feat!:" squash subject silently produces no release instead of a major.["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],ℹ️ Info: This is the entry affected by the preset-version mismatch flagged on
package.json. The commit-analyzer entry on line 5 is unaffected — it only consumesparserOpts, which are plain objects in both preset major versions.Round-1 disposition (fix in
1a39834):^9.3.1and the lockfile refreshed. Reproduced both ways before pushing: with 10.2.1,generateNotesreturns only the 93-byte compare heading; with 9.3.1 it returns the full 438-byte body (⚠ BREAKING CHANGES + Features sections) andanalyzeCommitsstill scores the realfeat!:squash asmajor. Candidly: my pre-PR verification rangenerateNotesand printed exactly the truncated heading — I misread it as success; the reviewers' catch is exactly why this cycle exists. The same pin is applied to j4k/align#169 so the fleet template rollout carries v9, not v10.presetsuggestion — acknowledged, keeping the per-plugin arrays: each plugin names its preset explicitly, the shape byte-matches the align template render, and the reviewer flagged it as a preference, not a defect.analyzeCommits/generateNotesfrom the config against afeat!:subject) — agreed on the value, but the right home is fleet-side: this file is align-managed, so a behavioral guard belongs next to the template. Filed as a follow-up on j4k/align rather than growing this repo's suite around a generated file.All five conversations resolved.
Approach review: The approach is correct and minimal.
The fix swaps the default
angularpreset (which cannot parse the Conventional Commits!marker, silently scoringfeat!:as no-release) forpreset: "conventionalcommits"on both@semantic-release/commit-analyzerand@semantic-release/release-notes-generator. This is the documented, canonical way to make semantic-release handle the!marker — the only realistic alternative would be hand-rolledreleaseRules/parserOpts, which would be more complex and less idiomatic for no gain.conventional-changelog-conventionalcommitsis correctly placed indevDependenciesalongsidesemantic-releaseitself, which only runs in the Release CI workflow where devDependencies are installed; nothing independenciesneeds it.The knip
ignoreDependenciesentry is the documented escape hatch for a package that semantic-release loads dynamically by string name, which knip's plugin cannot statically see. It is the right tool.No materially better approach exists.
Approach review by Approach Review 3 (OpenCode Wafer) (GLM-5.2)
Approach review: The approach is correct and minimal, and I independently confirmed the diagnosis against the real packages rather than taking the description on trust:
conventional-changelog-angular@8.3.1(src/parser.js) defines onlyheaderPattern: /^(\w*)(?:\((.*)\))?: (.*)$/and nobreakingHeaderPattern, so afeat!:subject fails to parse, yields a nulltypewith no notes, and matches none of commit-analyzer’s default release rules — exactly the silent no-release described.conventional-changelog-conventionalcommits@9.3.1defines bothheaderPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/andbreakingHeaderPattern, so the!marker resolves to a major.@semantic-release/commit-analyzer@13.0.1resolves a preset throughimportFrom.silent(__dirname, ...) || importFrom(cwd, ...)and depends only on the angular preset, so the preset package genuinely has to be a root dependency of this repo — thedevDependenciesentry is required, not incidental, and the Release workflow’spnpm install --frozen-lockfileinstalls it.On alternatives I considered and rejected as not better:
parserOptswith a hand-writtenheaderPattern/breakingHeaderPatterninstead of adding the package would duplicate preset internals across two plugins and drift from upstream; adding the published preset is the standard facility.knip.jsonignoreDependenciesentry is the right escape hatch: knip’s semantic-release plugin extracts plugin names only, so a preset resolved by name at runtime is invisible to it, and there is no more precise knip mechanism for a dynamically resolved package.presetkey to@semantic-release/npmand@j4k/semantic-release-forgejo; that trade is a preference, not a defect.The change also brings the release pipeline in line with the
commit-msgworkflow, which already acceptsfeat!:as a valid subject — the two were disagreeing, and now they do not. No material alternative to raise.Approach review by Claude Code Opus (opus)
Approach review: The approach looks good.
Switching both
@semantic-release/commit-analyzerand@semantic-release/release-notes-generatorto the officialconventionalcommitspreset is the standard facility for this problem, and it is clearly preferable to the alternatives: hand-rollingparserOpts.headerPatternto admit the!marker, adding customreleaseRules, or mandating aBREAKING CHANGE:footer in squash subjects would each reimplement or work around what the preset already specifies, and would drift from the spec on the next parser change. Declaringconventional-changelog-conventionalcommitsas a devDependency is required because semantic-release resolvespresetby dynamic import, andknip.json'signoreDependenciesis the documented escape hatch for a package knip's semantic-release plugin structurally cannot see (it reads plugin names, not plugin options). The scope is minimal — no workflow, source, or test changes — and the fix lands entirely in config.The major-alignment constraint is handled correctly:
^9.3.1keeps the preset on the same major line as theconventional-changelog-writer@8that commit-analyzer 13.0.1 and notes-generator 14.1.1 pull transitively, and the caret cannot cross into 10. The follow-up commit on this branch already made that pin, so the PR description's "preset 10.2.1" is stale relative to the head; since the squash subject and body feed the release notes this cuts, it is worth refreshing before merge.One non-blocking observation, offered rather than requested: the failure class here — semantic-release exiting 0 having released nothing, with a green workflow — stays invisible after this fix, and
ignoreDependenciesadditionally means knip will not flag the preset package if thepresetoption is ever dropped fromrelease.config.mjs. If the fleet-wide change heading into j4k-align is a good moment for it, a single assertion thatanalyzeCommitsreturnsmajorfor afeat!:subject against the real config would pin both halves down. Not a reason to hold this PR.Approach review by Claude Code Opus (opus)
Summary: Found 1 low-severity issue. The core change is correct and I reproduced it.
Verification (fresh installs of the exact locked versions — commit-analyzer 13.0.1, release-notes-generator 14.1.1, conventional-changelog-writer 8.4.0, preset 9.3.1 — invoking
analyzeCommits/generateNoteson the realfeat!:squash subject of66c8896):analyzeCommits→null, notes render only the version heading — reproduces the silent skip described in the PR body.preset: "conventionalcommits"@ 9.3.1:analyzeCommits→"major", notes render the2.0.0compare heading plus⚠ BREAKING CHANGESandFeaturessections with correct Forgejo/issues/and/commit/links.node_modules(repeated the run with pnpm 11.8.0), so the dynamically loaded preset is found at release time; the workflow'spnpm install --frozen-lockfile --ignore-scriptsinstalls devDependencies, so it is present.Lockfile is consistent with
package.json(^9.3.1→ 9.3.1), the integrity hash matches the public registry entry, andcompare-funcstays deduped at 2.0.0. The knipignoreDependenciesentry is warranted: in a minimal repro, knip 6 reports the preset as an unused devDependency without it.Code review by Claude Code Opus (opus)
@ -5,0 +3,4 @@// The default angular preset cannot parse the Conventional Commits "!" marker,// so a "feat!:" squash subject silently produces no release instead of a major.["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],🟢 Low: The
^9.3.1range on the preset is load-bearing, but nothing in the tree records why — the only trace is theci: pin conventionalcommits preset to v9 for writer-8 notes compatcommit subject.I reproduced the incompatibility with the locked plugin versions: with
conventional-changelog-conventionalcommits@10.2.1andconventional-changelog-writer@8.4.0(what release-notes-generator 14.1.1 pulls in),analyzeCommitsstill returns"major", butgenerateNotesreturns only the version heading — noBREAKING CHANGESsection, noFeaturessection, and no error. Preset 9.x declares no peer range on the writer, so neither npm nor pnpm can warn, and a future^10bump (manualpnpm up --latestor an automated updater) would silently empty every release note while CI stays green — the same silent-failure class this PR exists to fix.Since
package.jsoncannot carry comments, extend the comment block already added here to state the constraint, e.g.// Preset pinned to 9.x: 10.x renders empty notes against conventional-changelog-writer 8 (what release-notes-generator 14 installs).Summary: The fix is correct and I reproduced every claim in the PR body against the exact locked versions (
@semantic-release/commit-analyzer13.0.1,@semantic-release/release-notes-generator14.1.1, preset 9.3.1). Found 1 low issue.Verification performed:
analyzeCommitson the real squash subjectfeat!: non-blocking default, distinct exit codes, JSON error envelopes (#3)alone returnsnullwith the default angular preset and"major"withpreset: "conventionalcommits"— the silent-skip reproduces exactly as described.generateNoteswith the preset renders the2.0.0compare heading plus⚠ BREAKING CHANGES,Features, andBug Fixessections.node_modules(enableGlobalVirtualStore: true): commit-analyzer'simportFrom(cwd, ...)fallback finds the root devDependency symlink, sopnpm install --frozen-lockfile --ignore-scriptsinrelease.ymlis sufficient.perf:andrevert:commits render identically under both presets, so the preset switch causes no release-notes regression for existing commit types.knipignoreDependenciesentry is genuinely load-bearing: knip 6.16.1's semantic-release plugin (dist/plugins/semantic-release/index.js) mapsconfig.pluginsto names only and never reads apresetoption, so the package would otherwise be reported unused — andpnpm knipruns inchecks.yml.oxfmt --checkpasses onrelease.config.mjs,package.json, andknip.json;release.config.mjsstill imports cleanly; the lockfile carries no leftover 10.2.1 reference.The v9 pin from the follow-up commit is justified, and the failure mode is worse than "notes compat": preset 10.2.1 against writer 8.4.0 emits the compare heading and zero sections, with no error. Note the PR body still describes verification against preset 10.2.1 while the head pins
^9.3.1— worth refreshing the description before merge so the record matches what ships.Code review by Claude Code Opus (opus)
@ -3,2 +3,2 @@"@semantic-release/commit-analyzer","@semantic-release/release-notes-generator",// The default angular preset cannot parse the Conventional Commits "!" marker,// so a "feat!:" squash subject silently produces no release instead of a major.🟢 Low: The comment records why the preset is needed, but not the constraint that makes the
^9.3.1pin inpackage.jsonload-bearing — and that constraint fails silently.The preset's major must match the
conventional-changelog-writermajor used by@semantic-release/release-notes-generator. I ran the mismatch against the locked versions: preset 10.2.1 with release-notes-generator 14.1.1 (writer 8.4.0) producesand nothing else — no
BREAKING CHANGES, noFeatures, noBug Fixes— with no thrown error. semantic-release would exit 0 and cut an empty-notes release, the same green-but-wrong shape this PR exists to fix.The caret range itself is safe today (9.3.1 is the last 9.x, and
semantic-release@^25.0.7holds release-notes-generator at^14.1.0/writer^8), so this is not a live defect. It only bites on a deliberate major bump of either package. Extending the existing comment with one clause, e.g.pinned to v9 because release-notes-generator 14 loads conventional-changelog-writer 8; a v10 preset renders empty notes without erroring, keeps that reasoning where the next person bumping the dependency will see it.Round 3 (head
cd5cf60) — both findings addressed.^9.3.1pin is load-bearing): implemented incd5cf60— therelease.config.mjscomment now records that release-notes-generator 14 loads conventional-changelog-writer 8 and a v10 preset renders empty notes against it without erroring. Both reviews asked for the same clause; one comment block now carries it.Approach review: The approach is sound. Switching both
commit-analyzerandrelease-notes-generatortopreset: "conventionalcommits"is the standard, correct fix for the angular preset's inability to parse the Conventional Commits!marker — the root cause of the silent no-release onfeat!:. Applying it to both plugins is right: the analyzer decides the level, but the generator renders the BREAKING CHANGES section, so both must agree.The
^9.3.1pin is a justified defensive measure, empirically verified against the exact installed writer version (release-notes-generator 14 → conventional-changelog-writer 8) that v10 of the preset renders empty notes against. The knipignoreDependenciesentry is necessary because the preset is loaded dynamically by plugin name, invisible to static analysis. Adding the preset as a devDependency (not a runtime dep) is correct since semantic-release runs in CI only.A custom
releaseRules/parserOptsheader-pattern extension to the angular preset would be more complex for no benefit, and upgrading release-notes-generator to chase preset v10 is not clearly preferable to the verified pin. The systemic follow-up for the align template fleet is noted. No materially better alternative.Approach review by Approach Review 3 (OpenCode Wafer) (GLM-5.2)
Approach review: The approach looks good — this is the canonical fix, and the supporting premises check out.
What I verified independently of the PR body:
conventional-changelog-angular@8.3.1(the version the lockfile resolves for both plugins) shipsheaderPattern: /^(\w*)(?:\((.*)\))?: (.*)$/insrc/parser.js, with nobreakingHeaderPattern. Afeat!:subject fails the header match entirely, so it scores as no release — exactly the silent skip described.@semantic-release/release-notes-generator@14.1.1is the currentlatestand depends onconventional-changelog-writer@^8.0.0, so there is no newer generator that would accept a v10 preset.^9.3.1cannot resolve past 9.x, so the caret itself enforces the writer-8 constraint; the comment inrelease.config.mjsexplains a genuinely non-obvious why rather than standing in for the guard. The repo has no Renovate/Dependabot config, so the remaining bump path is a deliberate manual major upgrade.feat!:commits out of the rendered sections even once the analyzer scored them correctly.knip.jsonentry is necessary. The preset is resolved dynamically by name, so knip's semantic-release plugin cannot see it;ignoreDependenciesis the standard escape hatch here.Alternatives I considered and rejected as worse:
parserOpts(e.g. adding abreakingHeaderPattern) avoids the new devDependency and the knip entry, but it hand-maintains a regex fork of a preset's parser, and it would fix the major verdict while leaving type/subject unparsed for notes rendering. The official preset is both simpler and more complete.One thing worth confirming at merge, since it is the part I could not exercise without a
node_modulestree: theconventionalcommitspreset's defaultcommitUrlFormat/compareUrlFormat/issueUrlFormatare GitHub-shaped (/commit/,/compare/,/issues/). Forgejo uses those same paths forcode.j4k.dev/j4k-oss/tropkod-client, so the generated links should resolve without apresetConfigoverride — but the v2.0.0 notes are the first real render, so it is cheap to eyeball the links on the resulting release.Approach review by Claude Code Opus (opus)
Approach review: The diagnosis and the fix both hold up when checked against the locked tree.
conventional-changelog-angular@8.3.1'sparserOptscarries only aheaderPatternrequiringtype(scope): subjectand nobreakingHeaderPattern, so afeat!:subject parses to a null type with no notes and scores as no release. The silent skip is real.@semantic-release/commit-analyzer@13.0.1nor@semantic-release/release-notes-generator@14.1.1bundles the conventionalcommits preset — the lockfile shows both pullingconventional-changelog-angular@8.3.1only — so adding the preset as a direct devDependency is required, not incidental.createWriterOptsreturnsmainTemplate(the writer-8 key), while v10.2.1 returnstemplateand depends on@conventional-changelog/template. Under the lockedconventional-changelog-writer@8.4.0the v10 preset's main template is simply never found, which matches the empty-notes symptom described.Switching both plugins to
preset: "conventionalcommits"is the documented configuration for this problem and keeps the repo in step with the align template; the knipignoreDependenciesentry is the standard escape hatch for a preset loaded by name rather than imported. The alternative of keeping the angular preset and overridingparserOpts.breakingHeaderPatternwould avoid the new dependency and the knip entry, but yields worse-grouped notes and diverges from the fleet template, so it is not preferable here.One maintenance observation is left as a comment: the preset/writer major coupling is load-bearing but expressed only as a caret range plus prose.
Approach review by Claude Code Opus (opus)
@ -50,6 +50,7 @@"@total-typescript/ts-reset": "^0.6.1","@types/node": "^25.9.2","@vitest/coverage-v8": "^4.1.8","conventional-changelog-conventionalcommits": "^9.3.1",The
^9range and the comment inrelease.config.mjsare the only things holding this preset on the writer-8-compatible major — and the failure modes on both sides of that boundary are silent. The old default scored no release; a v10 preset against writer 8 renders an empty body. Neither is caught by knip, lint, typecheck, or the test suite, so a routine major-bump PR for this package would go green and the next release would again ship nothing useful, with no error anywhere.Worth considering something that makes a violation loud rather than relying on the range holding: a small test that feeds a synthetic
feat!:commit throughanalyzeCommits/generateNotesand asserts amajorbump and a rendered BREAKING CHANGES section would catch both the original bug and any future preset/writer skew. If the fleet's dependency-bot config is in reach, a rule capping this package below 10 is a cheaper complement. As it stands the constraint is documented but unenforced, which is the same shape of problem this PR is fixing.Summary: No blocking issues; 1 low-severity note.
I reproduced the change against the exact versions in the lockfile (
@semantic-release/commit-analyzer@13.0.1,@semantic-release/release-notes-generator@14.1.1,conventional-changelog-conventionalcommits@9.3.1) on the realfeat!:squash subject, under a pnpm install using this repo'senableGlobalVirtualStore: truelayout:analyzeCommitsreturnsnull(confirms the silent skip described in the PR body)preset: "conventionalcommits"at 9.3.1 ->"major", andgenerateNotesrenders the full body (BREAKING CHANGES + Features sections)"major"but notes collapse to the bare version heading with no error (confirms the reason for the v9 constraint)Preset resolution works in the pnpm layout: commit-analyzer's
import-from-esmfalls back to the cwd-rootednode_modules, where the new top-level devDependency lives. The release workflow installs devDependencies (pnpm install --frozen-lockfile --ignore-scripts), so the preset is present at release time.Other checks: the lockfile entry matches the published 9.3.1 metadata (integrity,
compare-func@^2.0.0,engines.node >=18) and introduces no duplicate versions, sopnpm dedupe --checkand--frozen-lockfilestay satisfied;oxfmt --checkpasses on all three text files; the knipignoreDependenciesentry is required because the preset is referenced only as a config string, andpnpm knipruns in CI.Code review by Claude Code Opus (opus)
@ -50,6 +50,7 @@"@total-typescript/ts-reset": "^0.6.1","@types/node": "^25.9.2","@vitest/coverage-v8": "^4.1.8","conventional-changelog-conventionalcommits": "^9.3.1",🟢 Low: The regression this guards against is silent, and the range wording overstates the guard.
^9.3.1is a range, not a pin (therelease.config.mjscomment calls it "pinned"), and it is the only thing keeping the preset off v10. I confirmed the v10 failure mode against the installed generator: 10.2.1 still returns"major", butgenerateNotesrenders only the version heading — no sections, no error, semantic-release exits 0 and the workflow stays green, so a future major bump reproduces exactly the kind of invisible breakage this PR is fixing.Consider making the constraint explicit and machine-enforced rather than prose-only, e.g.
"conventional-changelog-conventionalcommits": ">=9.3.1 <10"(or an exact9.3.1), so an automated dependency update has to widen the range deliberately instead of a^-to-^10bump sliding through.Summary: No actionable defects found. 1 info note and 1 low convention note.
I reproduced the PR's claims against the exact versions the lockfile pins (
@semantic-release/commit-analyzer@13.0.1,@semantic-release/release-notes-generator@14.1.1,conventional-changelog-writer@8.4.0) using the realfeat!:squash message of66c8896(which has noBREAKING CHANGE:footer, so the!marker is the only breaking signal):analyzeCommitsreturnsnull(reproduces the silent skip)preset: "conventionalcommits"@ 9.3.1 →"major", andgenerateNotesrenders the full body (BREAKING CHANGES + Features sections, correct Forgejocompare/commit/issueslinks)preset: "conventionalcommits"@ 10.2.1 →"major", but notes render as the bare## [2.0.0](…compare…)heading only — confirms the^9.3.1pin rationaleOther checks that passed:
whatBump, so a range of onlyci:/docs:commits still returnsnullunder the new preset (the preset's ownwhatBumpwould have said patch).ignoreDependencies, knip reportsconventional-changelog-conventionalcommitsas an unused devDependency; with it, the dependency is clean and the tuple plugin form is still parsed (other plugin names resolve normally).package.jsonexactly for both dependency sections, the new package/snapshot entries carry the registry's real integrity hash and dep set (compare-func@2.0.0, already present — no duplicate forpnpm dedupe --check), sopnpm install --frozen-lockfilewill pass. The release workflow installs devDependencies, so the preset is present at release time.oxfmt --checkpasses on all three changed text files, includingpackage.jsonkey sorting.Code review by Claude Code Opus (opus)
@ -2,3 +2,2 @@plugins: ["@semantic-release/commit-analyzer","@semantic-release/release-notes-generator",// The default angular preset cannot parse the Conventional Commits "!" marker,🟢 Low:
AGENTS.md("Comments Explain Why, Not What") mandates one short line and explicitly forbids multi-line comment blocks; this is five lines. The content is genuinely load-bearing why, so don't drop it — the fitting split is a one-liner here plus the full rationale where it already lives (the commit message / PR body), e.g.// The angular preset cannot parse the "!" breaking marker; the preset is held at v9 for writer-8 notes compat.@ -5,0 +6,4 @@// loads conventional-changelog-writer 8, and a v10 preset renders empty// release notes against it without erroring.["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],ℹ️ Info: Verified end to end at the locked versions — with this preset the real
feat!:squash resolves tomajorand the notes render in full, while preset 10.2.1 againstconventional-changelog-writer@8.4.0renders only the version heading. Worth noting the residual failure mode this pin creates: the coupling is a silent one in both directions. If a futuresemantic-releasebump movesrelease-notes-generatoronto writer 9, the pinned preset 9 will start emitting empty notes with exit 0 and a green workflow — exactly the class of failure this PR is fixing.--frozen-lockfilemeans that can only happen through a deliberate lockfile change, so the practical guard is to eyeball the rendered notes on the first release after any semantic-release upgrade.Round 4 (head
cb06bf8) — disposition for the four round-3 findings:cb06bf8— the rationale is now one line, per AGENTS.md.^9.3.1and>=9.3.1 <10denote the same version set, so the suggested rewrite changes nothing an updater sees; keeping^9.3.1matches the fleet-wide guidance. The misleading "pinned" wording is gone with the condensed comment.>=9.3.1 <10,--fixrewrites it) and exempts the preset from the outdated-bump advice, so every aligned repo gets the guard this comment asks for.Approach review: The approach looks good.
The fix correctly identifies the root cause — semantic-release's default angular preset can't parse the Conventional Commits
!breaking marker, sofeat!:scored as "no release" and v2.0.0 was silently never cut. Switching both@semantic-release/commit-analyzerand@semantic-release/release-notes-generatortopreset: "conventionalcommits"is the standard, idiomatic facility for this syntax — not a custom or hacky workaround.The preset pin to
^9.3.1(which the caret keeps within v9) is well-reasoned and verified against the exact installed versions: release-notes-generator v14 loadsconventional-changelog-writer8, and preset v10 emits the writer-9 API against it, rendering only the bare version heading with no sections and no error. The knipignoreDependenciesentry is a necessary, correct workaround — knip's semantic-release plugin extracts only plugin names, never thepresetoption, so the dynamically-loaded preset package is invisible to it.I considered the obvious alternative (upgrade
@semantic-release/release-notes-generatorto v15+ to useconventional-changelog-writer9 and the latest preset v10) and it is not materially better: it crosses a major version of a release-time dependency for no real benefit, where the chosen minimal fix is verified, lower-risk, and the pin is documented in-code. An override of the angular preset'sheaderPatternwould be fragile by comparison.Approach review by Approach Review 3 (OpenCode Wafer) (GLM-5.2)
Approach review: The approach is sound and minimal; no material alternative to suggest.
I verified the two load-bearing claims against the published packages rather than taking the description at face value:
conventional-changelog-angular@8(8.3.1 per the lockfile) ships onlyheaderPattern: /^(\w*)(?:\((.*)\))?: (.*)$/and nobreakingHeaderPattern, so afeat!:subject fails header parsing outright — type is never extracted, which is exactly the no release scoring described.conventional-changelog-conventionalcommits@9.3.1addsbreakingHeaderPatternand depends only oncompare-func@2(matching the lockfile snapshot), while10.2.1swaps that for@conventional-changelog/template@^1, i.e. the writer-9 templating API. Holding at v9 against release-notes-generator 14's bundledconventional-changelog-writer@8is the right constraint, and^9.3.1bounds it correctly.On design choices:
conventionalcommitspreset is preferable to the smaller-footprint alternative of hand-writingparserOpts.breakingHeaderPatternon commit-analyzer: that would copy a regex the upstream preset already maintains and would leave the writer on a different preset than the parser.conventional-changelog-<preset>from the project root, which pnpm's strict layout will not satisfy transitively.knip.ignoreDependenciesis the standard escape hatch for a dependency loaded by name from config, and the entry is scoped to the one package.One residual, non-blocking observation: both failure modes in this story are silent-green (no release cut; empty notes rendered), and nothing in the workflow would catch a recurrence.
^9.3.1already confines that exposure to a deliberate major-bump PR, so it does not warrant a change here, but a reviewer of any future preset-10 bump should check rendered notes rather than trust a green run.Approach review by Claude Code Opus (opus)
Approach review: The approach is sound and I found no materially better alternative.
I independently verified both premises against the versions the lockfile resolves:
conventional-changelog-angular@8.3.1(what commit-analyzer 13.0.1 and release-notes-generator 14.1.1 both pull in) exportsheaderPattern: /^(\w*)(?:\((.*)\))?: (.*)$/and, notably, nobreakingHeaderPattern. Afeat!:header therefore parses totype: nullwith zero notes, andwhatBumpreturns no release. The silent-skip diagnosis holds.createWriterOptsreturnsmainTemplate(theconventional-changelog-writer8 API), while 10.2.1 returnstemplate/preamblePartialbound functions from@conventional-changelog/template(writer 9). Neither package declares a peer range that would catch the mismatch, which is exactly why v10 renders a bare version heading instead of erroring.^9.3.1is the right shape of constraint here — it stays inside the compatible major while still taking patches.Switching the preset is the ecosystem-standard fix, and it is strictly better than the narrower alternative of hand-overriding
parserOpts.breakingHeaderPatternon commit-analyzer:conventional-commits-parser@6.4.0does support that option, but it would patch only the release decision and leave the notes generator unable to group the commit, i.e. a correct version bump with unusable release notes. Adding the preset fixes both sides with one dependency. The knipignoreDependenciesentry is the correct escape for a package resolved dynamically from apresetstring that knip's plugin cannot see, and devDependencies is the right place given the release workflow does a fullpnpm install --frozen-lockfile.One residual risk worth being aware of, not a blocker and not something this PR introduces: the preset-major↔writer-major coupling is enforced by nothing but the caret range and the inline comment, and its failure mode is the same silent-green class this PR is fixing (empty notes, exit 0). If a future
semantic-releasebump moves release-notes-generator onto writer 9, the range has to move with it. The comment inrelease.config.mjsrecords this, which is the pragmatic minimum; a renderer smoke check would be the belt-and-braces version if this bites again.Approach review by Claude Code Opus (opus)
Summary: No actionable issues found. The fix is correct and I reproduced every claim in the PR body against the exact locked versions.
What I verified empirically (installed
@semantic-release/commit-analyzer@13.0.1,@semantic-release/release-notes-generator@14.1.1, and the preset, then invokedanalyzeCommits/generateNoteson the realfeat!: …(#3)squash subject):analyzeCommitsreturnsnull, notes render as the bare compare heading — reproduces the silent no-release.preset: "conventionalcommits"@ 9.3.1 →"major", with a full⚠ BREAKING CHANGES+Featuresbody.conventional-changelog-writer@8.4.0→"major"but empty notes (93 chars, heading only), confirming the^9.3.1pin is load-bearing and not incidental.Other checks:
importFrom.silent(__dirname, pkg) || importFrom(cwd, pkg), andpnpm exec semantic-releaseruns with cwd at the repo root where the direct devDependency is linked — so the cwd fallback resolves it even withenableGlobalVirtualStore: true.pnpm install --frozen-lockfile --ignore-scripts), so the preset is present at release time; the preset package has no lifecycle scripts.knip.jsonignore is genuinely required: knip 6.27's semantic-release plugin (resolveConfig) maps onlyplugin[0]names and never readspreset.pnpm-lock.yaml's importer block matchespackage.jsonfor all 18 dependencies, and the new snapshot carries its only dependency (compare-func).Code review by Claude Code Opus (opus)
@ -3,2 +3,2 @@"@semantic-release/commit-analyzer","@semantic-release/release-notes-generator",// The angular preset can't parse the "!" breaking marker; held to preset v9 — v10 renders empty notes against writer 8.["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],ℹ️ Info: Confirmed this resolves correctly under this repo's pnpm setup — a non-obvious risk given
enableGlobalVirtualStore: trueinpnpm-workspace.yaml. Both plugins load presets viaimportFrom.silent(__dirname, "conventional-changelog-conventionalcommits") || importFrom(cwd, ...); the first lookup fails from inside the virtual store, and thecwdfallback succeeds becausepnpm exec semantic-releaseruns at the repo root where the direct devDependency is linked. No change needed.Summary: No actionable issues found. The fix is correct and I reproduced both claims independently.
Verification performed against the exact locked versions (
@semantic-release/commit-analyzer@13.0.1,@semantic-release/release-notes-generator@14.1.1), callinganalyzeCommits/generateNoteson the realfeat!: ... (#3)squash subject:nulland a bare version heading — reproduces the silent skip.preset: "conventionalcommits"@ 10.2.1:major, but notes render as the compare heading only (v10 dropped to@conventional-changelog/template, which the writer-8 the notes generator resolves cannot render) — justifies the^9pin.preset: "conventionalcommits"@ 9.3.1:major, with BREAKING CHANGES and Features sections and correct Forgejo commit/issue URLs.Other checks:
importFrom(cwd, "conventional-changelog-" + preset), so the direct devDependency symlink at the repo root resolves it. Re-ran the same script under a pnpm install withenableGlobalVirtualStore: true(the layout this repo uses) — resolves and renders identically.pnpm install --frozen-lockfile --ignore-scripts), so the preset is present whenpnpm exec semantic-releaseruns.packagesandsnapshotsentries match byte-for-byte what pnpm 11 generates for this package, the integrity hash matches the registry, and its only dependency (compare-func@2.0.0) is already in the tree, so--frozen-lockfileandpnpm dedupe --checkstay satisfied.package.jsondevDependencies remain sorted (oxfmtsortPackageJson), andrelease.config.mjspassesoxfmt --check.!marker) changes.Code review by Claude Code Opus (opus)
@ -50,6 +50,7 @@"@total-typescript/ts-reset": "^0.6.1","@types/node": "^25.9.2","@vitest/coverage-v8": "^4.1.8","conventional-changelog-conventionalcommits": "^9.3.1",ℹ️ Info:
^9.3.1correctly excludes v10, and the reason is recorded inrelease.config.mjs. Worth noting the constraint is two-sided and lives in a different file than the range: v9 works only because@semantic-release/release-notes-generator@14.1.1resolvesconventional-changelog-writer@8. If that plugin ever moves to writer 9, this pin becomes the wrong side of the same incompatibility — and the failure mode is silent (major bump, empty notes, exit 0, green run), exactly what this PR fixes. No change needed here; just re-render notes when either side is bumped.Round 4 (head
cb06bf8) came back clean: both code reviewers report no actionable issues; the two new inline notes (32676, 32696) are explicitly informational — acknowledged (the writer-major coupling note is also mirrored in j4k/align's range policy). Resolving them; PR is ready to merge.