Skip to content

Commit c2536cc

Browse files
committed
fix(bump): auto-increment prerelease when tag already exists
1 parent 6a5627d commit c2536cc

File tree

2 files changed

+103
-0
lines changed

2 files changed

+103
-0
lines changed

lib/lifecycles/bump.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const runLifecycleScript = require('../run-lifecycle-script');
1212
const semver = require('semver');
1313
const writeFile = require('../write-file');
1414
const { resolveUpdaterObjectFromArgument } = require('../updaters');
15+
const gitSemverTags = require('git-semver-tags');
1516
let configsToUpdate = {};
1617
const sanitizeQuotesRegex = /['"]+/g;
1718

@@ -83,6 +84,15 @@ async function Bump(args, version) {
8384

8485
newVersion = semver.inc(version, releaseType, args.prerelease);
8586
}
87+
88+
// If creating a prerelease, ensure the computed version is unique among existing git tags
89+
if (isString(args.prerelease) && newVersion) {
90+
newVersion = await resolveUniquePrereleaseVersion(
91+
newVersion,
92+
args.tagPrefix,
93+
args.prerelease,
94+
);
95+
}
8696
updateConfigs(args, newVersion);
8797
} else {
8898
checkpoint(
@@ -261,3 +271,78 @@ function updateConfigs(args, newVersion) {
261271
}
262272

263273
module.exports = Bump;
274+
275+
/**
276+
* Ensure prerelease version uniqueness by checking existing git tags.
277+
* If a tag for the same base version and prerelease identifier exists, bump the numeric suffix.
278+
* @param {string} proposedVersion The version computed by bump logic, may include build metadata.
279+
* @param {string} tagPrefix The tag prefix to respect when reading tags (e.g., 'v').
280+
* @param {string} prereleaseId The prerelease identifier (e.g., 'alpha', 'beta', 'rc').
281+
* @returns {Promise<string>} The adjusted version that does not collide with existing tags.
282+
*/
283+
async function resolveUniquePrereleaseVersion(
284+
proposedVersion,
285+
tagPrefix,
286+
prereleaseId,
287+
) {
288+
try {
289+
const parsed = new semver.SemVer(proposedVersion);
290+
const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`;
291+
const build = parsed.build; // preserve build metadata if present
292+
293+
// Current numeric index if present, otherwise default to 0
294+
const currentNum =
295+
typeof parsed.prerelease[1] === 'number' ? parsed.prerelease[1] : 0;
296+
297+
const tags = await new Promise((resolve, reject) => {
298+
gitSemverTags({ tagPrefix }, (err, t) =>
299+
err ? reject(err) : resolve(t || []),
300+
);
301+
});
302+
303+
// strip prefix and clean
304+
const cleaned = tags
305+
.map((t) => t.replace(new RegExp('^' + tagPrefix), ''))
306+
.map((t) => (semver.valid(t) ? semver.clean(t) : null))
307+
.filter(Boolean);
308+
309+
// collect numeric suffix for same base and prerelease id
310+
const nums = cleaned
311+
.filter((t) => {
312+
const v = new semver.SemVer(t);
313+
if (!Array.isArray(v.prerelease) || v.prerelease.length === 0)
314+
return false;
315+
// same base version and same prerelease id
316+
return (
317+
v.major === parsed.major &&
318+
v.minor === parsed.minor &&
319+
v.patch === parsed.patch &&
320+
String(v.prerelease[0]) === String(prereleaseId)
321+
);
322+
})
323+
.map((t) => {
324+
const v = new semver.SemVer(t);
325+
return typeof v.prerelease[1] === 'number' ? v.prerelease[1] : 0;
326+
});
327+
328+
if (nums.length === 0) {
329+
// no collisions possible
330+
return proposedVersion;
331+
}
332+
333+
const maxExisting = Math.max(...nums);
334+
// If our proposed numeric index is already used or below max, bump to max + 1
335+
if (currentNum <= maxExisting) {
336+
let candidate = `${base}-${prereleaseId}.${maxExisting + 1}`;
337+
// re-append build metadata if any
338+
if (build && build.length) {
339+
candidate = semvarToVersionStr(candidate, build);
340+
}
341+
return candidate;
342+
}
343+
return proposedVersion;
344+
} catch {
345+
// If anything goes wrong, fall back to proposedVersion
346+
return proposedVersion;
347+
}
348+
}

test/git.integration-test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,24 @@ describe('git', function () {
248248
expect(getPackageVersion()).toEqual('1.1.0-0');
249249
});
250250

251+
it('increments prerelease number when same prerelease tag already exists', async function () {
252+
writePackageJson('1.4.3-abc.0');
253+
// Simulate existing tags where v1.4.3-xyz.0 already exists from git history
254+
mock({ bump: 'patch', tags: ['v1.4.3-xyz.0'] });
255+
await exec('--prerelease xyz');
256+
// Base remains 1.4.3 when switching prerelease channel mid-cycle; must bump numeric suffix to avoid tag collision
257+
expect(getPackageVersion()).toEqual('1.4.3-xyz.1');
258+
});
259+
260+
it('increments prerelease number with gitTagFallback when same prerelease tag already exists', async function () {
261+
// Setup without package.json and with existing tags only
262+
shell.rm('package.json');
263+
mock({ bump: 'patch', tags: ['v1.4.3-xyz.0'] });
264+
await exec({ packageFiles: [], gitTagFallback: true, prerelease: 'xyz' });
265+
const output = shell.exec('git tag').stdout;
266+
expect(output).toMatch(/v1\.4\.3-xyz\.1/);
267+
});
268+
251269
describe('gitTagFallback', function () {
252270
beforeEach(function () {
253271
setup();

0 commit comments

Comments
 (0)