@@ -12,6 +12,7 @@ const runLifecycleScript = require('../run-lifecycle-script');
12
12
const semver = require ( 'semver' ) ;
13
13
const writeFile = require ( '../write-file' ) ;
14
14
const { resolveUpdaterObjectFromArgument } = require ( '../updaters' ) ;
15
+ const gitSemverTags = require ( 'git-semver-tags' ) ;
15
16
let configsToUpdate = { } ;
16
17
const sanitizeQuotesRegex = / [ ' " ] + / g;
17
18
@@ -83,6 +84,15 @@ async function Bump(args, version) {
83
84
84
85
newVersion = semver . inc ( version , releaseType , args . prerelease ) ;
85
86
}
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
+ }
86
96
updateConfigs ( args , newVersion ) ;
87
97
} else {
88
98
checkpoint (
@@ -261,3 +271,78 @@ function updateConfigs(args, newVersion) {
261
271
}
262
272
263
273
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
+ }
0 commit comments