Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions scripts/preflight-npm-oidc.sh
Original file line number Diff line number Diff line change
@@ -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 <scope> [registry]
#
# Why this exists. npm checks Trusted Publishing **per package**, by exchanging a
# GitHub OIDC token at
#
# POST /-/npm/v1/oidc/token/exchange/package/<escaped-name>
#
# 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 <<EOF
preflight FAILED: ${#not_ready[@]} of ${#packages[@]} packages cannot publish via OIDC.

Fix each one at:
https://www.npmjs.com/package/<name>/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."
Loading