|
| 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