Skip to content

Commit 97e136b

Browse files
ConardLicursoragent
andcommitted
feat(release): respect manifests that have been hand-bumped past last tag
Previously cut-release.mjs always bumped from manifest.version, so a contributor who manually edited manifest.version from 1.0.0 to 1.0.3 and asked for a "patch" bump would land on 1.0.4, silently skipping their intended 1.0.3. Now: detect manifestState ∈ {synced, ahead, behind}. - synced → previous behaviour (prompt patch/minor/major). - ahead → release exactly the manifest version, no extra bump (kind="keep"). This matches the natural workflow of "edit manifest, commit, then release". - behind → refuse with a clear error (no accidental downgrades). Also: add compareSemver() helper in lib/skills.mjs. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dac12c2 commit 97e136b

2 files changed

Lines changed: 65 additions & 5 deletions

File tree

scripts/release/cut-release.mjs

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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);

scripts/release/lib/skills.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,21 @@ export function commitsSince(tag, skill, limit = 50) {
223223
});
224224
}
225225

226+
// Compare two SemVer-ish strings (pre-release suffix is ignored). Returns
227+
// -1 if a<b, 0 if equal core, 1 if a>b.
228+
const SEMVER_CORE_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/;
229+
export function compareSemver(a, b) {
230+
const ma = SEMVER_CORE_RE.exec(a);
231+
const mb = SEMVER_CORE_RE.exec(b);
232+
if (!ma || !mb) throw new Error(`compareSemver: invalid input ${a} / ${b}`);
233+
for (let i = 1; i <= 3; i++) {
234+
const da = Number(ma[i]);
235+
const db = Number(mb[i]);
236+
if (da !== db) return da > db ? 1 : -1;
237+
}
238+
return 0;
239+
}
240+
226241
// Bump a SemVer string. Pre-release suffix is dropped on patch/minor/major.
227242
const SEMVER_BUMP_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/;
228243
export function bumpVersion(version, kind) {

0 commit comments

Comments
 (0)