Skip to content

Commit 663f67d

Browse files
dmealingclaude
andcommitted
fix(release): four scripts derived the Maven coordinate and two were wrong
ADR-0035's decoupled cut fixes the JVM major at `npm major + 7`, so `1.0.0` on npm/PyPI/NuGet is `8.0.0` on Maven Central. Four release scripts each worked that out for themselves: - `finish-release.mjs` — correct, with the rationale written out - `release-verify.mjs` — correct, but only since F41: before that it asked Central for `7.0.0`, a version BELOW the last release, and would have reported all nine live modules missing - `prerelease.mjs` — `const MAVEN_MAJOR = 7`, so a `1.0.0` base derives `7.0.0-rc.N`. Same defect as F41, still shipped, in the script that PUBLISHES - `release.mjs` — prints `java-v7.<minor>.<patch>` as the tag to create. An instruction to a human, which makes it the hardest of the four to catch, because nothing executes it and no gate reads it Two of those are one defect wearing a different hat, which is what a rule with four doors eventually produces. `scripts/maven-coordinate.mjs` is now the single derivation and all four import it; there is no hardcoded 7 left outside it. The 1.0 cases are the whole reason it needs a test: every one of them is a version this repo has never released, which is exactly why two derivations could be wrong with every gate green. `test-maven-coordinate.mjs` pins the historical 0.x mappings unchanged, the 1.0/2.0 majors, an RC suffix surviving verbatim, and malformed input throwing rather than deriving a plausible-looking wrong coordinate. It runs in `gate_release_tag`, beside the tag gate it serves. `finish-release.test.ts` copies the script under test into a temp tree, so it now copies the module too — as a REAL copy, not a stub. A stub would let that suite pass while the shipped derivation was wrong, which is the failure the module exists to prevent. Verified end to end rather than by inspection: `release-verify.mjs 1.0.0-rc.5 --registries=maven` derives `8.0.0-rc.5` through the shared module and finds all nine modules live on Central. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent cec5464 commit 663f67d

9 files changed

Lines changed: 124 additions & 12 deletions

