@@ -31,7 +31,9 @@ import {
3131 validateManifest ,
3232 validateSkillStructure ,
3333 buildTag ,
34+ parseTag ,
3435 bumpVersion ,
36+ compareSemver ,
3537 lastTagFor ,
3638 commitsSince ,
3739 git ,
@@ -181,11 +183,27 @@ async function buildPlan(args) {
181183 }
182184 const lastTag = lastTagFor ( s . name ) ;
183185 const commits = commitsSince ( lastTag , s . name ) ;
186+ const publishedVersion = lastTag ? parseTag ( lastTag ) ?. version ?? null : null ;
187+ const manifestVersion = s . manifest . version ;
188+
189+ // The manifest can legitimately sit ahead of the last published tag:
190+ // a contributor may have bumped manifest.version by hand and committed
191+ // it before running release. In that case we must NOT bump again — we
192+ // ship exactly the version they wrote into the manifest.
193+ let manifestState = "synced" ;
194+ if ( publishedVersion ) {
195+ const cmp = compareSemver ( manifestVersion , publishedVersion ) ;
196+ if ( cmp > 0 ) manifestState = "ahead" ;
197+ else if ( cmp < 0 ) manifestState = "behind" ;
198+ }
199+
184200 candidates . push ( {
185201 name : s . name ,
186202 manifestPath : s . manifestPath ,
187203 manifest : s . manifest ,
188- currentVersion : s . manifest . version ,
204+ currentVersion : manifestVersion ,
205+ publishedVersion,
206+ manifestState, // "synced" | "ahead" | "behind"
189207 lastTag,
190208 commits,
191209 isInitial : lastTag === null ,
@@ -207,7 +225,15 @@ async function decideBumps(candidates, args) {
207225 const v = cand . currentVersion ;
208226 const presetKind = args . perSkill [ cand . name ] ;
209227 const hasNew = cand . commits . length > 0 ;
210- const eligible = cand . isInitial || hasNew || presetKind ;
228+ const isAhead = cand . manifestState === "ahead" ;
229+ const eligible = cand . isInitial || hasNew || presetKind || isAhead ;
230+
231+ if ( cand . manifestState === "behind" ) {
232+ fail (
233+ `${ cand . name } : manifest.version (v${ v } ) is BEHIND the latest tag (${ cand . lastTag } ). ` +
234+ `Refusing to release a downgrade. Bump manifest to >= v${ cand . publishedVersion } .` ,
235+ ) ;
236+ }
211237
212238 if ( ! eligible ) {
213239 console . log (
@@ -216,7 +242,11 @@ async function decideBumps(candidates, args) {
216242 continue ;
217243 }
218244 printedAny = true ;
219- const tag = cand . isInitial ? c ( "yellow" , "INITIAL" ) : `since ${ cand . lastTag } ` ;
245+ const tag = cand . isInitial
246+ ? c ( "yellow" , "INITIAL" )
247+ : isAhead
248+ ? c ( "cyan" , `MANIFEST AHEAD: v${ cand . publishedVersion } → v${ v } ` )
249+ : `since ${ cand . lastTag } ` ;
220250 console . log (
221251 ` ${ c ( "green" , "●" ) } ${ c ( "bold" , cand . name . padEnd ( 24 ) ) } ${ c ( "dim" , `v${ v } ` ) } ${ c ( "dim" , `(${ tag } ${ hasNew ? `, ${ cand . commits . length } commit${ cand . commits . length === 1 ? "" : "s" } ` : "" } )` ) } ` ,
222252 ) ;
@@ -237,6 +267,14 @@ async function decideBumps(candidates, args) {
237267 ? `initial release at v${ v } (--bump ${ presetKind } ignored — edit manifest.json to change)`
238268 : `initial release at v${ v } ` ;
239269 console . log ( ` → ${ c ( "cyan" , note ) } ` ) ;
270+ } else if ( isAhead ) {
271+ // Manifest was hand-bumped past the last tag. Honour exactly what the
272+ // contributor wrote — bumping again would skip over their version.
273+ kind = "keep" ;
274+ const note = presetKind
275+ ? `using manifest v${ v } as-is (--bump ${ presetKind } ignored — manifest already ahead of ${ cand . lastTag } )`
276+ : `using manifest v${ v } as-is (already bumped past ${ cand . lastTag } )` ;
277+ console . log ( ` → ${ c ( "cyan" , note ) } ` ) ;
240278 } else if ( presetKind ) {
241279 kind = presetKind ;
242280 console . log ( ` → ${ c ( "cyan" , `bump preset: ${ kind } (v${ bumpVersion ( v , kind ) } )` ) } ` ) ;
@@ -248,7 +286,8 @@ async function decideBumps(candidates, args) {
248286 console . log ( c ( "dim" , ` skipped` ) ) ;
249287 continue ;
250288 }
251- const nextVersion = kind === "initial" ? v : bumpVersion ( v , kind ) ;
289+ const nextVersion =
290+ kind === "initial" || kind === "keep" ? v : bumpVersion ( v , kind ) ;
252291 decisions . push ( { ...cand , kind, nextVersion } ) ;
253292 console . log ( "" ) ;
254293 }
@@ -337,7 +376,11 @@ async function main() {
337376 // ---- summary ----------------------------------------------------------
338377 console . log ( c ( "bold" , "\nRelease plan:" ) ) ;
339378 for ( const d of decisions ) {
340- const hop = d . kind === "initial" ? `v${ d . nextVersion } (initial)` : `v${ d . currentVersion } → v${ d . nextVersion } (${ d . kind } )` ;
379+ let hop ;
380+ if ( d . kind === "initial" ) hop = `v${ d . nextVersion } (initial)` ;
381+ else if ( d . kind === "keep" )
382+ hop = `v${ d . publishedVersion } → v${ d . nextVersion } (manifest already bumped)` ;
383+ else hop = `v${ d . currentVersion } → v${ d . nextVersion } (${ d . kind } )` ;
341384 const tag = buildTag ( d . name , d . nextVersion ) ;
342385 console . log ( ` • ${ c ( "bold" , d . name . padEnd ( 24 ) ) } ${ hop } ${ c ( "dim" , `→ tag ${ tag } ` ) } ` ) ;
343386 }
@@ -369,6 +412,8 @@ async function main() {
369412 for ( const d of decisions ) {
370413 if ( d . kind === "initial" ) {
371414 console . log ( ` ${ d . name } : keeping v${ d . nextVersion } (initial)` ) ;
415+ } else if ( d . kind === "keep" ) {
416+ console . log ( ` ${ d . name } : keeping v${ d . nextVersion } (manifest already at target)` ) ;
372417 } else {
373418 console . log ( ` ${ d . name } : ${ d . currentVersion } → ${ d . nextVersion } ` ) ;
374419 await bumpManifest ( d ) ;
0 commit comments