ci: use oidc token for npm promotion

This commit is contained in:
Peter Steinberger
2026-04-02 20:23:56 +01:00
parent dbfb13b93a
commit 658f0c5d2d
4 changed files with 78 additions and 3 deletions

View File

@@ -116,6 +116,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
## 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

View File

@@ -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)"

View File

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

View File

@@ -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 <package-name>");
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);