Skip to content

Commit ab82d0f

Browse files
dmealingclaude
andcommitted
feat(release): one committed verification script, and never probe auth with a write
Replaces the ad-hoc shell the 0.24.0 cut was verified with. `release-verify.mjs` does the auth preflight, all four registries, stray dist-tag reporting, and a real external install in npm + pnpm — every check READ-ONLY. WHY IT IS READ-ONLY, SPECIFICALLY. The improvised auth check on that cut was a WRITE against a published package: `npm dist-tag add @metaobjectsdev/cli auth-probe`. It is not reversible. Since 2026-07-31 a bypass-2FA token may still publish but may not change package access, so the add succeeds and the rm returns 403 — stranding a stray tag on a public package that only an interactive-2FA session can clear. Worse, I then wrote that probe into RELEASING.md as recommended practice. Both are removed here. WHAT REPLACES IT. `whoami` and `access list` are reads and prove auth but NOT write capability, and the preflight says exactly that rather than implying more. The real probe is the tier-0 publish: `metadata` goes first, so a wrong token fails there, before anything depends on it — which is already why the set publishes in tier order. The smoke runs under ~/.cache and refuses to start if an ancestor holds a node_modules; a stale /tmp/node_modules faked a MISSING-export failure on the same cut, and the false GREEN is the worse half of that bug. Stray dist-tags are REPORTED, never deleted — deletion needs interactive 2FA. Verified against the live 0.24.0 release: 14/14 npm, PyPI, 4 NuGet, 9 Maven modules, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent a677381 commit ab82d0f

3 files changed

Lines changed: 190 additions & 11 deletions

File tree

‎.claude/skills/releasing/SKILL.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ reviewable "Version Packages" PR → automated bump + CHANGELOG). Adopting it wo
211211
replace Phases 1/3/7's manual steps; the preflight gate, smoke test, and
212212
stop-and-confirm in this skill stay.
213213

214+
## Verify with the committed script, not ad-hoc shell
215+
`node scripts/release-verify.mjs [--preflight] [<version>] [--smoke]` is the one place
216+
release verification lives — auth preflight, all four registries, stray dist-tags, and a
217+
real external install in npm + pnpm (outside `/tmp`, so nothing shadows it). Use it
218+
instead of improvising checks per release: the 0.24.0 cut was verified by a dozen
219+
throwaway scripts, and the improvised auth check was a **write** against a published
220+
package that could not be undone. **Never probe credentials by mutating a published
221+
package.** It is read-only; extend it there when a release needs a check it lacks.
222+
214223
## Phase 0 addendum — check that you CAN publish
215224
`scripts/release.mjs` now runs `npm whoami` in preflight. Do the same by hand if you
216225
are publishing manually, and use an **Automation / bypass-2FA** token: a "Publish"

