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:
+27
-25
@@ -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
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user