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."