Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions lib/lifecycles/bump.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string>} 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;
}
}
39 changes: 39 additions & 0 deletions test/git.integration-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down