From 658f0c5d2d8e8f1d4cc33186e58ec1bc97705777 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 2 Apr 2026 20:23:56 +0100 Subject: [PATCH] ci: use oidc token for npm promotion --- .../openclaw-release-maintainer/SKILL.md | 3 + .../workflows/openclaw-npm-promote-beta.yml | 6 +- docs/reference/RELEASING.md | 4 ++ scripts/npm-oidc-exchange-token.mjs | 68 +++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 scripts/npm-oidc-exchange-token.mjs diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index 283ac936ec7..eae99eddee4 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -116,6 +116,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts ## Use the right auth flow - OpenClaw publish uses GitHub trusted publishing. +- Stable npm promotion from `beta` to `latest` also uses GitHub Actions OIDC by + exchanging the workflow token for a short-lived npm registry token; it should + not depend on a stored `NPM_TOKEN`. - The publish run must be started manually with `workflow_dispatch`. - The npm workflow and the private mac publish workflow accept `preflight_only=true` to run validation/build/package steps without uploading diff --git a/.github/workflows/openclaw-npm-promote-beta.yml b/.github/workflows/openclaw-npm-promote-beta.yml index 69797347b8c..bea8ec1f48a 100644 --- a/.github/workflows/openclaw-npm-promote-beta.yml +++ b/.github/workflows/openclaw-npm-promote-beta.yml @@ -23,6 +23,7 @@ jobs: environment: npm-release permissions: contents: read + id-token: write steps: - name: Validate version input format env: @@ -69,16 +70,15 @@ jobs: - name: Promote beta to latest env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} RELEASE_VERSION: ${{ inputs.version }} run: | set -euo pipefail + registry_token="$(node scripts/npm-oidc-exchange-token.mjs openclaw)" userconfig="$(mktemp)" trap 'rm -f "${userconfig}"' EXIT chmod 0600 "${userconfig}" - printf '%s\n' "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > "${userconfig}" + printf '%s\n' "//registry.npmjs.org/:_authToken=${registry_token}" > "${userconfig}" - NPM_CONFIG_USERCONFIG="${userconfig}" npm whoami >/dev/null NPM_CONFIG_USERCONFIG="${userconfig}" \ npm dist-tag add "openclaw@${RELEASE_VERSION}" latest promoted_latest="$(npm view openclaw dist-tags.latest)" diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index c0346829cb2..3e147ba9a72 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -52,6 +52,7 @@ OpenClaw has three public release lanes: - stable npm releases default to `beta` - stable npm publish can target `latest` explicitly via workflow input - stable npm promotion from `beta` to `latest` is still available as a separate manual workflow step + - that promotion workflow exchanges the GitHub Actions OIDC token for a short-lived npm registry token instead of depending on a stored `NPM_TOKEN` - public `macOS Release` is validation-only - real private mac publish must pass successful private mac `preflight_run_id` and `validate_run_id` @@ -108,6 +109,9 @@ When cutting a stable npm release: the exact stable version when you want to move that published build to `latest` +The promotion workflow still requires the `npm-release` environment approval, +but it no longer depends on a long-lived npm publish token. + That keeps the direct publish path and the beta-first promotion path both documented and operator-visible. diff --git a/scripts/npm-oidc-exchange-token.mjs b/scripts/npm-oidc-exchange-token.mjs new file mode 100644 index 00000000000..3a2168c6817 --- /dev/null +++ b/scripts/npm-oidc-exchange-token.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +const packageName = process.argv[2]; + +if (!packageName) { + console.error("usage: node scripts/npm-oidc-exchange-token.mjs "); + process.exit(2); +} + +const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; +const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + +if (!requestUrl || !requestToken) { + console.error( + "GitHub OIDC request environment is missing. ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN are required.", + ); + process.exit(1); +} + +async function readJson(response, context) { + const text = await response.text(); + if (!response.ok) { + throw new Error(`${context} failed (${response.status}): ${text}`); + } + try { + return JSON.parse(text); + } catch (error) { + throw new Error( + `${context} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} + +const oidcUrl = new URL(requestUrl); +oidcUrl.searchParams.set("audience", "npm:registry.npmjs.org"); + +const oidcResponse = await fetch(oidcUrl, { + headers: { + Authorization: `Bearer ${requestToken}`, + }, +}); +const oidcPayload = await readJson(oidcResponse, "GitHub OIDC token request"); +const idToken = oidcPayload && typeof oidcPayload.value === "string" ? oidcPayload.value : ""; + +if (!idToken) { + throw new Error("GitHub OIDC token response did not include a token value."); +} + +const exchangeUrl = new URL( + `https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${encodeURIComponent(packageName)}`, +); + +const exchangeResponse = await fetch(exchangeUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + }, +}); +const exchangePayload = await readJson(exchangeResponse, "npm OIDC exchange"); +const registryToken = + exchangePayload && typeof exchangePayload.token === "string" ? exchangePayload.token : ""; + +if (!registryToken) { + throw new Error("npm OIDC exchange response did not include a registry token."); +} + +process.stdout.write(registryToken);