diff --git a/lib/lifecycles/bump.js b/lib/lifecycles/bump.js index 7db4524e3..eef9a17c0 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,96 @@ 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 + + // 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) => + 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 (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; + const sameBase = + v.major === parsed.major && + v.minor === parsed.minor && + 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; + }); + + 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 = + prereleaseId === '' + ? `${base}-${maxExisting + 1}` + : `${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..d2219d85b 100644 --- a/test/git.integration-test.js +++ b/test/git.integration-test.js @@ -248,6 +248,45 @@ 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 + 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();