From 5c5e5e1b542e604102189db3ad1e40dbbafcbe3e Mon Sep 17 00:00:00 2001 From: Timothy Jones Date: Fri, 2 Oct 2026 14:38:38 +1000 Subject: [PATCH] fix: list only the changes since the previous prerelease with the same name in each prerelease changelog section, instead of repeating everything since the last stable release. Stable releases, and the first prerelease with a new name, still list everything since the last stable release. (fixes #349) Since 13.1.1 (#310) the changelog window always started at the last stable tag, so a continued prerelease repeated every earlier prerelease's entries and its compare link pointed at the stable tag. The window now starts at the most recent tag that is stable or a prerelease with the same identifier as the version being released. The tag list is filtered in a ConventionalGitClient subclass passed to ConventionalChangelog, so the commit range, section splits and compare links all use the same boundaries. The bump calculation is unchanged. A switched identifier (dev -> rc) deliberately stays cumulative, as the #310 tests assert, rather than reverting to "last tag of any kind". Co-Authored-By: Claude Fable 5.1 --- README.md | 27 ++++++++- lib/lifecycles/changelog.js | 67 ++++++++++++++++++---- test/prerelease-window.integration-test.js | 45 ++++++++++++++- 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d913c7c..5589a1f 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ commit-and-tag-version --first-release npx commit-and-tag-version --first-release ``` -This will tag a release **without bumping the version `bumpFiles`[1]()**. +This will tag a release **without bumping the version `bumpFiles`[1](<>)**. When you are ready, push the git tag and `npm publish` your first release. \o/ @@ -299,6 +299,29 @@ commit-and-tag-version --prerelease xyz # Creates v1.4.3-xyz.1 (auto-incremente This behavior applies to both named prereleases (e.g., `-alpha.0`, `-beta.1`) and unnamed prereleases (e.g., `-0`, `-1`), ensuring that you can safely cut multiple prerelease versions without encountering git tag conflicts. +#### What Each Changelog Section Covers + +Each release's changelog section starts where the previous release ended, and which earlier release counts as "previous" depends on what you are releasing: + +- A stable release covers everything since the last stable release, including changes that already appeared in prereleases. +- A prerelease covers everything since the most recent tag that is either a stable release or a prerelease with the same name. Prereleases with other names are ignored. + +For example, starting from `v1.0.0`: + +```bash +# feat: add a +commit-and-tag-version --prerelease alpha # v1.1.0-alpha.0 lists "add a" +# feat: add b +commit-and-tag-version --prerelease alpha # v1.1.0-alpha.1 lists "add b" only +# fix: correct c +commit-and-tag-version --prerelease beta # v1.1.0-beta.0 lists a, b and c: it is the first beta +commit-and-tag-version # v1.1.0 lists a, b and c +``` + +The compare link in each heading starts from the same tag, so `v1.1.0-alpha.1` links to `v1.1.0-alpha.0...v1.1.0-alpha.1` and `v1.1.0` links to `v1.0.0...v1.1.0`. + +Unnamed prereleases (e.g., `-0`, `-1`) are treated as sharing one name. + #### Keeping Prereleases out of a Final Changelog `commit-and-tag-version` does not currently provide a general-purpose option to @@ -663,7 +686,7 @@ module.exports.writeVersion = function (contents, version) { ### Why do breaking changes before 1.0.0 not trigger a 1.0.0 release? Below 1.0.0, the semver specification doesn't give any guarantees about the -meaning of version numbers. +meaning of version numbers. However, with npm there is a community convention, and implementation-defined behaviour: If your version is between 0.1.0 and diff --git a/lib/lifecycles/changelog.js b/lib/lifecycles/changelog.js index 1722082..37e286f 100644 --- a/lib/lifecycles/changelog.js +++ b/lib/lifecycles/changelog.js @@ -1,3 +1,4 @@ +import { ConventionalGitClient } from '@conventional-changelog/git-client'; import chalk from 'chalk'; import { ConventionalChangelog } from 'conventional-changelog'; import fs from 'fs'; @@ -42,6 +43,52 @@ function extractChangelogBody(oldContent) { : oldContent; } +/** + * The prerelease identifier of a version: 'alpha' for 1.0.0-alpha.1, '' for an + * unnamed prerelease such as 1.0.0-0, and null for a stable version. + */ +function prereleaseChannel(version) { + const prerelease = semver.prerelease(version); + if (!prerelease) return null; + return typeof prerelease[0] === 'string' ? prerelease[0] : ''; +} + +/** + * Whether an earlier release ends the changelog section of the version being + * released. Stable releases always do. Prereleases only do for a later + * prerelease with the same identifier, so that: + * - a stable release, or the first prerelease with a new identifier, covers + * everything since the last stable release, including changes that only + * appeared in other prereleases (#310) + * - a continued prerelease only covers changes since the previous prerelease + * with that identifier, rather than repeating them every time (#349) + */ +function isReleaseBoundary(version, newVersion) { + if (!semver.valid(version)) return false; + const channel = prereleaseChannel(version); + return channel === null || channel === prereleaseChannel(newVersion); +} + +/** + * Git client that only reports the tags which are release boundaries for the + * version being released, so the changelog's commit range, section splits + * and compare links all agree on where the previous release was. + */ +class ReleaseBoundaryGitClient extends ConventionalGitClient { + constructor(cwd, newVersion) { + super(cwd); + this.newVersion = newVersion; + } + + async *getSemverTags(params = {}) { + for await (const tag of super.getSemverTags(params)) { + if (isReleaseBoundary(tag.replace(params.prefix, ''), this.newVersion)) { + yield tag; + } + } + } +} + async function outputChangelog(args, newVersion) { createIfMissing(args); const header = args.header; @@ -55,13 +102,12 @@ async function outputChangelog(args, newVersion) { const changelogFrontMatter = extractFrontMatter(oldContent); - const generator = new ConventionalChangelog() + const generator = new ConventionalChangelog( + new ReleaseBoundaryGitClient(process.cwd(), newVersion), + ) .readPackage() .loadPreset(presetLoader(args)) - // skipUnstable keeps the changelog window aligned with the bump logic: - // both cover all commits since the last stable release, so changes that - // only appeared in prereleases are not dropped from the release (#310). - .tags({ prefix: args.tagPrefix, skipUnstable: true }) + .tags({ prefix: args.tagPrefix }) .options({ releaseCount: args.releaseCount, ...(args.verbose @@ -73,13 +119,10 @@ async function outputChangelog(args, newVersion) { .context({ version: newVersion }) .commits({ merges: null, path: args.path }, args.parserOpts) .writer({ - // Split sections at stable release tags only, so commits already - // released in intermediate prereleases stay in this release's section - // rather than being regenerated under the prerelease's heading (#310). - generateOn: (commit) => - Boolean( - semver.valid(commit.version) && !semver.prerelease(commit.version), - ), + // Split sections at the same tags the commit range is built from, so + // commits released in other prereleases stay in this release's section + // rather than being regenerated under that prerelease's heading. + generateOn: (commit) => isReleaseBoundary(commit.version, newVersion), ...(args.writerOpts || {}), }); diff --git a/test/prerelease-window.integration-test.js b/test/prerelease-window.integration-test.js index aea79d0..9667da9 100644 --- a/test/prerelease-window.integration-test.js +++ b/test/prerelease-window.integration-test.js @@ -118,11 +118,50 @@ describe('prerelease bump and changelog windows (#310)', function () { runCli('--prerelease', 'dev'); expect(tags()).toContain('v1.1.0-dev.1'); - // Prerelease sections are cumulative: each one covers everything since - // the last stable release. + // Only the changes since the previous prerelease with this identifier + // are listed; the feat is already in the dev.0 section (#349). const devSection = changelogSection('1.1.0-dev.1'); - expect(devSection).toContain('add feat'); + expect(devSection).not.toContain('add feat'); expect(devSection).toContain('add fix'); + expect(devSection).toContain('v1.1.0-dev.0...v1.1.0-dev.1'); + expect(changelogSection('1.1.0-dev.0')).toContain('add feat'); + }); + + it('lists each change once across continued prereleases and again in the stable release', function () { + commit('feat: add a'); + runCli('--prerelease', 'alpha'); + commit('feat: add b'); + runCli('--prerelease', 'alpha'); + commit('fix: correct c'); + runCli('--prerelease', 'alpha'); + + const prereleases = fs + .readFileSync('CHANGELOG.md', 'utf-8') + .split(/^#+ \[?1\.0\.0[\] ]/m)[0]; + expect(prereleases.match(/add a/g)).toHaveLength(1); + expect(prereleases.match(/add b/g)).toHaveLength(1); + expect(prereleases.match(/correct c/g)).toHaveLength(1); + + runCli(); + const stableSection = changelogSection('1.1.0'); + expect(stableSection).toContain('add a'); + expect(stableSection).toContain('add b'); + expect(stableSection).toContain('correct c'); + expect(stableSection).toContain('v1.0.0...v1.1.0'); + }); + + it('covers other prereleases when returning to an earlier identifier', function () { + commit('feat: add feat'); + runCli('--prerelease', 'dev'); + commit('fix: add fix'); + runCli('--prerelease', 'rc'); + commit('fix: second fix'); + runCli('--prerelease', 'dev'); + + const devSection = changelogSection('1.1.0-dev.1'); + expect(devSection).not.toContain('add feat'); + expect(devSection).toContain('add fix'); + expect(devSection).toContain('second fix'); }); it('escalates a continued prerelease when a bigger change lands', function () {