From a06e987df1b135b884ce6a6562beb1bd44e64ed8 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 26 Jul 2026 21:56:44 -0400 Subject: [PATCH] fix(ci): pass NPM_TOKEN to the publish steps, not just NODE_AUTH_TOKEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.5.0 release failed at the credential check with `401 Unauthorized` from `npm whoami`. That reads as a revoked or mistyped token. It was neither — the token is fine and the workflow was wrong. This repository's `.npmrc` reads `//registry.npmjs.org/:_authToken=${NPM_TOKEN}`, and npm's project config outranks the userconfig that `setup-node` writes. So for any command run from the repository root the repo `.npmrc` wins, and it wants NPM_TOKEN — a name the workflow never set, because it set only NODE_AUTH_TOKEN. Critically, npm does not fall back to the userconfig when the variable is missing. It sends the literal, unexpanded text as the bearer token. Confirmed against a local registry that echoes the Authorization header: NPM_TOKEN unset -> AUTH_HEADER: "Bearer ${NPM_TOKEN}" NPM_TOKEN set -> AUTH_HEADER: "Bearer REAL_TOKEN_VALUE" The first is indistinguishable, from the registry's side, from a garbage credential — hence the 401 and the misleading diagnosis. Both publish steps now set NPM_TOKEN alongside NODE_AUTH_TOKEN. They stay step-scoped rather than job-scoped so the token is not exposed to `pnpm -r test`. verify-npm-publish-access.mjs now checks NPM_TOKEN is present and expanded before calling the registry, so a recurrence names the cause instead of surfacing an authentication error that sends you looking at the token. Verified: both guard paths exit 1 with an accurate message, architecture guard passes, workflow YAML parses with the trigger intact. --- .github/workflows/publish-cli.yml | 17 +++++++++++++++++ scripts/verify-npm-publish-access.mjs | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index acafc1d..9422a51 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -99,9 +99,25 @@ jobs: - name: Verify the release tarball run: pnpm --filter @workspacejson/cli exec node ../../scripts/verify-package-tarball.mjs + # Both variables carry the same secret, because two different config + # layers each want their own name and the wrong one alone is silently + # useless: + # + # NODE_AUTH_TOKEN — the userconfig `setup-node` writes to $RUNNER_TEMP. + # NPM_TOKEN — the repository's own `.npmrc`, which reads + # `//registry.npmjs.org/:_authToken=${NPM_TOKEN}`. + # + # npm's project config outranks the userconfig, so the repo `.npmrc` wins + # for any command run from the repository root. With NPM_TOKEN unset it + # does not fall back to the userconfig token — it sends the *literal* + # string `Bearer ${NPM_TOKEN}`, which the registry rejects as 401. That is + # exactly how the 0.5.0 release failed, and it reads as a bad credential + # rather than a misconfigured workflow, which is what made it worth a + # comment this long. - name: Confirm the publish credential reached the runner env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: node scripts/verify-npm-publish-access.mjs # npm rather than pnpm, for two reasons: pnpm 9 exposes no --provenance @@ -119,6 +135,7 @@ jobs: working-directory: packages/cli env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --provenance --access public # A publish that npm accepts can still be unusable — wrong files, broken diff --git a/scripts/verify-npm-publish-access.mjs b/scripts/verify-npm-publish-access.mjs index eafca51..8e00dd6 100644 --- a/scripts/verify-npm-publish-access.mjs +++ b/scripts/verify-npm-publish-access.mjs @@ -2,6 +2,22 @@ import { spawnSync } from "node:child_process"; +// The repository `.npmrc` reads `//registry.npmjs.org/:_authToken=${NPM_TOKEN}`, +// and npm's project config outranks the userconfig `setup-node` writes. If +// NPM_TOKEN is missing npm does not fall back — it sends the literal string +// `Bearer ${NPM_TOKEN}` and the registry answers 401, which looks identical to a +// revoked or mistyped token. Checking the variable first turns half an hour of +// suspecting the credential into one accurate line. +const token = process.env.NPM_TOKEN; +if (!token || token.startsWith("${")) { + console.error( + token + ? `NPM_TOKEN is present but unexpanded (${JSON.stringify(token)}). The repository .npmrc interpolates it, so npm would send that literal text as the bearer token and the registry would reject it as 401.` + : "NPM_TOKEN is not set. The repository .npmrc interpolates it, so npm would send the literal text \"${NPM_TOKEN}\" as the bearer token and the registry would reject it as 401 — which reads as a bad credential rather than a missing one.", + ); + process.exit(1); +} + const username = JSON.parse(run("npm", ["whoami", "--json"])); // `npm access` reports package visibility or package enumeration; it does not