Summary
When semantic-release creates a maintenance branch from an existing release, it runs analyzeCommits / generateNotes twice within a single CI run:
- Pass 1: rattach the existing tag to the new branch's channel (
Add channel X.x to tag Y.Y.Y)
- Pass 2: evaluate whether a new release is needed on top of that tag
semantic-release-gitmoji builds a single ReleaseNotes instance whose internal state (_rtype cache and _context.commits) is not reset between passes. As a result:
- The
_rtype returned by getReleaseType in pass 2 is the cached value from pass 1, even when the new commit alone would not trigger any release.
generateNotes in pass 2 lists commits that were only included in pass 1's broader range (i.e. commits already shipped in previous releases).
Net effect: a release is created that shouldn't exist, and its release notes contain commits from prior releases.
Environment
semantic-release-gitmoji@1.6.9
semantic-release@25.0.3
@semantic-release/git@10.0.1, @semantic-release/gitlab@13.3.2, @semantic-release/changelog@6.0.3
- Node 22
- GitLab self-managed
Reproduction
Create a maintenance branch from an existing release tag, then push a commit whose gitmoji is not in releaseRules (e.g. :construction:). semantic-release runs the channel-add pass followed by the release-evaluation pass on the same ReleaseNotes singleton.
Expected
semantic-release detects that:
- Tag
6.10.0 exists on the branch ancestry but is not on channel 6.x → adds the channel to the tag (pass 1, no new version).
- Only one commit since
6.10.0, and its gitmoji (:construction:) is not in releaseRules → no new release.
Log should end with There will be no new version. for pass 2.
Actual
[semantic-release] › ℹ Found 2 commits since last release
[semantic-release] › ℹ Start step "generateNotes" of plugin "semantic-release-gitmoji"
[semantic-release] › ✔ Add channel 6.x to tag 6.10.0
[semantic-release] › ℹ Found git tag 6.10.0 associated with version 6.10.0 on branch 6.x
[semantic-release] › ℹ Found 1 commits since last release
[semantic-release] › ℹ Start step "analyzeCommits" of plugin "semantic-release-gitmoji"
[semantic-release-gitmoji] › ℹ The next release will be a "minor" release.
[semantic-release] › ℹ The next release version is 6.11.0
- A
6.11.0 minor release is created from a single :construction: commit that matches no rule.
- The generated release notes for
6.11.0 include commits that belong to prior releases — specifically the :sparkles: commit shipped in 6.10.0, and even the :bookmark: 6.10.0 commit itself (rendered under "🔖 Release / Version tags").
Root cause
lib/release-notes.js exposes a process-wide singleton:
static get (context, releaseNotesOptions) {
ReleaseNotes._instance = ReleaseNotes._instance || new ReleaseNotes(context, releaseNotesOptions)
return ReleaseNotes._instance
}
Both analyze-commits.js and generate-notes.js call ReleaseNotes.get(context, ...), so pass 2 reuses pass 1's instance. Two pieces of state leak:
-
_rtype is memoized on the instance:
getReleaseType (releaseSchema) {
// cache hits
if (this._rtype) return this._rtype
...
}
Pass 1's release type (computed on the broader range used to add the channel) short-circuits pass 2.
-
_context.commits is parsed once in the constructor from context.commits and never refreshed. updateContext() only updates lastRelease, nextRelease, compareUrl — not the commits. So generateNotes in pass 2 renders the commits captured in pass 1.
Suggested fix
Create a fresh ReleaseNotes instance per pass in the plugin entry points (analyze-commits.js, generate-notes.js), so semantic-release's internally updated context.commits is reflected. It aligns with semantic-release's per-call context model: replace ReleaseNotes.get(...) with new ReleaseNotes(...) in both entry points and drop the static get. updateContext() is still invoked once on the per-pass instance (from generate-notes.js), so there's no regression on note rendering.
Summary
When semantic-release creates a maintenance branch from an existing release, it runs
analyzeCommits/generateNotestwice within a single CI run:Add channel X.x to tag Y.Y.Y)semantic-release-gitmojibuilds a singleReleaseNotesinstance whose internal state (_rtypecache and_context.commits) is not reset between passes. As a result:_rtypereturned bygetReleaseTypein pass 2 is the cached value from pass 1, even when the new commit alone would not trigger any release.generateNotesin pass 2 lists commits that were only included in pass 1's broader range (i.e. commits already shipped in previous releases).Net effect: a release is created that shouldn't exist, and its release notes contain commits from prior releases.
Environment
semantic-release-gitmoji@1.6.9semantic-release@25.0.3@semantic-release/git@10.0.1,@semantic-release/gitlab@13.3.2,@semantic-release/changelog@6.0.3Reproduction
Create a maintenance branch from an existing release tag, then push a commit whose gitmoji is not in
releaseRules(e.g.:construction:). semantic-release runs the channel-add pass followed by the release-evaluation pass on the sameReleaseNotessingleton.Expected
semantic-release detects that:
6.10.0exists on the branch ancestry but is not on channel6.x→ adds the channel to the tag (pass 1, no new version).6.10.0, and its gitmoji (:construction:) is not inreleaseRules→ no new release.Log should end with
There will be no new version.for pass 2.Actual
6.11.0minor release is created from a single:construction:commit that matches no rule.6.11.0include commits that belong to prior releases — specifically the:sparkles:commit shipped in6.10.0, and even the:bookmark: 6.10.0commit itself (rendered under "🔖 Release / Version tags").Root cause
lib/release-notes.jsexposes a process-wide singleton:Both
analyze-commits.jsandgenerate-notes.jscallReleaseNotes.get(context, ...), so pass 2 reuses pass 1's instance. Two pieces of state leak:_rtypeis memoized on the instance:Pass 1's release type (computed on the broader range used to add the channel) short-circuits pass 2.
_context.commitsis parsed once in the constructor fromcontext.commitsand never refreshed.updateContext()only updateslastRelease,nextRelease,compareUrl— not the commits. SogenerateNotesin pass 2 renders the commits captured in pass 1.Suggested fix
Create a fresh
ReleaseNotesinstance per pass in the plugin entry points (analyze-commits.js,generate-notes.js), so semantic-release's internally updatedcontext.commitsis reflected. It aligns with semantic-release's per-call context model: replaceReleaseNotes.get(...)withnew ReleaseNotes(...)in both entry points and drop the staticget.updateContext()is still invoked once on the per-pass instance (fromgenerate-notes.js), so there's no regression on note rendering.