mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user