mirror of
https://github.com/Heretek-AI/open-code-review.git
synced 2026-11-07 02:46:04 -10:00
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:
4 files changed
+334
-13
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user