From 1672852136fc87748249d22bce0819f33419ea79 Mon Sep 17 00:00:00 2001 From: Kite <254839944+lizhengfeng101@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:45:38 +0800 Subject: [PATCH] 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 --- .github/workflows/action-contract.yml | 5 + .github/workflows/release.yml | 118 +++++++++- package.json | 2 +- .../github-actions/release-contract.test.js | 222 ++++++++++++++++++ 4 files changed, 334 insertions(+), 13 deletions(-) create mode 100644 scripts/github-actions/release-contract.test.js diff --git a/.github/workflows/action-contract.yml b/.github/workflows/action-contract.yml index 717f078..7041b86 100644 --- a/.github/workflows/action-contract.yml +++ b/.github/workflows/action-contract.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c1fe40..dedb17d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/package.json b/package.json index a9cf671..f105b22 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/github-actions/release-contract.test.js b/scripts/github-actions/release-contract.test.js new file mode 100644 index 0000000..1131573 --- /dev/null +++ b/scripts/github-actions/release-contract.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", "npm@11.15.0"]] + : []); + }); +} + +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;