fix(release): reject prereleases and normalize the version prefix

Wave-2 review findings on #7586:
- the guard accepted `17.2.8-rc.1`, and the publish step passes no
  --tag, so a prerelease would have become the npm `latest` every
  unqualified install and `omp update` resolve
- `v17.2.8` passed the guard and reached Cargo.toml verbatim, which
  cargo rejects only after every manifest was rewritten; validation now
  returns the normalized version and callers write that
This commit is contained in:
metaphorics
2026-08-05 01:59:08 +09:00
parent 726698e2b0
commit df3fa86f7d
2 changed files with 50 additions and 34 deletions
+27 -25
View File
@@ -1,36 +1,38 @@
import { describe, expect, test } from "bun:test";
import { isValidExplicitVersion } from "./release";
import { validateExplicitVersion } from "./release";
describe("isValidExplicitVersion", () => {
describe("validateExplicitVersion", () => {
test("rejects malformed versions", () => {
expect(isValidExplicitVersion("999.bad")).toBe(false);
expect(isValidExplicitVersion("17")).toBe(false);
expect(isValidExplicitVersion("17.2")).toBe(false);
expect(isValidExplicitVersion("17.2.8.9")).toBe(false);
expect(isValidExplicitVersion("v17.2.8.9")).toBe(false);
expect(isValidExplicitVersion("abc")).toBe(false);
expect(isValidExplicitVersion("")).toBe(false);
expect(isValidExplicitVersion("v")).toBe(false);
expect(isValidExplicitVersion("17.2.8-")).toBe(false);
expect(validateExplicitVersion("999.bad")).toBe(null);
expect(validateExplicitVersion("17")).toBe(null);
expect(validateExplicitVersion("17.2")).toBe(null);
expect(validateExplicitVersion("17.2.8.9")).toBe(null);
expect(validateExplicitVersion("v17.2.8.9")).toBe(null);
expect(validateExplicitVersion("abc")).toBe(null);
expect(validateExplicitVersion("")).toBe(null);
expect(validateExplicitVersion("v")).toBe(null);
expect(validateExplicitVersion("17.2.8-")).toBe(null);
});
test("accepts valid three-segment numeric versions", () => {
expect(isValidExplicitVersion("17.2.8")).toBe(true);
expect(isValidExplicitVersion("0.0.0")).toBe(true);
expect(isValidExplicitVersion("1.0.0")).toBe(true);
test("rejects prerelease suffixes (not supported by this release path)", () => {
// Prereleases would be published as npm `latest` because the downstream
// publish runs `npm publish` with no `--tag`.
expect(validateExplicitVersion("17.2.8-rc.1")).toBe(null);
expect(validateExplicitVersion("v17.2.8-beta")).toBe(null);
expect(validateExplicitVersion("1.0.0-alpha")).toBe(null);
expect(validateExplicitVersion("1.0.0-alpha.1.2")).toBe(null);
expect(validateExplicitVersion("1.0.0-0.3.7")).toBe(null);
expect(validateExplicitVersion("1.0.0-x.7.z.92")).toBe(null);
});
test("accepts leading v prefix", () => {
expect(isValidExplicitVersion("v17.2.8")).toBe(true);
expect(isValidExplicitVersion("V17.2.8")).toBe(false);
test("accepts bare three-segment numeric versions and returns them unchanged", () => {
expect(validateExplicitVersion("17.2.8")).toBe("17.2.8");
expect(validateExplicitVersion("0.0.0")).toBe("0.0.0");
expect(validateExplicitVersion("1.0.0")).toBe("1.0.0");
});
test("accepts prerelease suffixes", () => {
expect(isValidExplicitVersion("17.2.8-rc.1")).toBe(true);
expect(isValidExplicitVersion("v17.2.8-rc.1")).toBe(true);
expect(isValidExplicitVersion("1.0.0-beta")).toBe(true);
expect(isValidExplicitVersion("1.0.0-alpha.1.2")).toBe(true);
expect(isValidExplicitVersion("1.0.0-0.3.7")).toBe(true);
expect(isValidExplicitVersion("1.0.0-x.7.z.92")).toBe(true);
test("accepts leading v prefix and normalizes to the bare version", () => {
expect(validateExplicitVersion("v17.2.8")).toBe("17.2.8");
expect(validateExplicitVersion("V17.2.8")).toBe(null);
});
});
+23 -9
View File
@@ -16,12 +16,22 @@ const changelogGlob = new Glob("packages/*/CHANGELOG.md");
const packageJsonGlob = new Glob("packages/*/package.json");
const cargoTomlGlob = new Glob("crates/*/Cargo.toml");
/**
* Strict explicit-version guard: three numeric dot-segments, optional leading
* `v`, optional SemVer-2.0 prerelease suffix. Bump keywords (major/minor/patch)
* are handled separately and must not be routed through this check.
* Strict explicit-version guard: three numeric dot-segments with an optional
* leading `v` and NO prerelease suffix. Prereleases are rejected because the
* downstream publish (`scripts/ci-release-publish.ts`) runs `npm publish` with
* no `--tag`, which would promote a prerelease to the npm `latest` dist-tag —
* hitting every unqualified install and the `/latest` endpoint `omp update`
* reads. Bump keywords (major/minor/patch) are handled separately and must not
* be routed through this check.
*
* Returns the normalized bare version (leading `v` stripped) when accepted, or
* `null` when rejected. Callers must use the returned value for all writes so
* no downstream manifest (package.json, Cargo.toml, tag) ever sees a `v`
* prefix — Cargo rejects `version = "v17.2.8"`.
*/
export function isValidExplicitVersion(version: string): boolean {
return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version);
export function validateExplicitVersion(version: string): string | null {
const match = /^v?(\d+\.\d+\.\d+)$/.exec(version);
return match ? match[1] : null;
}
function git(args: readonly string[]) {
@@ -199,14 +209,18 @@ async function cmdRelease(versionOrBump: string): Promise<void> {
console.log("\n=== Release Script ===\n");
// Validate explicit versions before any compare: the shared compareVersions
// never throws, so without this guard garbage like "999.bad" would be
// accepted and written into every package.json / Cargo.toml / tag.
// accepted and written into every package.json / Cargo.toml / tag. The
// validator also normalizes a leading `v` to the bare version so every
// downstream write (manifests, Cargo.toml, tag) uses `17.2.8`, not `v17.2.8`.
if (versionOrBump !== "major" && versionOrBump !== "minor" && versionOrBump !== "patch") {
if (!isValidExplicitVersion(versionOrBump)) {
const normalized = validateExplicitVersion(versionOrBump);
if (normalized === null) {
console.error(
`Error: Invalid version "${versionOrBump}". Expected a semver like 17.2.8 or v17.2.8-rc.1, or a bump keyword (major/minor/patch).`,
`Error: Invalid version "${versionOrBump}". Expected a semver like 17.2.8 or v17.2.8 (prereleases such as 17.2.8-rc.1 are not supported by this release path), or a bump keyword (major/minor/patch).`,
);
process.exit(1);
}
versionOrBump = normalized;
}
// 1. Pre-flight checks
@@ -421,7 +435,7 @@ if (import.meta.main) {
if (arg === "watch") {
await cmdWatch();
} else if (arg === "major" || arg === "minor" || arg === "patch" || isValidExplicitVersion(arg)) {
} else if (arg === "major" || arg === "minor" || arg === "patch" || validateExplicitVersion(arg) !== null) {
await cmdRelease(arg);
} else {
console.error(`Unknown command or invalid version: ${arg}`);