‎docs/RELEASING.md‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -300,19 +300,26 @@ diff explained.
300300
> in CI or any non-interactive shell; `--otp=` is silently IGNORED (bun prompts `Enter OTP:`
301301
> regardless, so a code must be piped on **stdin**). And if the account's 2FA is a security
302302
> key rather than TOTP, there is no 6-digit code to supply at all. Use **Classic → Automation**
303-
> (or a granular token with 2FA-bypass enabled). Verify before you need it, with a reversible
304-
> write rather than a read:
303+
> (or a granular token with 2FA-bypass enabled).
304+
>
305+
> **Do NOT try to prove the token type with a write against a published package.** The
306+
> obvious probe — `npm dist-tag add … auth-probe` then `rm` — is **not reversible**: since
307+
> 2026-07-31 a bypass-2FA token may still *publish* but may not change package access,
308+
> so the `add` succeeds and the `rm` returns `403` ("Granular access tokens that bypass
309+
> two-factor authentication may not perform this action"). That strands a stray tag on a
310+
> public package needing an interactive-2FA session to clear. It was tried on the 0.24.0
311+
> cut; don't repeat it.
312+
>
313+
> Verify what can be verified read-only, and let the publish itself be the rest:
305314
> ```bash
306-
> npm dist-tag add @metaobjectsdev/cli@<current-latest> auth-probe # must SUCCEED
307-
> npm dist-tag rm @metaobjectsdev/cli auth-probe
315+
> node scripts/release-verify.mjs --preflight # auth + scope, no writes
308316
> ```
309-
> **Note the second command now FAILS with a bypass-2FA token** (`403`, "Granular access
310-
> tokens that bypass two-factor authentication may not perform this action"). Since
311-
> 2026-07-31 such tokens may still *publish* but may not change package access — including
312-
> **deleting a dist-tag**. So the probe above is only half-reversible: prefer probing on a
313-
> throwaway package, or accept that removing the tag needs an interactive-2FA session.
314-
> `scripts/release.mjs` now runs `npm whoami` in phase 0 so a dead credential is caught
315-
> BEFORE the version bump is committed, not after other registries have shipped.
317+
> `whoami` and `access list` are both READS, so neither proves write capability — the
318+
> preflight says so rather than implying otherwise. **The tier-0 publish is the real
319+
> probe**: `metadata` publishes first, so a wrong token fails there, before anything
320+
> depends on it. `scripts/release.mjs` also runs `npm whoami` in phase 0, so a dead
321+
> credential is caught BEFORE the version bump is committed rather than after other
322+
> registries have shipped irreversibly.
316323
317324
Revoking the token after each release is a deliberate trade — it is also why the credential
318325
is reliably dead at the start of the next cut. Whichever you choose, do it knowingly.

‎scripts/release-verify.mjs‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env node
2+
// Verify a coordinated release across ALL FOUR registries — and check that publishing is
3+
// possible BEFORE anything irreversible happens.
4+
//
5+
// node scripts/release-verify.mjs --preflight # can we publish at all?
6+
// node scripts/release-verify.mjs 0.24.0 # is 0.24.0 actually live, everywhere?
7+
// node scripts/release-verify.mjs 0.24.0 --smoke # ...plus a real external install
8+
//
9+
// WHY THIS EXISTS. The 0.24.0 cut was verified by a dozen throwaway shell scripts written
10+
// as it went, which is how two avoidable things happened: the auth check was improvised as
11+
// a dist-tag WRITE against a published package (leaving a stray tag a bypass-2FA token
12+
// cannot delete), and registry state was repeatedly re-derived by hand. Both are fixed
13+
// checks, so they belong in a committed script.
14+
//
15+
// NOTHING HERE MUTATES A PUBLISHED PACKAGE. Read-only against every registry. The
16+
// preflight answers "is the credential alive and is it the right TYPE" without writing.
17+
18+
import { execSync } from "node:child_process";
19+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
20+
import { join, dirname } from "node:path";
21+
import { homedir } from "node:os";
22+
23+
const args = process.argv.slice(2);
24+
const VERSION = args.find((a) => /^\d+\.\d+\.\d+$/.test(a));
25+
const PREFLIGHT = args.includes("--preflight");
26+
const SMOKE = args.includes("--smoke");
27+
const MVN = VERSION && `7.${VERSION.split(".").slice(1).join(".")}`;
28+
29+
const NPM_PKGS = [
30+
"metadata", "render", "codegen-ts", "runtime-ts", "migrate-ts", "sdk", "docs-site",
31+
"runtime-web", "codegen-ts-react", "codegen-ts-tanstack", "react", "tanstack",
32+
"cli", "ai-runtime",
33+
];
34+
const NUGET_PKGS = ["metaobjects", "metaobjects.render", "metaobjects.codegen", "metaobjects.cli"];
35+
const MVN_MODULES = ["metadata", "om", "omdb", "render", "codegen-spring", "codegen-kotlin",
36+
"metadata-ktx", "maven-plugin", "spring-boot-starter"];
37+
38+
let failures = 0;
39+
const g = (m) => console.log(` \x1b[32m✓\x1b[0m ${m}`);
40+
const r = (m) => { console.log(` \x1b[31m✗\x1b[0m ${m}`); failures++; };
41+
const w = (m) => console.log(` \x1b[33m!\x1b[0m ${m}`);
42+
const out = (c) => execSync(c, { encoding: "utf8" }).trim();
43+
const head = async (url) => (await fetch(url, { method: "HEAD" })).status;
44+
45+
// ── preflight: can we publish, and is the token the right TYPE? ────────────────────────
46+
// Read-only by design. A "Publish" token passes whoami AND `access list` (both reads) and
47+
// then EOTPs on the first write, so neither proves write capability — say so rather than
48+
// pretending, and never probe with a real write against a published package.
49+
async function preflight() {
50+
console.log("\n── preflight ──\n");
51+
try { g(`npm auth: ${out("npm whoami")}`); }
52+
catch {
53+
r("npm is NOT authenticated (`npm whoami` failed)");
54+
console.log(" npm config set //registry.npmjs.org/:_authToken=<token>");
55+
console.log(" Must be Classic → Automation (or granular w/ 2FA-bypass). A 'Publish'");
56+
console.log(" token passes every read check, then EOTPs on the first publish.");
57+
return;
58+
}
59+
try {
60+
const n = out("npm access list packages @metaobjectsdev").split("\n").filter(Boolean).length;
61+
g(`npm scope access: ${n} packages visible`);
62+
} catch { w("could not list scope access (token may lack the read grant)"); }
63+
w("token TYPE cannot be proven without a write. Do NOT probe with `npm dist-tag add` on a");
64+
w("published package — a bypass-2FA token can add a tag and CANNOT delete it (403 since");
65+
w("2026-07-31). The tier-0 publish is the real probe: it fails first, before anything");
66+
w("depends on it, and `release.mjs` publishes in tier order for exactly that reason.");
67+
}
68+
69+
// ── per-registry verification (all read-only) ──────────────────────────────────────────
70+
async function verifyNpm() {
71+
console.log(`\n── npm @ ${VERSION} ──\n`);
72+
let ok = 0;
73+
for (const p of NPM_PKGS) {
74+
const name = `@metaobjectsdev/${p}`;
75+
try {
76+
const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2f")}`);
77+
const j = await res.json();
78+
if (j.versions?.[VERSION]) ok++;
79+
else r(`${name} missing ${VERSION}`);
80+
} catch (e) { r(`${name}: ${e.message}`); }
81+
}
82+
if (ok === NPM_PKGS.length) g(`all ${ok}/${NPM_PKGS.length} packages at ${VERSION}`);
83+
84+
const tags = JSON.parse(out("npm view @metaobjectsdev/cli dist-tags --json"));
85+
tags.latest === VERSION ? g(`cli latest = ${VERSION}`) : r(`cli latest = ${tags.latest}`);
86+
// Stray tags are release residue: a stale `next` from an RC line, or a leftover probe.
87+
// Reported, never deleted here — deletion needs interactive 2FA (403 for bypass tokens).
88+
const stray = Object.keys(tags).filter((t) => t !== "latest");
89+
if (stray.length) w(`extra dist-tags on cli: ${stray.map((t) => `${t}=${tags[t]}`).join(", ")}`
90+
+ " → `npm dist-tag rm` needs an interactive-2FA session");
91+
}
92+
93+
async function verifyPypi() {
94+
console.log(`\n── PyPI @ ${VERSION} ──\n`);
95+
const s = await head(`https://pypi.org/pypi/metaobjects/${VERSION}/json`);
96+
s === 200 ? g(`metaobjects ${VERSION}`) : r(`metaobjects ${VERSION} → HTTP ${s}`);
97+
}
98+
99+
async function verifyNuget() {
100+
console.log(`\n── NuGet @ ${VERSION} ──\n`);
101+
for (const p of NUGET_PKGS) {
102+
const s = await head(`https://api.nuget.org/v3-flatcontainer/${p}/${VERSION}/${p}.${VERSION}.nupkg`);
103+
// nuget.org validation/indexing lags the push by minutes — a 404 shortly after a
104+
// publish is not yet a failure, so it warns rather than fails.
105+
s === 200 ? g(p) : w(`${p} → HTTP ${s} (indexing can lag ~10-30min after push)`);
106+
}
107+
}
108+
109+
async function verifyMaven() {
110+
console.log(`\n── Maven Central @ ${MVN} ──\n`);
111+
for (const m of MVN_MODULES) {
112+
const s = await head(
113+
`https://repo1.maven.org/maven2/com/metaobjects/metaobjects-${m}/${MVN}/metaobjects-${m}-${MVN}.pom`);
114+
s === 200 ? g(`metaobjects-${m}`) : r(`metaobjects-${m} → HTTP ${s}`);
115+
}
116+
}
117+
118+
// ── external install smoke ─────────────────────────────────────────────────────────────
119+
// NOT under /tmp: Node's resolution walks up from the scratch dir, so a stray
120+
// /tmp/node_modules shadows anything the throwaway project does not hoist. That produced a
121+
// false MISSING-export report during the 0.24.0 cut; the false GREEN is the worse half.
122+
function smoke() {
123+
console.log(`\n── external install smoke @ ${VERSION} ──\n`);
124+
const root = join(homedir(), ".cache", "metaobjects-release-smoke");
125+
rmSync(root, { recursive: true, force: true });
126+
mkdirSync(root, { recursive: true });
127+
for (let d = root; d !== dirname(d); d = dirname(d)) {
128+
if (existsSync(join(d, "node_modules")))
129+
return r(`${join(d, "node_modules")} would shadow the smoke — remove it and re-run`);
130+
}
131+
for (const mgr of ["npm", "pnpm"]) {
132+
const dir = join(root, mgr);
133+
mkdirSync(dir, { recursive: true });
134+
writeFileSync(join(dir, "package.json"),
135+
JSON.stringify({ name: "smoke", version: "1.0.0", type: "module", private: true }, null, 2));
136+
try {
137+
execSync(mgr === "npm"
138+
? `npm i @metaobjectsdev/cli@${VERSION} --prefer-online`
139+
: `pnpm add @metaobjectsdev/cli@${VERSION}`, { cwd: dir, stdio: "pipe" });
140+
const v = execSync("./node_modules/.bin/meta --version", { cwd: dir, encoding: "utf8" }).trim();
141+
v.includes(VERSION) ? g(`${mgr}: meta --version = ${v}`) : r(`${mgr}: reports ${v}`);
142+
for (const c of ["init", "gen", "docs", "upgrade", "verify"]) {
143+
try { execSync(`./node_modules/.bin/meta ${c}`, { cwd: dir, stdio: "pipe" }); g(`${mgr}: meta ${c}`); }
144+
catch { r(`${mgr}: meta ${c} failed`); }
145+
}
146+
} catch (e) { r(`${mgr}: install failed — ${String(e.message).split("\n")[0]}`); }
147+
}
148+
}
149+
150+
if (PREFLIGHT) await preflight();
151+
if (VERSION) {
152+
await verifyNpm(); await verifyPypi(); await verifyNuget(); await verifyMaven();
153+
if (SMOKE) smoke();
154+
}
155+
if (!PREFLIGHT && !VERSION) {
156+
console.error("usage: release-verify.mjs [--preflight] [<version>] [--smoke]");
157+
process.exit(2);
158+
}
159+
160+
console.log(failures
161+
? `\n\x1b[31m✗ ${failures} check(s) failed\x1b[0m\n`
162+
: `\n\x1b[32m✓ all checks passed\x1b[0m\n`);
163+
process.exit(failures ? 1 : 0);

0 commit comments

Comments
 (0)