release: npm publish + deprecate run by themselves with a granular access token from ~/.storebound-keys/npm-token (0600; npm reads it through a temporary userconfig, never argv, logs or the checkout); bun tools/release.ts npm-token saves it once from the clipboard, npm-publish publishes this checkout's version

This commit is contained in:
CRTkafa 2026-10-15 20:13:42 +08:00
parent 9d41fcdc50
commit ead5da4ab3

View file

@ -9,6 +9,8 @@
// /Applications; everything it made is deleted at the end
// bun tools/release.ts <version> --resume --yes after a stop past the push: HEAD is origin/main, tagged v<version>,
// with that version; skips the bump and continues from step 4
// bun tools/release.ts npm-token once: the npm granular token from the clipboard to ~/.storebound-keys
// bun tools/release.ts npm-publish npm publish + deprecate of this checkout's version with that token
// bun tools/release.ts mac-acceptance the macOS acceptance runs for this checkout's CLI key (cached)
//
// Without --yes: the checks and the plan only. With --yes, in order:
@ -227,6 +229,8 @@ async function main(): Promise<void> {
const argv = process.argv.slice(2);
const has = (f: string) => argv.includes(f);
const yes = has('--yes');
if (argv[0] === 'npm-token') { npmTokenSetup(); return; }
if (argv[0] === 'npm-publish') { const t = npmToken() ?? fail(`no npm token: bun tools/release.ts npm-token`); await npmRelease(COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!, t); return; }
if (argv[0] === 'mac-acceptance') { const key = cliKey(ROOT); console.log(await macAcceptance(key, join(RELEASES, 'logs', `acceptance-${key}`))); return; }
const rehearsal = has('--rehearsal');
const resume = has('--resume');
@ -372,10 +376,68 @@ async function main(): Promise<void> {
writeFileSync(tf, `${JSON.stringify({ version, sha, tPush, tPublished, phases }, null, 2)}\n`);
console.log(`\nstorebound ${version}: ${rehearsal ? 'rehearsal done and cleaned up' : 'released'}. Times (from the tag push):\n${table}\n start of this command to tag push: ${Math.round((tPush - t0) / 1000)} s; timings in ${tf.replace(HOME, '~')}`);
if (!rehearsal) {
console.log(`\nThe owner's step (npm), from this checkout at ${tag}:
const npm = npmToken();
if (npm) await phase('npm publish + deprecate (token)', async () => npmRelease(version, npm));
else console.log(`\nnpm (no token in ${NPM_TOKEN_FILE.replace(HOME, '~')}: \`bun tools/release.ts npm-token\` sets it up once), from this checkout at ${tag}:
npm publish
npm deprecate storebound@"<${version}" "storebound ${version} is out: npx storebound@${version}"`);
}
}
// ---------------------------------------------------------------------------------------------------------------------
// npm with a granular access token (the owner's decision 2026-10-15: releases publish to npm by themselves). The token
// lives only in ~/.storebound-keys/npm-token (0600); npm reads it from the environment through a temporary userconfig
// that names ${NPM_TOKEN}, so it is never written into the checkout, a log or argv.
const NPM_TOKEN_FILE = join(KEYS, 'npm-token');
function npmToken(): string | null {
const v = process.env.NPM_TOKEN ?? (existsSync(NPM_TOKEN_FILE) ? readFileSync(NPM_TOKEN_FILE, 'utf8') : '');
const t = v.trim();
return /^npm_[A-Za-z0-9]{20,}$/.test(t) ? t : null;
}
function npmRun(args: string[], token: string): { code: number; out: string } {
const dir = mkdtempSync(join(tmpdir(), 'sb-npm-'));
try {
const rc = join(dir, 'npmrc');
writeFileSync(rc, '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n', { mode: 0o600 });
const r = spawnSync('npm', args, { cwd: ROOT, encoding: 'utf8', timeout: 10 * 60_000, env: { ...process.env, NPM_TOKEN: token, NPM_CONFIG_USERCONFIG: rc } });
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`.split(token).join('npm_***');
return { code: r.status ?? 1, out };
} finally { rmSync(dir, { recursive: true, force: true }); }
}
async function npmRelease(version: string, token: string): Promise<void> {
const who = npmRun(['whoami'], token);
if (who.code !== 0) fail(`npm token refused (npm whoami): ${who.out.trim().split('\n').slice(-2).join(' ')}; make a new one: bun tools/release.ts npm-token`);
const pub = npmRun(['publish'], token);
if (pub.code !== 0 && !/previously published|cannot publish over/i.test(pub.out)) fail(`npm publish failed: ${pub.out.trim().split('\n').slice(-4).join(' ')}`);
// the registry lags a little after a publish; wait (timeout, heartbeat) until it serves the version as latest
const end = Date.now() + 5 * 60_000;
let last = 0;
for (;;) {
const r = spawnSync('npm', ['view', 'storebound', 'dist-tags.latest'], { encoding: 'utf8', timeout: 30_000 });
if ((r.stdout ?? '').trim() === version) break;
if (Date.now() > end) fail(`npm: ${version} published but the registry still serves ${(r.stdout ?? '').trim()} as latest after 5 min`);
if (Date.now() - last > HEARTBEAT_MS) { last = Date.now(); say(`npm: waiting for the registry to serve ${version}`); }
await new Promise(res => setTimeout(res, 5_000));
}
const dep = npmRun(['deprecate', `storebound@<${version}`, `storebound ${version} is out: npx storebound@${version}`], token);
if (dep.code !== 0) fail(`npm deprecate failed: ${dep.out.trim().split('\n').slice(-3).join(' ')}`);
say(`npm: storebound@${version} published and older versions deprecated`);
}
/** One-time setup: the token from the clipboard (macOS pbpaste) into ~/.storebound-keys/npm-token, never printed. */
function npmTokenSetup(): void {
const r = spawnSync('pbpaste', [], { encoding: 'utf8' });
const t = (r.stdout ?? '').trim();
if (!/^npm_[A-Za-z0-9]{20,}$/.test(t)) fail('the clipboard holds no npm token (npm_...): copy the token on npmjs.com first, then run this again');
mkdirSync(KEYS, { recursive: true });
writeFileSync(NPM_TOKEN_FILE, `${t}\n`, { mode: 0o600 });
spawnSync('pbcopy', [], { input: '' });
const who = npmRun(['whoami'], t);
if (who.code !== 0) fail(`saved, but npm refuses it: ${who.out.trim().split('\n').slice(-2).join(' ')}`);
console.log(`npm token saved to ${NPM_TOKEN_FILE.replace(HOME, '~')} (0600; clipboard cleared); npm account: ${who.out.trim()}`);
}
if (import.meta.main) await main();