From 2a6a109e56ed639ee7c1679e942ab47540d7b446 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 13 May 2026 16:47:35 +0200 Subject: [PATCH 1/2] fix(actions): treat setup-node's NODE_AUTH_TOKEN placeholder as unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `actions/setup-node@v6` with `registry-url:` configured unconditionally exports `NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX` as a literal placeholder so its `.npmrc` template `_authToken=${NODE_AUTH_TOKEN}` expands to a non-empty (but useless) string. The previous guard treated that placeholder as a real token and refused to run, breaking every standard setup-node + publish flow — the placeholder is what the stdnum dispatch hit on attempt #1. Unset NODE_AUTH_TOKEN if and only if it matches the placeholder. Real legacy tokens still trip the refuse-and-exit branch. Surfaced by the stdnum#95 release dispatch against v0.0.1. --- .github/actions/npm-publish-hardened/publish.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/actions/npm-publish-hardened/publish.sh b/.github/actions/npm-publish-hardened/publish.sh index 637be86..d5c5fa6 100755 --- a/.github/actions/npm-publish-hardened/publish.sh +++ b/.github/actions/npm-publish-hardened/publish.sh @@ -4,9 +4,21 @@ set -euo pipefail +# `actions/setup-node@v6` with `registry-url:` exports +# NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX as a literal placeholder, so +# its `.npmrc` template `_authToken=${NODE_AUTH_TOKEN}` expands to a +# non-empty (but useless) string. Treat that placeholder as if the +# variable were unset — otherwise this guard refuses every standard +# setup-node + publish flow. +SETUP_NODE_PLACEHOLDER='XXXXX-XXXXX-XXXXX-XXXXX' +if [[ "${NODE_AUTH_TOKEN:-}" == "${SETUP_NODE_PLACEHOLDER}" ]]; then + unset NODE_AUTH_TOKEN +fi + # Defence in depth: trusted publishing performs auth via the OIDC token -# exchange. If a legacy token is in env, the publish below would silently -# fall back to bearer auth and the whole point of this action is lost. +# exchange. If a real legacy token is in env, the publish below would +# silently fall back to bearer auth and the whole point of this action +# is lost. if [[ -n "${NPM_TOKEN:-}" || -n "${NODE_AUTH_TOKEN:-}" ]]; then # Workflow commands (::error::) must be written to stdout to be picked # up by the runner's annotation processor; >&2 suppresses the UI From b981922c8c9e821b8f3671d878e092313239dd2c Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 13 May 2026 16:53:02 +0200 Subject: [PATCH 2/2] fix(actions): set placeholder NODE_AUTH_TOKEN to empty string, not unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex caught a real subtlety on the previous fix attempt. npm config does env-var expansion when reading .npmrc, and an unset NODE_AUTH_TOKEN can let npm pass the literal `${NODE_AUTH_TOKEN}` syntax through to the Authorization header instead of treating it as absent — which defeats OIDC and risks the placeholder-token bearer auth that this action exists to prevent. Set NODE_AUTH_TOKEN to an empty string instead. `.npmrc`'s `_authToken=${NODE_AUTH_TOKEN}` expands cleanly to `_authToken=` (no auth), and npm's OIDC trusted-publishing path takes over via the ACTIONS_ID_TOKEN_REQUEST_* env vars. Also adopts gemini's suggestion to declare the placeholder constant as `readonly`. Addresses bot reviews on PR #27. --- .../actions/npm-publish-hardened/publish.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/actions/npm-publish-hardened/publish.sh b/.github/actions/npm-publish-hardened/publish.sh index d5c5fa6..4497f46 100755 --- a/.github/actions/npm-publish-hardened/publish.sh +++ b/.github/actions/npm-publish-hardened/publish.sh @@ -7,12 +7,16 @@ set -euo pipefail # `actions/setup-node@v6` with `registry-url:` exports # NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX as a literal placeholder, so # its `.npmrc` template `_authToken=${NODE_AUTH_TOKEN}` expands to a -# non-empty (but useless) string. Treat that placeholder as if the -# variable were unset — otherwise this guard refuses every standard -# setup-node + publish flow. -SETUP_NODE_PLACEHOLDER='XXXXX-XXXXX-XXXXX-XXXXX' +# non-empty (but useless) string. We must neutralise the placeholder +# WITHOUT leaving the variable unset — npm config does env-var +# expansion when reading .npmrc, and an unset variable can leak the +# literal `${NODE_AUTH_TOKEN}` syntax into the Authorization header +# instead of being treated as absent. Setting it to an empty string +# expands cleanly to `_authToken=` (no auth) and lets npm's OIDC +# trusted-publishing path take over. +readonly SETUP_NODE_PLACEHOLDER='XXXXX-XXXXX-XXXXX-XXXXX' if [[ "${NODE_AUTH_TOKEN:-}" == "${SETUP_NODE_PLACEHOLDER}" ]]; then - unset NODE_AUTH_TOKEN + export NODE_AUTH_TOKEN='' fi # Defence in depth: trusted publishing performs auth via the OIDC token @@ -23,8 +27,8 @@ if [[ -n "${NPM_TOKEN:-}" || -n "${NODE_AUTH_TOKEN:-}" ]]; then # Workflow commands (::error::) must be written to stdout to be picked # up by the runner's annotation processor; >&2 suppresses the UI # annotation. This rule applies to every ::error:: line below as well. - printf '::error::NPM_TOKEN/NODE_AUTH_TOKEN must not be set when using %s\n' \ - "the hardened publish action — trusted publishing only." + printf '::error::NPM_TOKEN/NODE_AUTH_TOKEN must not be set to a real %s\n' \ + "value when using the hardened publish action — trusted publishing only." exit 2 fi