Skip to content

Commit 75d69a6

Browse files
authored
fix(bump): auto-increment prerelease when tag already exists (#258)
* fix(bump): auto-increment prerelease when tag already exists * fix(bump): auto-increment unnamed prerelease when tag already exists * 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.
1 parent 6a5627d commit 75d69a6

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,20 @@ npm run release -- --prerelease alpha
284284

285285
This will tag the version as: `1.0.1-alpha.0`
286286

287+
#### Prerelease Tag Collision Avoidance
288+
289+
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.
290+
291+
For example, if you're working with multiple prerelease channels simultaneously:
292+
293+
```bash
294+
commit-and-tag-version --prerelease xyz # Creates v1.4.3-xyz.0
295+
commit-and-tag-version --prerelease abc # Creates v1.4.3-abc.0
296+
commit-and-tag-version --prerelease xyz # Creates v1.4.3-xyz.1 (auto-incremented)
297+
```
298+
299+
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.
300+
287301
### Release as a Target Type Imperatively (`npm version`-like)
288302

289303
To forgo the automated version bump use `--release-as` with the argument `major`, `minor` or `patch`.

lib/lifecycles/bump.js

Lines changed: 103 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,96 @@ 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+
// Determine current numeric index depending on named vs unnamed prerelease
294+
let currentNum = 0;
295+
if (Array.isArray(parsed.prerelease) && parsed.prerelease.length) {
296+
if (prereleaseId === '' && typeof parsed.prerelease[0] === 'number') {
297+
// unnamed prerelease like 1.2.3-0
298+
currentNum = parsed.prerelease[0];
299+
} else if (typeof parsed.prerelease[1] === 'number') {
300+
// named prerelease like 1.2.3-alpha.0
301+
currentNum = parsed.prerelease[1];
302+
}
303+
}
304+
305+
const tags = await new Promise((resolve, reject) => {
306+
gitSemverTags({ tagPrefix }, (err, t) =>
307+
err ? reject(err) : resolve(t || []),
308+
);
309+
});
310+
311+
// strip prefix and clean
312+
const cleaned = tags
313+
.map((t) => t.replace(new RegExp('^' + tagPrefix), ''))
314+
.map((t) => (semver.valid(t) ? semver.clean(t) : null))
315+
.filter(Boolean);
316+
317+
// collect numeric suffix for same base and prerelease id (or unnamed prerelease)
318+
const nums = cleaned
319+
.filter((t) => {
320+
const v = new semver.SemVer(t);
321+
if (!Array.isArray(v.prerelease) || v.prerelease.length === 0)
322+
return false;
323+
const sameBase =
324+
v.major === parsed.major &&
325+
v.minor === parsed.minor &&
326+
v.patch === parsed.patch;
327+
if (!sameBase) return false;
328+
if (prereleaseId === '') {
329+
// unnamed prerelease: include tags where first prerelease token is numeric
330+
return typeof v.prerelease[0] === 'number';
331+
}
332+
// named prerelease: match by identifier
333+
return String(v.prerelease[0]) === String(prereleaseId);
334+
})
335+
.map((t) => {
336+
const v = new semver.SemVer(t);
337+
if (prereleaseId === '') {
338+
return typeof v.prerelease[0] === 'number' ? v.prerelease[0] : 0;
339+
}
340+
return typeof v.prerelease[1] === 'number' ? v.prerelease[1] : 0;
341+
});
342+
343+
if (nums.length === 0) {
344+
// no collisions possible
345+
return proposedVersion;
346+
}
347+
348+
const maxExisting = Math.max(...nums);
349+
// If our proposed numeric index is already used or below max, bump to max + 1
350+
if (currentNum <= maxExisting) {
351+
let candidate =
352+
prereleaseId === ''
353+
? `${base}-${maxExisting + 1}`
354+
: `${base}-${prereleaseId}.${maxExisting + 1}`;
355+
// re-append build metadata if any
356+
if (build && build.length) {
357+
candidate = semvarToVersionStr(candidate, build);
358+
}
359+
return candidate;
360+
}
361+
return proposedVersion;
362+
} catch {
363+
// If anything goes wrong, fall back to proposedVersion
364+
return proposedVersion;
365+
}
366+
}

test/git.integration-test.js

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

251+
it('increments unnamed prerelease number when unnamed prerelease tag already exists', async function () {
252+
writePackageJson('1.2.3');
253+
// Existing unnamed prerelease tag 1.2.3-0 exists
254+
mock({ bump: 'patch', tags: ['v1.2.3-0'] });
255+
await exec('--prerelease');
256+
expect(getPackageVersion()).toEqual('1.2.4-0');
257+
// Now start from a prerelease of same base to trigger unnamed collision
258+
writePackageJson('1.2.3-0');
259+
mock({ bump: 'patch', tags: ['v1.2.3-0'] });
260+
await exec('--prerelease');
261+
expect(getPackageVersion()).toEqual('1.2.3-1');
262+
});
263+
264+
it('increments unnamed prerelease number with gitTagFallback when unnamed prerelease tag already exists', async function () {
265+
shell.rm('package.json');
266+
mock({ bump: 'patch', tags: ['v1.2.3-0'] });
267+
await exec({ packageFiles: [], gitTagFallback: true, prerelease: '' });
268+
const output = shell.exec('git tag').stdout;
269+
expect(output).toMatch(/v1\.2\.3-1/);
270+
});
271+
272+
it('increments prerelease number when same prerelease tag already exists', async function () {
273+
writePackageJson('1.4.3-abc.0');
274+
// Simulate existing tags where v1.4.3-xyz.0 already exists from git history
275+
mock({ bump: 'patch', tags: ['v1.4.3-xyz.0'] });
276+
await exec('--prerelease xyz');
277+
// Base remains 1.4.3 when switching prerelease channel mid-cycle; must bump numeric suffix to avoid tag collision
278+
expect(getPackageVersion()).toEqual('1.4.3-xyz.1');
279+
});
280+
281+
it('increments prerelease number with gitTagFallback when same prerelease tag already exists', async function () {
282+
// Setup without package.json and with existing tags only
283+
shell.rm('package.json');
284+
mock({ bump: 'patch', tags: ['v1.4.3-xyz.0'] });
285+
await exec({ packageFiles: [], gitTagFallback: true, prerelease: 'xyz' });
286+
const output = shell.exec('git tag').stdout;
287+
expect(output).toMatch(/v1\.4\.3-xyz\.1/);
288+
});
289+
251290
describe('gitTagFallback', function () {
252291
beforeEach(function () {
253292
setup();

0 commit comments

Comments
 (0)