diff --git a/tools/release.ts b/tools/release.ts index 43c868f..162654c 100644 --- a/tools/release.ts +++ b/tools/release.ts @@ -9,6 +9,8 @@ // /Applications; everything it made is deleted at the end // bun tools/release.ts --resume --yes after a stop past the push: HEAD is origin/main, tagged v, // 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 { 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 { 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 { + 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();