mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
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:
co-authored by
Claude Opus 4.8
parent
1ae084f4ca
commit
cdc76729cd
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user