Skip to content

Commit 5df51c4

Browse files
dmealingclaude
andcommitted
fix(release): preflight npm auth, and keep the post-publish smoke out of /tmp
Two gaps the 0.24.0 cut walked into, both fixed where the release script already refuses things. AUTH IS NOW A PREFLIGHT. `release.mjs` checked the tree, the tag and the target version, but never that publishing was POSSIBLE. The version bump is committed and pushed long before the first `bun publish`, so a dead credential strands the release mid-flight — which is exactly what happened: BOTH npm paths were dead at once (the local token had been revoked, per RELEASING.md's own advice to revoke after each release, and the NPM_TOKEN secret was empty), discovered only after PyPI and NuGet had shipped 0.24.0 irreversibly, leaving the registries out of lockstep pending a human. `npm whoami` now runs in phase 0. The message names the trap that cost the most time: a "Publish" token authenticates and reads fine, then returns EOTP on every write, and `bun publish` falls back to an interactive browser flow that cannot complete non-interactively. It must be an Automation / bypass-2FA token. THE SMOKE TEST NO LONGER RUNS UNDER tmpdir(). Node's resolution walks UP from the scratch dir, so a stale /tmp/node_modules shadows anything the throwaway project does not hoist itself. The release box held a 0.21.5 install there and it made a real external smoke report a MISSING export that had in fact shipped correctly. It fails BOTH ways, and the false green is the dangerous one: a genuinely absent export resolves against the stale copy and the gate passes. The smoke now runs under ~/.cache and REFUSES to start if any ancestor holds a node_modules, rather than silently measuring the wrong tree. Both guards were proved by creating the condition they exist for: `npm whoami` exits non-zero for a missing token (ENEEDAUTH) and a dead one, and the ancestor walk is silent on a clean tree and names the directory when one is planted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent d46221c commit 5df51c4

1 file changed

Lines changed: 41 additions & 4 deletions

File tree

‎scripts/release.mjs‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,28 @@
1717
// refuses an already-taken version/tag.
1818

1919
import { execSync } from "node:child_process";
20-
import { readFileSync, writeFileSync, mkdtempSync } from "node:fs";
21-
import { join } from "node:path";
22-
import { tmpdir } from "node:os";
20+
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, existsSync } from "node:fs";
21+
import { join, dirname } from "node:path";
22+
import { tmpdir, homedir } from "node:os";
2323
import { createInterface } from "node:readline/promises";
2424
import { publishSet } from "./publish-set.mjs";
2525

2626
const VERSION = process.argv[2];
2727
const DRY = process.argv.includes("--dry-run");
2828
const YES = process.argv.includes("--yes");
2929

30+
// A scratch root for the post-publish smoke that no stray node_modules can shadow.
31+
// See PHASE 11. Kept beside the other helpers so the reason travels with the call.
32+
const smokeRoot = () => {
33+
const root = join(homedir(), ".cache", "metaobjects-release-smoke");
34+
mkdirSync(root, { recursive: true });
35+
for (let d = root; d !== dirname(d); d = dirname(d)) {
36+
if (existsSync(join(d, "node_modules")))
37+
die(`${join(d, "node_modules")} would shadow the smoke test — remove it and re-run`);
38+
}
39+
return root;
40+
};
41+
3042
const sh = (cmd, o = {}) => execSync(cmd, { encoding: "utf8", stdio: o.quiet ? "pipe" : "inherit", ...o });
3143
const out = (cmd) => execSync(cmd, { encoding: "utf8" }).trim();
3244
const ok = (m) => console.log(`\x1b[32m✓\x1b[0m ${m}`);
@@ -51,6 +63,25 @@ if (dirty.length) die(`uncommitted changes:\n${dirty.join("\n")}\n(commit or sta
5163

5264
if (out(`git tag -l v${VERSION}`)) die(`tag v${VERSION} already exists`);
5365

66+
// Can we publish AT ALL? Checked here, before the version bump, because the bump is
67+
// committed and pushed long before the first `bun publish` — so discovering a dead
68+
// credential at the publish step strands the release mid-flight. The 0.24.0 cut found
69+
// BOTH npm paths dead at once (the local token had been revoked per this doc's own
70+
// advice, and the NPM_TOKEN secret was empty), after PyPI and NuGet had already shipped
71+
// irreversibly. Note npm answers 404, not 401, for an unauthorized scoped package, so
72+
// the raw failure reads like "the package does not exist".
73+
try {
74+
const who = out("npm whoami").trim();
75+
ok(`npm auth: ${who}`);
76+
} catch {
77+
die("npm is not authenticated (`npm whoami` failed) — publishing would fail after the\n" +
78+
" version bump is already committed. Set a token that BYPASSES 2FA:\n" +
79+
" npm config set //registry.npmjs.org/:_authToken=<token>\n" +
80+
" It must be an Automation / bypass-2FA token. A 'Publish' token authenticates and\n" +
81+
" reads fine, then returns EOTP on every write, and `bun publish` falls back to an\n" +
82+
" interactive browser flow that cannot complete in CI or a non-interactive shell.");
83+
}
84+
5485
// The lockstep set = every non-private package at the CURRENT version (cli's version),
5586
// in tier order. Derived by scripts/publish-set.mjs, which .github/workflows/publish-npm.yml
5687
// also reads — the two publish paths must never disagree about which packages ship (they
@@ -149,7 +180,13 @@ sh(`git tag v${VERSION} && git push origin v${VERSION}`, { quiet: true });
149180
ok(`pushed main + tag v${VERSION}`);
150181

151182
// --- PHASE 11: post-publish smoke (the RC's safety net) -------------------
152-
const sm = mkdtempSync(join(tmpdir(), "rel-smoke-"));
183+
// NOT under tmpdir(). Node's resolution walks UP from the scratch dir, so a stale
184+
// /tmp/node_modules shadows anything the throwaway project does not hoist itself. That
185+
// is not hypothetical: /tmp/node_modules on the release box held a 0.24.0-era smoke's
186+
// 0.21.5 install and made a real external smoke report a MISSING export that shipped
187+
// fine. It fails both ways — the false GREEN is the dangerous one, since a genuinely
188+
// absent export resolves against the stale copy and the gate passes.
189+
const sm = mkdtempSync(join(smokeRoot(), "rel-smoke-"));
153190
sh(`cd ${sm} && npm init -y`, { quiet: true });
154191
sh(`cd ${sm} && npm i @metaobjectsdev/cli --prefer-online`, { quiet: true });
155192
const v = out(`cd ${sm} && ./node_modules/.bin/meta --version`);

0 commit comments

Comments
 (0)