From cd655e8feaa8f6e646ff0af819af68792ede2e05 Mon Sep 17 00:00:00 2001 From: Joel Dickson Date: Thu, 6 Aug 2026 11:32:15 +0700 Subject: [PATCH 1/2] ci: add npm publish credential preflight to PR checks Adds a pull_request job that verifies NPM_TOKEN can actually publish every publishable workspace package before merge, so a wrong/expired/wrong-account token fails the PR instead of only surfacing as an E403 during the master changeset publish. The script checks npm whoami (token validity + identity) and asserts the identity has read-write access to each already-published package; unpublished package names pass with any valid token (first publish). Fork PRs without the secret skip cleanly. --- .github/scripts/npm-preflight.mjs | 100 ++++++++++++++++++++++++++++++ .github/workflows/checks.yml | 15 +++++ 2 files changed, 115 insertions(+) create mode 100644 .github/scripts/npm-preflight.mjs diff --git a/.github/scripts/npm-preflight.mjs b/.github/scripts/npm-preflight.mjs new file mode 100644 index 0000000..d923950 --- /dev/null +++ b/.github/scripts/npm-preflight.mjs @@ -0,0 +1,100 @@ +// Verifies that NPM_TOKEN can actually publish every publishable workspace +// package, so a broken/expired/wrong-account token fails the PR instead of +// only surfacing as an E403 during the master publish. +// +// Checks, for the token's identity: +// - the token is valid (npm whoami succeeds) +// - for each already-published package: identity has read-write access +// - for packages not yet on npm: a valid token is enough (first publish) + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const REGISTRY = "https://registry.npmjs.org"; +const token = process.env.NPM_TOKEN; + +if (!token) { + console.log( + "npm_token secret is not available (e.g. a fork PR). Skipping npm publish preflight.", + ); + process.exit(0); +} + +// Point npm at the registry with the token, without touching the repo tree. +fs.writeFileSync( + path.join(os.homedir(), ".npmrc"), + `//registry.npmjs.org/:_authToken=${token}\n`, +); + +const npm = (args) => + execFileSync("npm", [...args, "--registry", REGISTRY], { + encoding: "utf8", + }).trim(); + +let whoami; +try { + whoami = npm(["whoami"]); +} catch { + console.error( + "::error::npm_token is invalid or expired (npm whoami failed). Publish on master will 403.", + ); + process.exit(1); +} +console.log(`npm identity: ${whoami}`); + +let access = {}; +try { + access = JSON.parse(npm(["access", "list", "packages", "--json"])); +} catch { + // Non-fatal: some token types can't enumerate; we fall back to per-package existence. +} + +const publishable = fs + .readdirSync("packages") + .map((dir) => path.join("packages", dir, "package.json")) + .filter((file) => fs.existsSync(file)) + .map((file) => JSON.parse(fs.readFileSync(file, "utf8"))) + .filter((pkg) => pkg && !pkg.private && pkg.name) + .map((pkg) => pkg.name); + +const existsOnNpm = async (name) => { + const res = await fetch(`${REGISTRY}/${encodeURIComponent(name)}`, { + method: "HEAD", + }); + return res.status === 200; +}; + +let failed = false; +for (const name of publishable) { + const perm = access[name]; + if (perm === "read-write") { + console.log(`ok ${name}: read-write`); + continue; + } + if (perm) { + console.log(`FAIL ${name}: ${perm} (need read-write)`); + failed = true; + continue; + } + // Not in the token's access list: only a problem if the package already exists. + if (await existsOnNpm(name)) { + console.log( + `FAIL ${name}: '${whoami}' has no access (owned by someone else) — this is your E403`, + ); + failed = true; + } else { + console.log(`ok ${name}: not yet on npm, valid token can first-publish`); + } +} + +if (failed) { + console.error( + `\n::error::npm token '${whoami}' cannot publish one or more packages. ` + + `Fix the token account/ownership before merging to master.`, + ); + process.exit(1); +} + +console.log("\nAll publishable packages are writable by this token."); diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e97b22b..e7b43b8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -51,3 +51,18 @@ jobs: cache-key: ci-types - name: types run: pnpm check-types + + npm-publish-preflight: + name: npm-publish-preflight + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + - name: node + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: verify npm publish credentials + env: + NPM_TOKEN: ${{ secrets.npm_token }} + run: node .github/scripts/npm-preflight.mjs From db562617d18dcac048fab44d71bd0c71c0d087d9 Mon Sep 17 00:00:00 2001 From: Joel Dickson Date: Fri, 7 Aug 2026 12:42:16 +0700 Subject: [PATCH 2/2] ci: fix npm preflight for user-owned packages npm access list packages only works for org accounts; for a personal account it queries /-/org//package and 403s, which made the preflight falsely report 'no access' for every package even when the token identity was the correct sole owner. Switch to a per-package 'npm access list collaborators --json' check, which works for both user- and org-owned packages. Not-yet- published names still pass as first-publish. --- .github/scripts/npm-preflight.mjs | 48 +++++++++++++++++-------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/.github/scripts/npm-preflight.mjs b/.github/scripts/npm-preflight.mjs index d923950..7ecc460 100644 --- a/.github/scripts/npm-preflight.mjs +++ b/.github/scripts/npm-preflight.mjs @@ -44,13 +44,6 @@ try { } console.log(`npm identity: ${whoami}`); -let access = {}; -try { - access = JSON.parse(npm(["access", "list", "packages", "--json"])); -} catch { - // Non-fatal: some token types can't enumerate; we fall back to per-package existence. -} - const publishable = fs .readdirSync("packages") .map((dir) => path.join("packages", dir, "package.json")) @@ -66,26 +59,39 @@ const existsOnNpm = async (name) => { return res.status === 200; }; +// Per-package collaborator check. Unlike `npm access list packages` (which only +// works for org accounts and 403s for a personal account), this works whether +// the packages are owned by a user or an org. let failed = false; for (const name of publishable) { - const perm = access[name]; - if (perm === "read-write") { - console.log(`ok ${name}: read-write`); - continue; - } - if (perm) { - console.log(`FAIL ${name}: ${perm} (need read-write)`); - failed = true; + let collaborators; + try { + collaborators = JSON.parse( + npm(["access", "list", "collaborators", name, "--json"]), + ); + } catch { + // Couldn't read collaborators: fine if the package doesn't exist yet + // (first publish), otherwise the token genuinely can't see/access it. + if (await existsOnNpm(name)) { + console.log( + `FAIL ${name}: '${whoami}' cannot read collaborators (token lacks access)`, + ); + failed = true; + } else { + console.log(`ok ${name}: not yet on npm, valid token can first-publish`); + } continue; } - // Not in the token's access list: only a problem if the package already exists. - if (await existsOnNpm(name)) { - console.log( - `FAIL ${name}: '${whoami}' has no access (owned by someone else) — this is your E403`, - ); + + const perm = collaborators[whoami]; + if (perm && perm.includes("write")) { + console.log(`ok ${name}: ${whoami} has ${perm}`); + } else if (perm) { + console.log(`FAIL ${name}: ${whoami} has ${perm} (need read-write)`); failed = true; } else { - console.log(`ok ${name}: not yet on npm, valid token can first-publish`); + console.log(`FAIL ${name}: ${whoami} is not a collaborator`); + failed = true; } }