ci(release): publish to npm via trusted publishing (#1732)

* ci(release): publish to npm via trusted publishing

Replace the NPM_TOKEN secret with GitHub OIDC token exchange, and add a
manually triggered job that verifies every package's trusted publisher
configuration without publishing anything.

npm only accepts an OIDC exchange from a cloud-hosted runner, so those two
jobs run on ubuntu-latest while the Go build matrix stays on self-hosted.

* fix(ci): harden npm trusted publishing checks

* fix(test): encode all slashes in release contract URLs
This commit is contained in:
Kite authored and GitHub committed 2026-10-13 17:45:38 +08:00
1 parent 3694a716c3
commit 1672852136
4 files changed
+334 -13

No files matched your search

+5
View File
@@ -18,6 +18,7 @@ on:
- 'scripts/github-actions/**'
- 'examples/github_actions/README.md'
- '.github/workflows/action-contract.yml'
- '.github/workflows/release.yml'
pull_request:
branches: [main]
paths:
@@ -30,6 +31,7 @@ on:
- 'scripts/github-actions/**'
- 'examples/github_actions/README.md'
- '.github/workflows/action-contract.yml'
- '.github/workflows/release.yml'
permissions:
contents: read
@@ -54,6 +56,9 @@ jobs:
- name: Trust workspace
run: git config --global --replace-all safe.directory '*'
- name: Install release contract dependencies
run: apt-get update && apt-get install -y jq
- name: Test GitHub Actions contracts
run: npm run test:github-actions
+106 -12
View File
@@ -3,12 +3,99 @@ name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
jobs:
# Exchanges a GitHub OIDC token for an npm publish token for every package
# without publishing anything, so a trusted publisher misconfiguration is
# caught before a tag is cut. Manual runs stop here.
verify-trusted-publishing:
if: github.event_name == 'workflow_dispatch'
# npm trusted publishing rejects self-hosted runners, so this job and
# npm-publish are cloud-hosted while the rest of the workflow is not.
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: node:24
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Trust workspace
run: git config --global --replace-all safe.directory '*'
- name: Install jq
run: apt-get update && apt-get install -y jq
- name: Exchange OIDC token for every package
shell: bash
run: |
set -euo pipefail
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then
echo "::error::OIDC is unavailable. The job needs 'id-token: write'."
exit 1
fi
REGISTRY_HOST=registry.npmjs.org
ID_TOKEN=$(curl -sSf \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
-H 'Accept: application/json' \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:${REGISTRY_HOST}" | jq -r '.value')
if [ -z "$ID_TOKEN" ] || [ "$ID_TOKEN" = "null" ]; then
echo "::error::Failed to obtain a GitHub OIDC token."
exit 1
fi
echo "Presenting claims:"
printf '%s' "$ID_TOKEN" | node -e '
let raw = ""
process.stdin.on("data", c => raw += c).on("end", () => {
const p = JSON.parse(Buffer.from(raw.trim().split(".")[1], "base64url"))
console.log(JSON.stringify({
repository: p.repository,
workflow_ref: p.workflow_ref,
environment: p.environment,
repository_visibility: p.repository_visibility,
}, null, 2))
})'
echo
# The main package plus every platform package it pulls in.
PACKAGES=$(jq -r '[.name] + (.optionalDependencies // {} | keys) | .[]' package.json)
failed=0
for pkg in $PACKAGES; do
escaped=$(printf '%s' "$pkg" | sed 's|/|%2f|')
body=$(curl -s -o exchange.json -w '%{http_code}' -X POST \
-H "Authorization: Bearer ${ID_TOKEN}" \
-H 'Accept: application/json' \
"https://${REGISTRY_HOST}/-/npm/v1/oidc/token/exchange/package/${escaped}")
if [ "$body" = "201" ] && jq -e '.token | strings | select(length > 0)' exchange.json > /dev/null; then
echo "OK ${pkg}"
else
failed=1
echo "FAILED ${pkg} (HTTP ${body}): $(jq -r '.message // .error // "unknown error"' exchange.json 2>/dev/null)"
fi
rm -f exchange.json
done
echo
if [ "$failed" -ne 0 ]; then
echo "::error::At least one package is not configured for trusted publishing from this workflow."
exit 1
fi
echo "All packages accept trusted publishing from this workflow."
build:
if: github.event_name == 'push'
runs-on: self-hosted
timeout-minutes: 15
container:
@@ -161,12 +248,15 @@ jobs:
npm-publish:
needs: release
runs-on: self-hosted
# Must stay cloud-hosted: npm refuses an OIDC exchange from a self-hosted
# runner. The heavy build matrix above is unaffected.
runs-on: ubuntu-latest
timeout-minutes: 15
container:
image: node:24
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -176,16 +266,24 @@ jobs:
- name: Install jq
run: apt-get update && apt-get install -y jq
# Keep a tested npm baseline without downgrading a newer bundled version.
- name: Ensure npm supports trusted publishing
shell: bash
run: |
set -euo pipefail
REQUIRED=11.15.0
CURRENT=$(npm --version)
if [ "$(printf '%s\n%s\n' "$REQUIRED" "$CURRENT" | sort -V | head -1)" != "$REQUIRED" ]; then
echo "npm ${CURRENT} is below the tested baseline ${REQUIRED}, upgrading"
npm install -g "npm@${REQUIRED}"
fi
npm --version
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: binary-*
merge-multiple: true
- name: Configure npm registry
run: echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish platform packages
run: |
VERSION="${GITHUB_REF_NAME#v}"
@@ -214,12 +312,10 @@ jobs:
if [ "$already" = "$VERSION" ]; then
echo "Skip: ${pkg_name}@${VERSION} already published"
else
npm publish "$pkg_dir" --access public
npm publish "$pkg_dir" --access public --provenance
echo "Published: ${pkg_name}@${VERSION}"
fi
done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Inject version and optionalDependencies
run: |
@@ -237,8 +333,6 @@ jobs:
if [ "$already" = "$VERSION" ]; then
echo "Skip: ${pkg_name}@${VERSION} already published"
else
npm publish --access public
npm publish --access public --provenance
echo "Published: ${pkg_name}@${VERSION}"
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -1
View File
@@ -15,7 +15,7 @@
],
"scripts": {
"postinstall": "node scripts/install.js",
"test:github-actions": "node scripts/github-actions/post-review-comments.test.js && node scripts/github-actions/check-translation-sync.test.js && node scripts/github-actions/action-contract.test.js && node scripts/github-actions/check-plugin-contract.test.js",
"test:github-actions": "node scripts/github-actions/post-review-comments.test.js && node scripts/github-actions/check-translation-sync.test.js && node scripts/github-actions/action-contract.test.js && node scripts/github-actions/check-plugin-contract.test.js && node scripts/github-actions/release-contract.test.js",
"test:update": "node scripts/version.test.js",
"test:launcher": "node bin/ocr.test.js"
},
@@ -0,0 +1,222 @@
#!/usr/bin/env node
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const ROOT = path.resolve(__dirname, "../..");
const WORKFLOW = fs.readFileSync(path.join(ROOT, ".github/workflows/release.yml"), "utf8");
const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
const PACKAGES = [MANIFEST.name, ...Object.keys(MANIFEST.optionalDependencies)].sort();
function jobText(name) {
const lines = WORKFLOW.split("\n");
const start = lines.indexOf(` ${name}:`);
assert.ok(start >= 0, `missing job ${name}`);
const end = lines.findIndex((line, index) => index > start && /^ [\w-]+:/.test(line));
return lines.slice(start, end < 0 ? undefined : end).join("\n");
}
function stepText(job, name) {
const lines = jobText(job).split("\n");
const start = lines.indexOf(` - name: ${name}`);
assert.ok(start >= 0, `missing step ${name}`);
const end = lines.findIndex((line, index) => index > start && /^ - /.test(line));
return lines.slice(start, end < 0 ? undefined : end).join("\n");
}
function runBlock(step) {
const lines = step.split("\n");
const start = lines.indexOf(" run: |");
assert.ok(start >= 0, "expected a multiline run block");
const script = [];
for (const line of lines.slice(start + 1)) {
if (line.trim() && !line.startsWith(" ")) break;
script.push(line.slice(10));
}
return script.join("\n");
}
const PREFLIGHT = stepText("verify-trusted-publishing", "Exchange OIDC token for every package");
const VERSION_GATE = stepText("npm-publish", "Ensure npm supports trusted publishing");
function execute(step, overrides = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ocr-release-contract-"));
const bin = path.join(directory, "bin");
fs.mkdirSync(bin);
fs.symlinkSync(process.execPath, path.join(bin, "node"));
fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify(MANIFEST));
const mock = `#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const command = path.basename(process.argv[1]);
const args = process.argv.slice(2);
fs.appendFileSync("calls.jsonl", JSON.stringify({ command, args }) + "\\n");
if (command === "npm") {
if (args[0] === "--version") {
console.log(process.env.TEST_NPM_VERSION);
} else if (args[0] === "install") {
process.exit(Number(process.env.TEST_INSTALL_STATUS || 0));
} else {
throw new Error("Unexpected npm operation: " + args.join(" "));
}
} else if (args[args.length - 1].startsWith("https://oidc.invalid/")) {
console.log(JSON.stringify({ value: process.env.TEST_ID_TOKEN }));
} else {
if (process.env.TEST_CURL_FAILURE === "1") process.exit(7);
const failed = process.env.TEST_FAILED_PACKAGE &&
args[args.length - 1].endsWith(process.env.TEST_FAILED_PACKAGE.split("/").join("%2f"));
const body = failed ? JSON.stringify({ message: "Not authorized" }) : process.env.TEST_RESPONSE;
const outputIndex = args.indexOf("-o");
if (outputIndex < 0) throw new Error("Expected a curl output file");
fs.writeFileSync(args[outputIndex + 1], body);
process.stdout.write(failed ? "401" : process.env.TEST_HTTP_STATUS);
}
`;
for (const command of ["curl", "npm"]) {
fs.writeFileSync(path.join(bin, command), mock, { mode: 0o755 });
}
const claims = Buffer.from(JSON.stringify({
repository: "alibaba/open-code-review",
workflow_ref: "alibaba/open-code-review/.github/workflows/release.yml@refs/heads/main",
repository_visibility: "public",
})).toString("base64url");
try {
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-e", "-c", runBlock(step)], {
cwd: directory,
encoding: "utf8",
timeout: 30000,
env: {
PATH: `${bin}:${process.env.PATH}`,
HOME: directory,
LC_ALL: "C",
ACTIONS_ID_TOKEN_REQUEST_URL: "https://oidc.invalid/?request=fixture",
ACTIONS_ID_TOKEN_REQUEST_TOKEN: "fixture-request-token",
TEST_ID_TOKEN: `header.${claims}.signature`,
TEST_HTTP_STATUS: "201",
TEST_RESPONSE: JSON.stringify({ token: "fixture-npm-token" }),
TEST_NPM_VERSION: "11.15.0",
...overrides,
},
});
assert.ifError(result.error);
const callsPath = path.join(directory, "calls.jsonl");
const calls = fs.existsSync(callsPath)
? fs.readFileSync(callsPath, "utf8").trim().split("\n").map(JSON.parse)
: [];
return { ...result, calls };
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
let failures = 0;
function test(name, check) {
try {
check();
console.log(`PASS ${name}`);
} catch (error) {
failures += 1;
console.error(`FAIL ${name}: ${error.message}`);
}
}
test("container scripts explicitly select Bash", () => {
for (const step of [PREFLIGHT, VERSION_GATE]) {
assert.match(step, /^ shell: bash$/m);
}
});
test("manual preflight is separated from tag publishing", () => {
assert.match(WORKFLOW, /^ workflow_dispatch:/m);
assert.match(jobText("verify-trusted-publishing"), /^ if: github.event_name == 'workflow_dispatch'$/m);
assert.match(jobText("build"), /^ if: github.event_name == 'push'$/m);
assert.match(jobText("release"), /^ needs: build$/m);
assert.match(jobText("npm-publish"), /^ needs: release$/m);
for (const job of ["build", "release"]) {
assert.match(jobText(job), /^ runs-on: self-hosted$/m);
}
for (const job of ["verify-trusted-publishing", "npm-publish"]) {
assert.match(jobText(job), /^ runs-on: ubuntu-latest$/m);
assert.match(jobText(job), /^ id-token: write$/m);
}
assert.doesNotMatch(WORKFLOW, /NPM_TOKEN|NODE_AUTH_TOKEN|_authToken/);
});
test("HTTP 201 validates all seven packages without publishing", () => {
const result = execute(PREFLIGHT);
assert.strictEqual(result.status, 0, result.stdout + result.stderr);
const verified = Array.from(result.stdout.matchAll(/^OK\s+(\S+)$/gm), (match) => match[1]).sort();
assert.strictEqual(PACKAGES.length, 7);
assert.deepStrictEqual(verified, PACKAGES);
assert.ok(result.calls.every((call) => call.command === "curl"));
const requests = result.calls.map((call) => call.args[call.args.length - 1]);
assert.strictEqual(requests[0], "https://oidc.invalid/?request=fixture&audience=npm:registry.npmjs.org");
assert.deepStrictEqual(requests.slice(1).sort(), PACKAGES.map((name) =>
`https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${name.split("/").join("%2f")}`
).sort());
assert.ok(!result.stdout.includes("fixture-npm-token"));
});
test("one unauthorized platform fails preflight", () => {
const result = execute(PREFLIGHT, { TEST_FAILED_PACKAGE: PACKAGES[0] });
assert.notStrictEqual(result.status, 0);
assert.match(result.stdout, /FAILED.*HTTP 401/);
assert.match(result.stdout, /At least one package is not configured/);
});
for (const response of [{}, { token: null }, { token: "" }, { token: 123 }, { token: [] }]) {
test(`HTTP 201 rejects an invalid token: ${JSON.stringify(response)}`, () => {
const result = execute(PREFLIGHT, { TEST_RESPONSE: JSON.stringify(response) });
assert.notStrictEqual(result.status, 0);
assert.match(result.stdout, /At least one package is not configured/);
});
}
test("missing OIDC permissions fail before network access", () => {
const result = execute(PREFLIGHT, { ACTIONS_ID_TOKEN_REQUEST_TOKEN: "" });
assert.notStrictEqual(result.status, 0);
assert.deepStrictEqual(result.calls, []);
});
test("network failures cannot pass preflight", () => {
const result = execute(PREFLIGHT, { TEST_CURL_FAILURE: "1" });
assert.notStrictEqual(result.status, 0);
assert.doesNotMatch(result.stdout, /All packages accept/);
});
for (const version of ["10.9.0", "11.5.1", "11.14.0", "11.15.0", "11.19.0"]) {
test(`npm version gate handles ${version}`, () => {
const result = execute(VERSION_GATE, { TEST_NPM_VERSION: version });
assert.strictEqual(result.status, 0, result.stdout + result.stderr);
const installs = result.calls.filter((call) => call.args[0] === "install");
const needsUpgrade = ["10.9.0", "11.5.1", "11.14.0"].includes(version);
assert.deepStrictEqual(installs.map((call) => call.args), needsUpgrade
? [["install", "-g", "[email protected]"]]
: []);
});
}
test("failed npm upgrade stops the job", () => {
const result = execute(VERSION_GATE, { TEST_NPM_VERSION: "10.9.0", TEST_INSTALL_STATUS: "1" });
assert.notStrictEqual(result.status, 0);
});
test("both publish paths require provenance", () => {
for (const name of ["Publish platform packages", "Publish to npm"]) {
const commands = runBlock(stepText("npm-publish", name)).split("\n")
.filter((line) => /^\s*npm publish\b/.test(line));
assert.strictEqual(commands.length, 1);
assert.match(commands[0], /--provenance(?:\s|$)/);
assert.match(commands[0], /--access public(?:\s|$)/);
}
});
if (failures) process.exitCode = 1;