From 13cf80cd6da9e123669f0eaa93f1fde88f56217a Mon Sep 17 00:00:00 2001 From: Ajay Bhargava Date: Mon, 27 Jul 2026 22:46:09 +0000 Subject: [PATCH] ci(npm): preflight Trusted Publishing before building or publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consecutive release attempts failed with a bare: npm error 404 Not Found - PUT https://registry.npmjs.org/@planmonster%2folk-darwin-arm64 The 404 masks an authorization failure. From npm's lib/utils/oidc.js, Trusted Publishing is checked *per package* by exchanging a GitHub OIDC token at POST /-/npm/v1/oidc/token/exchange/package/. A failed exchange is swallowed — logged at verbose level, then `return undefined` — after which npm falls back to whatever _authToken is configured. actions/setup-node writes the placeholder XXXXX-XXXXX-XXXXX-XXXXX there, so the registry rejects the PUT and the operator sees a 404 with no cause. Three problems, all addressed: 1. The failure was undiagnosable. NPM_CONFIG_LOGLEVEL=verbose on the publish step surfaces npm's own OIDC message. 2. The failure came late and could leave a partial release. The packages publish one at a time, so package 4 of 7 failing would strand a version that is immutable and can never be reused. A new `preflight` job exchanges a token for all seven names up front and reports exactly which are not ready, including the registry's message and the exact fields to fix. The build jobs now depend on it, so a misconfiguration costs ~20 s instead of ~6 min of Blacksmith and macOS time. 3. `always-auth: true` is not a valid input for actions/setup-node v6 and was emitting "Unexpected input(s)". Removed. The preflight job carries no job-level `if`: the build jobs depend on it, and a skipped dependency skips its dependents, which would have broken dry runs. The check is gated per step so a dry run leaves the job green. --- .github/workflows/publish-npm.yml | 41 +++++++++-- scripts/preflight-npm-oidc.sh | 114 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 scripts/preflight-npm-oidc.sh diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 65fbade..e35092a 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -65,9 +65,39 @@ jobs: echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT" echo "publishing $version (dry_run=$dry_run)" >> "$GITHUB_STEP_SUMMARY" + # Verify Trusted Publishing works for all seven packages BEFORE building + # anything. npm checks OIDC per package and swallows a failed exchange, so a + # missing publisher surfaces as a bare "404 Not Found - PUT" halfway through + # the release — after ~6 min of builds, and with some packages already + # published at a version that can never be reused. + preflight: + name: preflight (npm OIDC) + needs: version + # No job-level `if`: the build jobs depend on this one, and a *skipped* + # dependency skips them too. The check itself is gated per step instead, so + # a dry run still leaves this job green. + # + # Must be GitHub-hosted: npm Trusted Publishing rejects self-hosted OIDC. + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Check every package can publish via OIDC + if: ${{ needs.version.outputs.dry_run != 'true' }} + run: bash scripts/preflight-npm-oidc.sh "$NPM_SCOPE" "$NPM_REGISTRY" + + - name: Skipped (dry run publishes nothing) + if: ${{ needs.version.outputs.dry_run == 'true' }} + run: echo "dry run - no OIDC preflight needed" + build-linux-windows: name: build linux + windows - needs: version + needs: [version, preflight] runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 30 steps: @@ -109,7 +139,7 @@ jobs: build-darwin: name: build darwin - needs: version + needs: [version, preflight] # Must stay on a GitHub-hosted macOS runner: the keyring backend needs # CGO against the macOS Keychain, and Blacksmith supplies no macOS runners. runs-on: macos-latest @@ -151,7 +181,7 @@ jobs: publish: name: publish to npm - needs: [version, build-linux-windows, build-darwin] + needs: [version, preflight, build-linux-windows, build-darwin] # Must stay on a GitHub-hosted runner. npm Trusted Publishing accepts # cloud-hosted runners only; a Blacksmith runner presents itself as # self-hosted in the OIDC claims and npm rejects it. This job only runs @@ -171,7 +201,6 @@ jobs: # Trusted Publishing needs Node >= 22.14 and npm >= 11.5.1. node-version: "24" registry-url: ${{ env.NPM_REGISTRY }} - always-auth: true - name: Upgrade npm run: npm install -g npm@latest @@ -236,6 +265,10 @@ jobs: if: ${{ needs.version.outputs.dry_run != 'true' }} env: VERSION: ${{ needs.version.outputs.version }} + # npm logs a failed OIDC token exchange at verbose level and then + # silently falls back to the placeholder _authToken that setup-node + # writes, which the registry rejects as a 404. Surface the real cause. + NPM_CONFIG_LOGLEVEL: verbose run: | set -euo pipefail # build-npm.mjs publishes the six binary packages first and the diff --git a/scripts/preflight-npm-oidc.sh b/scripts/preflight-npm-oidc.sh new file mode 100644 index 0000000..eaa0c09 --- /dev/null +++ b/scripts/preflight-npm-oidc.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Preflight: verify npm Trusted Publishing works for every package we are about +# to publish, before publishing any of them. +# +# bash scripts/preflight-npm-oidc.sh [registry] +# +# Why this exists. npm checks Trusted Publishing **per package**, by exchanging a +# GitHub OIDC token at +# +# POST /-/npm/v1/oidc/token/exchange/package/ +# +# and npm's oidc.js swallows a failed exchange: it logs at `verbose` level and +# returns, then `npm publish` falls back to whatever `_authToken` is configured. +# actions/setup-node writes the placeholder XXXXX-XXXXX-XXXXX-XXXXX there, so the +# registry answers: +# +# npm error 404 Not Found - PUT https://registry.npmjs.org/@scope%2fpkg +# +# A 404 that actually means "no trusted publisher for this package". Worse, the +# packages publish one at a time, so package 4 of 7 failing leaves a half-released +# version — and npm versions are immutable, so that cannot be repaired in place. +# +# This script exchanges a token for all seven names up front and reports exactly +# which ones are not ready. +set -euo pipefail + +scope="${1:-@planmonster}" +registry="${2:-https://registry.npmjs.org}" +scope="${scope#@}" + +if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "preflight: no GitHub OIDC token available." >&2 + echo " This must run in GitHub Actions with 'permissions: id-token: write'." >&2 + exit 2 +fi + +host="$(printf '%s' "$registry" | sed -E 's#^https?://##; s#/.*$##')" +audience="npm:${host}" + +echo "registry: $registry" +echo "audience: $audience" +echo "scope: @$scope" +echo + +# Request the ID token once; the audience is per-registry, not per-package. +id_token="$(curl -sS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + -H "Accept: application/json" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${audience}" | + node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);if(!j.value){console.error("no id_token in response");process.exit(1)}process.stdout.write(j.value)})')" + +if [ -z "$id_token" ]; then + echo "preflight: could not obtain a GitHub ID token" >&2 + exit 1 +fi +echo "obtained GitHub ID token (${#id_token} chars)" +echo + +packages=( + "olkcli" + "olk-darwin-arm64" + "olk-darwin-x64" + "olk-linux-arm64" + "olk-linux-x64" + "olk-win32-arm64" + "olk-win32-x64" +) + +not_ready=() +for p in "${packages[@]}"; do + full="@${scope}/${p}" + escaped="@${scope}%2f${p}" + body="$(mktemp)" + code="$(curl -sS -o "$body" -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${id_token}" \ + -H "Accept: application/json" \ + -H "Content-Length: 0" \ + "${registry}/-/npm/v1/oidc/token/exchange/package/${escaped}")" + + if [ "$code" = "200" ] && grep -q '"token"' "$body"; then + printf ' %-34s READY\n' "$full" + else + msg="$(node -e ' + const fs=require("fs"); + try{const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));process.stdout.write(j.message||j.error||JSON.stringify(j).slice(0,200))} + catch{process.stdout.write(fs.readFileSync(process.argv[1],"utf8").slice(0,200))} + ' "$body" 2>/dev/null || echo "unreadable response")" + printf ' %-34s NOT READY (http %s) %s\n' "$full" "$code" "$msg" + not_ready+=("$full") + fi + rm -f "$body" +done + +echo +if [ "${#not_ready[@]}" -gt 0 ]; then + cat >&2 </access + + Trusted Publisher -> GitHub Actions + Organization or user: ${GITHUB_REPOSITORY%%/*} + Repository: ${GITHUB_REPOSITORY##*/} + Workflow filename: publish-npm.yml + Environment: (leave empty) + +Not ready: +EOF + printf ' %s\n' "${not_ready[@]}" >&2 + exit 1 +fi + +echo "preflight OK: all ${#packages[@]} packages can publish via OIDC."