fix(release): scope tag-monotonicity guard to tags reachable from HEAD

The guard blocked on any semver-higher v* tag, including orphan tags on dead
branches (e.g. a stray `chore: release v1.0.3` never merged or published).
Such tags can't appear in the release history and shouldn't block a legitimate
release. Now only tags that are BOTH higher AND reachable from HEAD block;
extracted `findBlockingTags` with unit coverage for orphan/reachable/lower cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Vance Ingalls
2026-06-19 00:34:58 -07:00
co-authored by Claude Opus 4.8
parent 1ae084f4ca
commit cdc76729cd
2 changed files with 69 additions and 14 deletions
+25
View File
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import {
changelogArtifacts,
compareSemver,
findBlockingTags,
findUnexpectedChanges,
isPrerelease,
parseReleaseOptions,
@@ -77,6 +78,30 @@ describe("set-version release options", () => {
});
});
describe("tag-monotonicity guard", () => {
it("blocks a higher tag that is reachable from HEAD", () => {
assert.deepEqual(
findBlockingTags(["0.6.112", "0.7.0"], "0.6.113", () => true),
["0.7.0"],
);
});
it("ignores a higher tag that is not reachable from HEAD (orphan)", () => {
// v1.0.3 is higher but lives on a dead branch — not an ancestor of HEAD.
assert.deepEqual(
findBlockingTags(["0.6.112", "1.0.3"], "0.6.113", (t) => t !== "1.0.3"),
[],
);
});
it("ignores lower-or-equal tags regardless of reachability", () => {
assert.deepEqual(
findBlockingTags(["0.6.112", "0.6.113"], "0.6.113", () => true),
[],
);
});
});
describe("semver comparison", () => {
it("returns negative when a < b", () => {
assert.ok(compareSemver("0.6.82", "1.0.3") < 0);
+44 -14
View File
@@ -149,6 +149,36 @@ export function compareSemver(a: string, b: string): number {
return 0;
}
function tagReachableFromHead(tag: string): boolean {
// `merge-base --is-ancestor` exits 0 when v<tag> is an ancestor of HEAD, 1
// otherwise. Orphan tags (abandoned release attempts on dead branches) are
// NOT ancestors, so they return false and are ignored by the guard below.
try {
execFileSync("git", ["merge-base", "--is-ancestor", `v${tag}`, "HEAD"], {
cwd: ROOT,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
/**
* Tags that would hijack a tag-sorting installer for this line: BOTH
* semver-higher than the release AND reachable from the release commit. A
* higher tag that isn't an ancestor of HEAD (e.g. a stray `chore: release`
* commit on a dead branch that was never published) can't appear in this
* history and is excluded — it should not block a legitimate release.
*/
export function findBlockingTags(
stableVersions: string[],
version: string,
isReachable: (tag: string) => boolean,
): string[] {
return stableVersions.filter((t) => compareSemver(t, version) > 0 && isReachable(t));
}
function assertTagMonotonicity(version: string) {
if (isPrerelease(version)) return;
@@ -168,20 +198,20 @@ function assertTagMonotonicity(version: string) {
.filter((t) => t && !t.includes("-"))
.map((t) => t.replace(/^v/, ""));
for (const existing of stableVersions) {
if (compareSemver(existing, version) > 0) {
console.error(`\nTag v${existing} already exists and is semver-higher than v${version}.`);
console.error(`Tag-sorting installers (npx skills, etc.) would resolve the wrong version.`);
console.error(`\nOptions:`);
console.error(
` Delete the stale tag: git tag -d v${existing} && git push origin :refs/tags/v${existing}`,
);
console.error(
` Skip this check: bun run set-version ${version} --skip-monotonicity-check`,
);
process.exit(1);
}
}
const blocking = findBlockingTags(stableVersions, version, tagReachableFromHead);
if (blocking.length === 0) return;
const existing = blocking[0];
console.error(
`\nTag v${existing} already exists, is reachable from HEAD, and is semver-higher than v${version}.`,
);
console.error(`Tag-sorting installers (npx skills, etc.) would resolve the wrong version.`);
console.error(`\nOptions:`);
console.error(
` Delete the stale tag: git tag -d v${existing} && git push origin :refs/tags/v${existing}`,
);
console.error(` Skip this check: bun run set-version ${version} --skip-monotonicity-check`);
process.exit(1);
}
export function releaseRequiresChangelog(options: ReleaseOptions) {