Skip to content

ReleaseNotes instance state leaks between semantic-release passes when adding a channel to an existing tag (maintenance branch creation) #111

@glandais-nickel

Description

@glandais-nickel

Summary

When semantic-release creates a maintenance branch from an existing release, it runs analyzeCommits / generateNotes twice within a single CI run:

  1. Pass 1: rattach the existing tag to the new branch's channel (Add channel X.x to tag Y.Y.Y)
  2. 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 releaseRulesno 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:

  1. _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.

  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions