fix(release): guard against non-monotonic tags (#1283)

* fix(release): guard against non-monotonic tags in set-version

Add a pre-tag check that rejects stable releases when an existing tag
has a higher semver. This prevents tag-sorting installers (npx skills,
simple-git tags.latest) from resolving stale versions.

Closes #1282

* fix(review): add --skip-monotonicity-check escape hatch, fix formatting

Address review feedback:
- Add --skip-monotonicity-check flag for legitimate backport scenarios
- Soften error message to show both options (delete tag or skip check)
- Fix oxfmt formatting
- Add test for new flag parsing
This commit is contained in:
Miguel Ángel
2026-06-08 19:49:59 -04:00
committed by GitHub
parent bcd16b5f74
commit 4c8ef56273
2 changed files with 93 additions and 4 deletions
+37
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
changelogArtifacts,
compareSemver,
findUnexpectedChanges,
isPrerelease,
parseReleaseOptions,
@@ -16,6 +17,16 @@ describe("set-version release options", () => {
version: "1.2.3",
skipTag: true,
skipChangelogCheck: true,
skipMonotonicityCheck: false,
});
});
it("parses --skip-monotonicity-check flag", () => {
assert.deepEqual(parseReleaseOptions(["0.6.82", "--skip-monotonicity-check"]), {
version: "0.6.82",
skipTag: false,
skipChangelogCheck: false,
skipMonotonicityCheck: true,
});
});
@@ -66,6 +77,32 @@ describe("set-version release options", () => {
});
});
describe("semver comparison", () => {
it("returns negative when a < b", () => {
assert.ok(compareSemver("0.6.82", "1.0.3") < 0);
});
it("returns positive when a > b", () => {
assert.ok(compareSemver("1.0.4", "0.6.82") > 0);
});
it("returns zero for equal versions", () => {
assert.equal(compareSemver("0.6.82", "0.6.82"), 0);
});
it("compares major version first", () => {
assert.ok(compareSemver("2.0.0", "1.99.99") > 0);
});
it("compares minor when major is equal", () => {
assert.ok(compareSemver("0.7.0", "0.6.99") > 0);
});
it("compares patch when major and minor are equal", () => {
assert.ok(compareSemver("0.6.83", "0.6.82") > 0);
});
});
describe("changed-path guard", () => {
it("splits NUL-separated git output and drops the empty trailing entry", () => {
assert.deepEqual(splitNulList("packages/core/package.json\0releases/v1.2.3.md\0"), [
+56 -4
View File
@@ -41,6 +41,7 @@ type ReleaseOptions = {
version: string;
skipTag: boolean;
skipChangelogCheck: boolean;
skipMonotonicityCheck: boolean;
};
function main() {
@@ -61,7 +62,7 @@ function main() {
return;
}
createReleaseCommitAndTag(options.version);
createReleaseCommitAndTag(options.version, options.skipMonotonicityCheck);
printReleaseNextSteps(options.version);
}
@@ -69,9 +70,12 @@ export function parseReleaseOptions(args: string[]): ReleaseOptions {
const version = args.find((a) => !a.startsWith("--"));
const skipTag = args.includes("--no-tag");
const skipChangelogCheck = args.includes("--skip-changelog-check");
const skipMonotonicityCheck = args.includes("--skip-monotonicity-check");
if (!version) {
console.error("Usage: bun run set-version <version> [--no-tag] [--skip-changelog-check]");
console.error(
"Usage: bun run set-version <version> [--no-tag] [--skip-changelog-check] [--skip-monotonicity-check]",
);
console.error("Example: bun run set-version 0.1.1");
process.exit(1);
}
@@ -81,7 +85,7 @@ export function parseReleaseOptions(args: string[]): ReleaseOptions {
process.exit(1);
}
return { version, skipTag, skipChangelogCheck };
return { version, skipTag, skipChangelogCheck, skipMonotonicityCheck };
}
function updatePackageVersions(version: string) {
@@ -109,7 +113,11 @@ function updatePluginVersions(version: string) {
}
}
function createReleaseCommitAndTag(version: string) {
function createReleaseCommitAndTag(version: string, skipMonotonicityCheck: boolean = false) {
if (!skipMonotonicityCheck) {
assertTagMonotonicity(version);
}
const allowedPaths = releaseAllowedPaths(version);
assertNoUnexpectedChanges(collectChangedPaths(), allowedPaths);
@@ -125,6 +133,50 @@ function createReleaseCommitAndTag(version: string) {
console.log(`\nCreated commit and tag v${version}`);
}
export function compareSemver(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
}
function assertTagMonotonicity(version: string) {
if (isPrerelease(version)) return;
let tags: string;
try {
tags = execFileSync("git", ["tag", "--list", "v[0-9]*"], {
cwd: ROOT,
encoding: "utf-8",
});
} catch {
return;
}
const stableVersions = tags
.trim()
.split("\n")
.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);
}
}
}
export function releaseRequiresChangelog(options: ReleaseOptions) {
return !options.skipTag && !options.skipChangelogCheck && !isPrerelease(options.version);
}