‎scripts/ci-local.sh‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,10 @@ gate_site_reference() { bun_install && bun scripts/build-site-reference.ts --che
325325
#
326326
# No bun_install: the test imports node builtins only and spawns `node` on a copy of the
327327
# script beside a fixture repository, so there is no bare specifier to resolve.
328-
gate_release_tag() { bun test scripts/finish-release.test.ts; }
328+
# Also runs the Maven-coordinate derivation`s own tests: the same rule serves four release
329+
# scripts and two of them used to hardcode major 7, so the 1.0 cases (never released, never
330+
# exercised) are the ones worth a test at all. See scripts/maven-coordinate.mjs.
331+
gate_release_tag() { bun test scripts/finish-release.test.ts && node scripts/test-maven-coordinate.mjs; }
329332

330333
# ── repo-root scripts/ typechecks ─────────────────────────────────────────────
331334
# `bun test` transpiles per file and never typechecks, and `bun run --filter '*'

‎scripts/finish-release.mjs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { execSync } from "node:child_process";
3535
import { existsSync, readFileSync } from "node:fs";
3636
import { dirname, join, resolve } from "node:path";
3737
import { fileURLToPath } from "node:url";
38+
import { mavenVersion } from "./maven-coordinate.mjs";
3839

3940
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
4041
const CHECK_ONLY = process.argv.includes("--check");
@@ -59,10 +60,9 @@ if (VERSION === undefined || !/^\d+\.\d+\.\d+$/.test(VERSION)) {
5960
// hardcoded 7 would demand `7.0.0` at that release while the manifest and Maven Central
6061
// both said `8.0.0`, blocking the cut, with the only ways past being to declare Maven
6162
// (which definitely published) as --sat-out or to write a wrong coordinate into the
62-
// payload. Both defeat the gate.
63-
const [npmMajor, ...npmRest] = VERSION.split(".");
64-
const MAVEN_MAJOR = Number(npmMajor) >= 1 ? 7 + Number(npmMajor) : 7;
65-
const MAVEN_VERSION = `${MAVEN_MAJOR}.${npmRest.join(".")}`;
63+
// payload. Both defeat the gate. The derivation lives in ONE module because four scripts
64+
// had it independently and two were wrong — see scripts/maven-coordinate.mjs.
65+
const MAVEN_VERSION = mavenVersion(VERSION);
6666

6767
// ── 1. the working tree is the tree that will be tagged ──────────────────────
6868
// A tag points at a COMMIT, so anything uncommitted is silently excluded. Tagging with

‎scripts/finish-release.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import { dirname, join, resolve } from "node:path";
3030

3131
const REPO = resolve(import.meta.dirname, "..");
3232
const SCRIPT = readFileSync(resolve(REPO, "scripts/finish-release.mjs"), "utf8");
33+
// A real copy, not a stub: the Maven coordinate is derived here, and the whole point of the
34+
// module is that ONE derivation serves four scripts. Stubbing it would let this suite pass
35+
// while the shipped derivation was wrong — which is the defect the module exists for.
36+
const MAVEN_COORD = readFileSync(resolve(REPO, "scripts/maven-coordinate.mjs"), "utf8");
3337

3438
const created: string[] = [];
3539
afterAll(() => { for (const d of created) rmSync(d, { recursive: true, force: true }); });
@@ -147,7 +151,11 @@ function makeRepo(spec: Spec = {}): Fixture {
147151

148152
// The script under test lives INSIDE the tree it reasons about, so it is committed
149153
// like any other file — otherwise gate 1 would report the fixture dirty.
150-
for (const [rel, body] of Object.entries({ ...files, "scripts/finish-release.mjs": SCRIPT })) {
154+
for (const [rel, body] of Object.entries({
155+
...files,
156+
"scripts/finish-release.mjs": SCRIPT,
157+
"scripts/maven-coordinate.mjs": MAVEN_COORD,
158+
})) {
151159
const full = join(work, rel);
152160
mkdirSync(dirname(full), { recursive: true });
153161
writeFileSync(full, body);

‎scripts/maven-coordinate.mjs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env node
2+
// The ONE place the Maven coordinate is derived from the npm version.
3+
//
4+
// ADR-0035's decoupled cut fixes the JVM major at **npm major + 7**: every `0.x` line has
5+
// shipped as `7.x`, and when npm/PyPI/NuGet cut `1.0.0` Maven Central cuts `8.0.0`. That is a
6+
// forward major, not a continuation, and a hardcoded `7` is wrong the moment the npm line
7+
// leaves `0.x`.
8+
//
9+
// WHY THIS FILE EXISTS. Four scripts derived this independently and two of them were wrong:
10+
// * `finish-release.mjs` — correct (`npmMajor >= 1 ? 7 + npmMajor : 7`)
11+
// * `release-verify.mjs` — correct (`npmMajor + 7`), fixed only after it asked Maven
12+
// Central for `7.0.0` — a version BELOW the last release — and
13+
// would have declared all nine live modules missing
14+
// * `prerelease.mjs` — hardcoded `7`, so a `1.0.0` base derived `7.0.0-rc.N`
15+
// * `release.mjs` — printed `java-v7.<minor>.<patch>` as the tag to create, which
16+
// is an instruction to a human and therefore the hardest of the
17+
// four to catch: nothing executes it
18+
// Two of those are the SAME defect wearing a different hat, which is what a rule with four
19+
// doors always eventually produces. Import from here; do not re-derive.
20+
//
21+
// import { mavenVersion, mavenMajor } from "./maven-coordinate.mjs";
22+
23+
/** The historical offset: the JVM line has always run `npm major + 7`. */
24+
export const MAVEN_MAJOR_OFFSET = 7;
25+
26+
/**
27+
* @param {string} npmVersion e.g. "0.25.0", "1.0.0", "1.0.0-rc.5"
28+
* @returns {number} the Maven major
29+
*/
30+
export function mavenMajor(npmVersion) {
31+
const major = Number(String(npmVersion).split(".")[0]);
32+
if (!Number.isInteger(major) || major < 0) {
33+
throw new Error(`maven-coordinate: cannot read an npm major from "${npmVersion}"`);
34+
}
35+
// `0.x` maps to 7, which the offset already gives; stated as one rule rather than a
36+
// special case, so there is nothing to keep in sync when the npm line moves past 1.
37+
return major + MAVEN_MAJOR_OFFSET;
38+
}
39+
40+
/**
41+
* The full Maven coordinate for an npm version, pre-release suffix preserved verbatim.
42+
* `0.25.0` → `7.25.0`; `1.0.0` → `8.0.0`; `1.0.0-rc.5` → `8.0.0-rc.5`.
43+
* @param {string} npmVersion
44+
* @returns {string}
45+
*/
46+
export function mavenVersion(npmVersion) {
47+
const s = String(npmVersion);
48+
const m = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(s);
49+
if (!m) throw new Error(`maven-coordinate: "${s}" is not a three-part version`);
50+
return `${Number(m[1]) + MAVEN_MAJOR_OFFSET}.${m[2]}.${m[3]}${m[4] ?? ""}`;
51+
}

‎scripts/prerelease.mjs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import { execSync, execFileSync } from "node:child_process";
5959
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
6060
import { join } from "node:path";
61+
import { mavenVersion } from "./maven-coordinate.mjs";
6162

6263
const argv = process.argv.slice(2);
6364
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i === -1 ? d : argv[i + 1]; };
@@ -207,13 +208,15 @@ if (explicitIter && burned.includes(ITER))
207208
` Publishing it privately is fine, but that number can never be used for a public RC.\x1b[0m`);
208209

209210
const CANON = `${BASEVER}-rc.${ITER}`;
210-
const MAVEN_MAJOR = 7;
211-
// The ONE place per-ecosystem normalization lives.
211+
// The ONE place per-ecosystem normalization lives. The Maven coordinate is NOT normalized
212+
// here — it is derived by scripts/maven-coordinate.mjs, because this file used to hardcode
213+
// major 7 and would have derived `7.0.0-rc.N` from a `1.0.0` base: a version BELOW the last
214+
// released 7.25.x, which is the same defect release-verify.mjs shipped and had to be fixed for.
212215
const V = {
213216
npm: CANON,
214217
nuget: CANON,
215218
pypi: `${BASEVER}rc${ITER}`,
216-
maven: `${MAVEN_MAJOR}.${BASEVER.split(".").slice(1).join(".")}-rc.${ITER}`,
219+
maven: mavenVersion(CANON),
217220
};
218221

219222
const ALL = ["npm", "python", "csharp", "java"];

‎scripts/release-verify.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { execSync } from "node:child_process";
2222
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2323
import { join, dirname } from "node:path";
2424
import { homedir } from "node:os";
25+
import { mavenVersion } from "./maven-coordinate.mjs";
2526

2627
const args = process.argv.slice(2);
2728
const VERSION = args.find((a) => /^\d+\.\d+\.\d+(-rc\.\d+)?$/.test(a));
@@ -44,7 +45,7 @@ const parts = VERSION?.match(/^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/);
4445
const IS_RC = Boolean(parts?.[4]);
4546
const PYPI_VERSION = parts && `${parts[1]}.${parts[2]}.${parts[3]}${IS_RC ? `rc${parts[4]}` : ""}`;
4647
const MVN = args.find((a) => a.startsWith("--maven="))?.slice("--maven=".length)
47-
?? (parts && `${Number(parts[1]) + 7}.${parts[2]}.${parts[3]}${IS_RC ? `-rc.${parts[4]}` : ""}`);
48+
?? (parts && mavenVersion(`${parts[1]}.${parts[2]}.${parts[3]}${IS_RC ? `-rc.${parts[4]}` : ""}`));
4849

4950
// A pre-release is published to `next` and must NOT move `latest` — checking `latest`
5051
// against an RC would fail every correct RC cut, and the mistake it is really guarding

‎scripts/release.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { join, dirname } from "node:path";
2929
import { tmpdir, homedir } from "node:os";
3030
import { createInterface } from "node:readline/promises";
3131
import { publishSet } from "./publish-set.mjs";
32+
import { mavenVersion } from "./maven-coordinate.mjs";
3233

3334
const VERSION = process.argv[2];
3435
const DRY = process.argv.includes("--dry-run");
@@ -279,7 +280,7 @@ ok(`smoke: clean external install → meta --version = ${v}`);
279280
console.log(`\n\x1b[32m\x1b[1m✅ ${VERSION} is live on npm latest.\x1b[0m`);
280281
console.log(`\n\x1b[1mNOT DONE — v${VERSION} is not tagged yet, by design.\x1b[0m`);
281282
console.log(` 1. Bump PyPI/NuGet/Maven, run \`bun run site:payload\`, commit, push.`);
282-
console.log(` 2. Tag the ports: python-v${VERSION}, csharp-v${VERSION}, java-v7.${VERSION.split(".").slice(1).join(".")}.`);
283+
console.log(` 2. Tag the ports: python-v${VERSION}, csharp-v${VERSION}, java-v${mavenVersion(VERSION)}.`);
283284
console.log(` 3. \`bun scripts/finish-release.mjs ${VERSION}\` — gates the coordinates, then cuts v${VERSION}.`);
284285
console.log(` The website pins to v${VERSION}, so tagging before step 1 publishes three stale versions.`);
285286
console.log(` Docs version refs are NOT auto-bumped (low-value churn for a patch); batch them when convenient.`);

‎scripts/test-maven-coordinate.mjs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env node
2+
// Tests for scripts/maven-coordinate.mjs.
3+
//
4+
// The 1.0 cases are the point: every one of them is a version this repo has never released,
5+
// which is exactly why two of the four original derivations were wrong and nothing noticed.
6+
//
7+
// node scripts/test-maven-coordinate.mjs
8+
9+
import { mavenMajor, mavenVersion } from "./maven-coordinate.mjs";
10+
11+
let fails = 0;
12+
const eq = (got, want, label) =>
13+
got === want ? console.log(`ok: ${label}`) : (console.error(`FAIL: ${label} — got ${got}, want ${want}`), fails++);
14+
const throws = (fn, label) => {
15+
try {
16+
fn();
17+
console.error(`FAIL: ${label} — did not throw`);
18+
fails++;
19+
} catch {
20+
console.log(`ok: ${label}`);
21+
}
22+
};
23+
24+
// Every historical 0.x mapping, unchanged — this is the regression half.
25+
eq(mavenVersion("0.25.0"), "7.25.0", "0.25.0 → 7.25.0");
26+
eq(mavenVersion("0.24.5"), "7.24.5", "0.24.5 → 7.24.5");
27+
eq(mavenVersion("0.15.1"), "7.15.1", "0.15.1 → 7.15.1");
28+
eq(mavenMajor("0.25.0"), 7, "0.x major is 7");
29+
30+
// The 1.0 line — where the hardcoded 7 produced a version BELOW the last release.
31+
eq(mavenVersion("1.0.0"), "8.0.0", "1.0.0 → 8.0.0");
32+
eq(mavenMajor("1.0.0"), 8, "1.x major is 8");
33+
eq(mavenVersion("1.0.0-rc.5"), "8.0.0-rc.5", "an RC keeps its suffix verbatim");
34+
eq(mavenVersion("1.2.3-rc.11"), "8.2.3-rc.11", "a two-digit iteration survives");
35+
eq(mavenVersion("2.0.0"), "9.0.0", "the offset is a rule, not a 1.0 special case");
36+
37+
// Malformed input fails loudly rather than deriving a plausible-looking wrong coordinate.
38+
throws(() => mavenVersion("1.0"), "a two-part version throws");
39+
throws(() => mavenVersion("v1.0.0"), "a v-prefixed tag throws");
40+
throws(() => mavenVersion(""), "an empty string throws");
41+
throws(() => mavenMajor("nope"), "a non-numeric major throws");
42+
43+
console.log(fails === 0 ? "\nAll maven-coordinate tests passed." : `\n${fails} test(s) FAILED.`);
44+
process.exit(fails === 0 ? 0 : 1);

‎tsconfig.scripts.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"scripts/site/*.mjs",
146146
"scripts/finish-release.mjs",
147147
"scripts/site-inject-ci.mjs",
148-
"scripts/check-migration-guides-vs-registry.mjs"
148+
"scripts/check-migration-guides-vs-registry.mjs",
149+
"scripts/maven-coordinate.mjs"
149150
]
150151
}

0 commit comments

Comments
 (0)