From 7ab8c75ea03d99c8ffec4660ba25c560e1c6dda7 Mon Sep 17 00:00:00 2001 From: SLASHLogin Date: Wed, 24 Sep 2025 22:03:02 +0200 Subject: [PATCH 1/3] fix(bump): auto-increment prerelease when tag already exists --- lib/lifecycles/bump.js | 85 ++++++++++++++++++++++++++++++++++++ test/git.integration-test.js | 18 ++++++++ 2 files changed, 103 insertions(+) diff --git a/lib/lifecycles/bump.js b/lib/lifecycles/bump.js index 7db4524e3..6b3ed86da 100644 --- a/lib/lifecycles/bump.js +++ b/lib/lifecycles/bump.js @@ -12,6 +12,7 @@ const runLifecycleScript = require('../run-lifecycle-script'); const semver = require('semver'); const writeFile = require('../write-file'); const { resolveUpdaterObjectFromArgument } = require('../updaters'); +const gitSemverTags = require('git-semver-tags'); let configsToUpdate = {}; const sanitizeQuotesRegex = /['"]+/g; @@ -83,6 +84,15 @@ async function Bump(args, version) { newVersion = semver.inc(version, releaseType, args.prerelease); } + + // If creating a prerelease, ensure the computed version is unique among existing git tags + if (isString(args.prerelease) && newVersion) { + newVersion = await resolveUniquePrereleaseVersion( + newVersion, + args.tagPrefix, + args.prerelease, + ); + } updateConfigs(args, newVersion); } else { checkpoint( @@ -261,3 +271,78 @@ function updateConfigs(args, newVersion) { } module.exports = Bump; + +/** + * Ensure prerelease version uniqueness by checking existing git tags. + * If a tag for the same base version and prerelease identifier exists, bump the numeric suffix. + * @param {string} proposedVersion The version computed by bump logic, may include build metadata. + * @param {string} tagPrefix The tag prefix to respect when reading tags (e.g., 'v'). + * @param {string} prereleaseId The prerelease identifier (e.g., 'alpha', 'beta', 'rc'). + * @returns {Promise} The adjusted version that does not collide with existing tags. + */ +async function resolveUniquePrereleaseVersion( + proposedVersion, + tagPrefix, + prereleaseId, +) { + try { + const parsed = new semver.SemVer(proposedVersion); + const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`; + const build = parsed.build; // preserve build metadata if present + + // Current numeric index if present, otherwise default to 0 + const currentNum = + typeof parsed.prerelease[1] === 'number' ? parsed.prerelease[1] : 0; + + const tags = await new Promise((resolve, reject) => { + gitSemverTags({ tagPrefix }, (err, t) => + err ? reject(err) : resolve(t || []), + ); + }); + + // strip prefix and clean + const cleaned = tags + .map((t) => t.replace(new RegExp('^' + tagPrefix), '')) + .map((t) => (semver.valid(t) ? semver.clean(t) : null)) + .filter(Boolean); + + // collect numeric suffix for same base and prerelease id + const nums = cleaned + .filter((t) => { + const v = new semver.SemVer(t); + if (!Array.isArray(v.prerelease) || v.prerelease.length === 0) + return false; + // same base version and same prerelease id + return ( + v.major === parsed.major && + v.minor === parsed.minor && + v.patch === parsed.patch && + String(v.prerelease[0]) === String(prereleaseId) + ); + }) + .map((t) => { + const v = new semver.SemVer(t); + return typeof v.prerelease[1] === 'number' ? v.prerelease[1] : 0; + }); + + if (nums.length === 0) { + // no collisions possible + return proposedVersion; + } + + const maxExisting = Math.max(...nums); + // If our proposed numeric index is already used or below max, bump to max + 1 + if (currentNum <= maxExisting) { + let candidate = `${base}-${prereleaseId}.${maxExisting + 1}`; + // re-append build metadata if any + if (build && build.length) { + candidate = semvarToVersionStr(candidate, build); + } + return candidate; + } + return proposedVersion; + } catch { + // If anything goes wrong, fall back to proposedVersion + return proposedVersion; + } +} diff --git a/test/git.integration-test.js b/test/git.integration-test.js index 33945868f..7f8713881 100644 --- a/test/git.integration-test.js +++ b/test/git.integration-test.js @@ -248,6 +248,24 @@ describe('git', function () { expect(getPackageVersion()).toEqual('1.1.0-0'); }); + it('increments prerelease number when same prerelease tag already exists', async function () { + writePackageJson('1.4.3-abc.0'); + // Simulate existing tags where v1.4.3-xyz.0 already exists from git history + mock({ bump: 'patch', tags: ['v1.4.3-xyz.0'] }); + await exec('--prerelease xyz'); + // Base remains 1.4.3 when switching prerelease channel mid-cycle; must bump numeric suffix to avoid tag collision + expect(getPackageVersion()).toEqual('1.4.3-xyz.1'); + }); + + it('increments prerelease number with gitTagFallback when same prerelease tag already exists', async function () { + // Setup without package.json and with existing tags only + shell.rm('package.json'); + mock({ bump: 'patch', tags: ['v1.4.3-xyz.0'] }); + await exec({ packageFiles: [], gitTagFallback: true, prerelease: 'xyz' }); + const output = shell.exec('git tag').stdout; + expect(output).toMatch(/v1\.4\.3-xyz\.1/); + }); + describe('gitTagFallback', function () { beforeEach(function () { setup(); From 30ff7bd057aab9bfa1a906e977573c4b13088bb2 Mon Sep 17 00:00:00 2001 From: SLASHLogin Date: Wed, 24 Sep 2025 22:13:48 +0200 Subject: [PATCH 2/3] fix(bump): auto-increment unnamed prerelease when tag already exists --- lib/lifecycles/bump.js | 38 ++++++++++++++++++++++++++---------- test/git.integration-test.js | 21 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/lib/lifecycles/bump.js b/lib/lifecycles/bump.js index 6b3ed86da..eef9a17c0 100644 --- a/lib/lifecycles/bump.js +++ b/lib/lifecycles/bump.js @@ -290,9 +290,17 @@ async function resolveUniquePrereleaseVersion( const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`; const build = parsed.build; // preserve build metadata if present - // Current numeric index if present, otherwise default to 0 - const currentNum = - typeof parsed.prerelease[1] === 'number' ? parsed.prerelease[1] : 0; + // Determine current numeric index depending on named vs unnamed prerelease + let currentNum = 0; + if (Array.isArray(parsed.prerelease) && parsed.prerelease.length) { + if (prereleaseId === '' && typeof parsed.prerelease[0] === 'number') { + // unnamed prerelease like 1.2.3-0 + currentNum = parsed.prerelease[0]; + } else if (typeof parsed.prerelease[1] === 'number') { + // named prerelease like 1.2.3-alpha.0 + currentNum = parsed.prerelease[1]; + } + } const tags = await new Promise((resolve, reject) => { gitSemverTags({ tagPrefix }, (err, t) => @@ -306,22 +314,29 @@ async function resolveUniquePrereleaseVersion( .map((t) => (semver.valid(t) ? semver.clean(t) : null)) .filter(Boolean); - // collect numeric suffix for same base and prerelease id + // collect numeric suffix for same base and prerelease id (or unnamed prerelease) const nums = cleaned .filter((t) => { const v = new semver.SemVer(t); if (!Array.isArray(v.prerelease) || v.prerelease.length === 0) return false; - // same base version and same prerelease id - return ( + const sameBase = v.major === parsed.major && v.minor === parsed.minor && - v.patch === parsed.patch && - String(v.prerelease[0]) === String(prereleaseId) - ); + v.patch === parsed.patch; + if (!sameBase) return false; + if (prereleaseId === '') { + // unnamed prerelease: include tags where first prerelease token is numeric + return typeof v.prerelease[0] === 'number'; + } + // named prerelease: match by identifier + return String(v.prerelease[0]) === String(prereleaseId); }) .map((t) => { const v = new semver.SemVer(t); + if (prereleaseId === '') { + return typeof v.prerelease[0] === 'number' ? v.prerelease[0] : 0; + } return typeof v.prerelease[1] === 'number' ? v.prerelease[1] : 0; }); @@ -333,7 +348,10 @@ async function resolveUniquePrereleaseVersion( const maxExisting = Math.max(...nums); // If our proposed numeric index is already used or below max, bump to max + 1 if (currentNum <= maxExisting) { - let candidate = `${base}-${prereleaseId}.${maxExisting + 1}`; + let candidate = + prereleaseId === '' + ? `${base}-${maxExisting + 1}` + : `${base}-${prereleaseId}.${maxExisting + 1}`; // re-append build metadata if any if (build && build.length) { candidate = semvarToVersionStr(candidate, build); diff --git a/test/git.integration-test.js b/test/git.integration-test.js index 7f8713881..d2219d85b 100644 --- a/test/git.integration-test.js +++ b/test/git.integration-test.js @@ -248,6 +248,27 @@ describe('git', function () { expect(getPackageVersion()).toEqual('1.1.0-0'); }); + it('increments unnamed prerelease number when unnamed prerelease tag already exists', async function () { + writePackageJson('1.2.3'); + // Existing unnamed prerelease tag 1.2.3-0 exists + mock({ bump: 'patch', tags: ['v1.2.3-0'] }); + await exec('--prerelease'); + expect(getPackageVersion()).toEqual('1.2.4-0'); + // Now start from a prerelease of same base to trigger unnamed collision + writePackageJson('1.2.3-0'); + mock({ bump: 'patch', tags: ['v1.2.3-0'] }); + await exec('--prerelease'); + expect(getPackageVersion()).toEqual('1.2.3-1'); + }); + + it('increments unnamed prerelease number with gitTagFallback when unnamed prerelease tag already exists', async function () { + shell.rm('package.json'); + mock({ bump: 'patch', tags: ['v1.2.3-0'] }); + await exec({ packageFiles: [], gitTagFallback: true, prerelease: '' }); + const output = shell.exec('git tag').stdout; + expect(output).toMatch(/v1\.2\.3-1/); + }); + it('increments prerelease number when same prerelease tag already exists', async function () { writePackageJson('1.4.3-abc.0'); // Simulate existing tags where v1.4.3-xyz.0 already exists from git history From 690590e88327033f679d6ebb921cdd78c6508d72 Mon Sep 17 00:00:00 2001 From: SLASHLogin Date: Tue, 28 Oct 2025 21:03:20 +0100 Subject: [PATCH 3/3] docs: add prerelease collision avoidance to README Update readme explaining how --prerelease automatically increments the numeric suffix when a tag already exists for the same base version and prerelease channel. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index b466a5e13..442b01e77 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,20 @@ npm run release -- --prerelease alpha This will tag the version as: `1.0.1-alpha.0` +#### Prerelease Tag Collision Avoidance + +When cutting a prerelease with `--prerelease`, `commit-and-tag-version` automatically checks existing git tags (respecting your `tagPrefix` configuration) for the same base version and prerelease channel. If a tag already exists, the numeric suffix is automatically incremented to avoid conflicts. + +For example, if you're working with multiple prerelease channels simultaneously: + +```bash +commit-and-tag-version --prerelease xyz # Creates v1.4.3-xyz.0 +commit-and-tag-version --prerelease abc # Creates v1.4.3-abc.0 +commit-and-tag-version --prerelease xyz # Creates v1.4.3-xyz.1 (auto-incremented) +``` + +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. + ### Release as a Target Type Imperatively (`npm version`-like) To forgo the automated version bump use `--release-as` with the argument `major`, `minor` or `patch`.