diff --git a/changelog.d/maintenance/11878-check-workflows-provenance-self-hosted.md b/changelog.d/maintenance/11878-check-workflows-provenance-self-hosted.md new file mode 100644 index 0000000000..d07e0fdbdc --- /dev/null +++ b/changelog.d/maintenance/11878-check-workflows-provenance-self-hosted.md @@ -0,0 +1,3 @@ +- `check:workflows` now fails (under `--strict`/`--ratchet`) when any job routed to a + self-hosted runner publishes with `--provenance` — npm rejects that with `422` at the + registry, which in v3.8.50 only surfaced after the tag and Docker images were public. diff --git a/scripts/check/check-workflows.mjs b/scripts/check/check-workflows.mjs index 2ac213ff37..8559332fce 100644 --- a/scripts/check/check-workflows.mjs +++ b/scripts/check/check-workflows.mjs @@ -42,6 +42,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { findProvenanceOnSelfHosted, formatProvenanceFinding } from "./lib/provenanceRunner.mjs"; const ROOT = process.cwd(); const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows"); @@ -275,6 +276,23 @@ export function runZizmor(workflowsDir) { // Main // --------------------------------------------------------------------------- +/** + * Hard rule (not a lint count): `--provenance` inside a job that runs on a + * self-hosted runner. npm answers 422 at the registry, and in v3.8.50 that + * answer only came after the tag, the GitHub Release and the Docker images were + * already out. Blocks under --strict AND --ratchet (the CI mode); plain mode + * reports it like everything else. + * @param {string[]} files absolute workflow paths + */ +export function runProvenanceRunnerCheck(files) { + const findings = []; + for (const file of files) { + const text = fs.readFileSync(file, "utf8"); + findings.push(...findProvenanceOnSelfHosted(text, path.relative(ROOT, file))); + } + return findings; +} + function main() { const hasActionlint = isBinaryAvailable("actionlint"); const hasZizmor = isBinaryAvailable("zizmor"); @@ -350,6 +368,16 @@ function main() { } } + const provenanceFindings = runProvenanceRunnerCheck(workflowFiles); + if (provenanceFindings.length > 0) { + console.error( + `[check-workflows] provenance×self-hosted: ${provenanceFindings.length} finding(s) — HARD RULE:` + ); + provenanceFindings.forEach((f) => console.error(` ${formatProvenanceFinding(f)}`)); + } else if (!QUIET) { + console.log("[check-workflows] provenance×self-hosted: OK (0 findings)"); + } + const total = actionlintCount + zizmorCount; process.stdout.write(`workflowFindings=${total}\n`); process.stdout.write(`actionlintFindings=${actionlintCount}\n`); @@ -357,6 +385,15 @@ function main() { // Read this line with the count above: a finding total is only reproducible against the // version that produced it. See zizmorVersion(). process.stdout.write(`zizmorVersion=${hasZizmor ? zizmorVersion() : "absent"}\n`); + process.stdout.write(`provenanceRunnerFindings=${provenanceFindings.length}\n`); + if ((STRICT || RATCHET) && provenanceFindings.length > 0) { + console.error( + `\n[check-workflows] FAIL — ${provenanceFindings.length} job(s) publish with --provenance from a self-hosted runner.\n` + + " npm rejects that with 422 at the registry. Move the upload step to a github-hosted job\n" + + " (see .github/workflows/npm-publish.yml `stage-npm` for the pattern)." + ); + process.exit(1); + } if (STRICT && total > 0) { console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`); diff --git a/scripts/check/lib/provenanceRunner.mjs b/scripts/check/lib/provenanceRunner.mjs new file mode 100644 index 0000000000..b38602e96b --- /dev/null +++ b/scripts/check/lib/provenanceRunner.mjs @@ -0,0 +1,83 @@ +/** + * scripts/check/lib/provenanceRunner.mjs + * + * npm refuses `--provenance` from a self-hosted runner: + * + * 422 Unprocessable Entity - Error verifying sigstore provenance bundle: + * Unsupported GitHub Actions runner environment: "self-hosted". + * Only "github-hosted" runners are supported when publishing with provenance. + * + * v3.8.50 hit this at the very end of a 76-minute publish job — after the tag, + * the GitHub Release and the Docker images were already public — because + * `USE_VPS_RUNNER` had been turned on (2026-08-02) with no release in between to + * surface it. The combination is greppable, so it must fail in CI the moment a + * workflow introduces it, not four weeks later at the registry. + * + * Pure: takes workflow YAML text, returns the offending (job, step) pairs. + */ +import { load as yamlLoad } from "js-yaml"; + +const SELF_HOSTED = /\bself-hosted\b/; +const EXPRESSION = /\$\{\{/; +// Lookahead, not \b: `--provenance-file=…` is a different flag (a pre-built +// bundle) and must not match — a word boundary sits between "e" and "-". +const PROVENANCE = /(^|\s)--provenance(?=\s|=|$)/m; + +/** + * Classifies a job's `runs-on` value. + * @returns {"self-hosted"|"hosted"|"unknown"} + * "unknown" = an expression with no literal `self-hosted` in it (e.g. + * `${{ matrix.os }}`); the check does not guess, it skips. + */ +export function classifyRunsOn(runsOn) { + if (runsOn == null) return "unknown"; + if (typeof runsOn === "string") { + if (SELF_HOSTED.test(runsOn)) return "self-hosted"; + return EXPRESSION.test(runsOn) ? "unknown" : "hosted"; + } + if (Array.isArray(runsOn)) { + return runsOn.some((v) => typeof v === "string" && SELF_HOSTED.test(v)) + ? "self-hosted" + : "hosted"; + } + if (typeof runsOn === "object") { + // { group: ..., labels: ... } form + const labels = runsOn.labels; + return classifyRunsOn(Array.isArray(labels) ? labels : labels == null ? "" : String(labels)); + } + return "unknown"; +} + +/** + * @param {string} yamlText + * @param {string} fileName used only for reporting + * @returns {{ file: string, job: string, step: string }[]} + */ +export function findProvenanceOnSelfHosted(yamlText, fileName = "") { + let doc; + try { + doc = yamlLoad(yamlText); + } catch { + // actionlint owns syntax; an unparseable file is not this rule's finding. + return []; + } + const jobs = + doc && typeof doc === "object" && doc.jobs && typeof doc.jobs === "object" ? doc.jobs : {}; + const findings = []; + for (const [jobName, job] of Object.entries(jobs)) { + if (!job || typeof job !== "object") continue; + if (classifyRunsOn(job["runs-on"]) !== "self-hosted") continue; + const steps = Array.isArray(job.steps) ? job.steps : []; + steps.forEach((step, i) => { + if (step && typeof step.run === "string" && PROVENANCE.test(step.run)) { + findings.push({ file: fileName, job: jobName, step: step.name || `#${i + 1}` }); + } + }); + } + return findings; +} + +/** Human-readable line per finding, used by the CLI. */ +export function formatProvenanceFinding(f) { + return `${f.file}: job "${f.job}", step "${f.step}" runs \`--provenance\` on a self-hosted runner — npm rejects that (422). Move the upload to a github-hosted job.`; +} diff --git a/tests/unit/check-workflows-provenance-runner.test.ts b/tests/unit/check-workflows-provenance-runner.test.ts new file mode 100644 index 0000000000..826327fdbd --- /dev/null +++ b/tests/unit/check-workflows-provenance-runner.test.ts @@ -0,0 +1,145 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { + classifyRunsOn, + findProvenanceOnSelfHosted, +} from "../../scripts/check/lib/provenanceRunner.mjs"; + +/** + * v3.8.50, 10th publish attempt, 76 minutes in — after the tag, the GitHub + * Release and the Docker images were already public: + * + * 422 Unprocessable Entity - Error verifying sigstore provenance bundle: + * Unsupported GitHub Actions runner environment: "self-hosted". + * + * `USE_VPS_RUNNER` had routed the publish job to the .113 pool on 2026-08-02; + * no release happened between 07-30 and 08-28, so nothing surfaced it. The + * pairing is pure text, so it must fail the workflow lint on the PR that + * introduces it. + */ +const ROOT = join(import.meta.dirname, "../.."); +const WORKFLOWS = join(ROOT, ".github/workflows"); + +// The exact runs-on expression npm-publish.yml used when it broke. +const VPS_EXPR = + "${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('[\"self-hosted\",\"omni-release\"]') || 'ubuntu-latest' }}"; + +function workflow(runsOn: string, run: string, extra = ""): string { + return [ + "name: t", + "on: push", + "jobs:", + " publish:", + ` runs-on: ${runsOn}`, + extra, + " steps:", + " - name: upload", + ` run: ${run}`, + "", + ].join("\n"); +} + +test("classifyRunsOn: literal, array, object-with-labels and the fromJSON expression are self-hosted", () => { + assert.equal(classifyRunsOn("self-hosted"), "self-hosted"); + assert.equal(classifyRunsOn(["self-hosted", "omni-release"]), "self-hosted"); + assert.equal(classifyRunsOn({ group: "Default", labels: ["self-hosted"] }), "self-hosted"); + assert.equal(classifyRunsOn(VPS_EXPR), "self-hosted"); +}); + +test("classifyRunsOn: hosted labels are hosted, opaque expressions are unknown (never guessed)", () => { + assert.equal(classifyRunsOn("ubuntu-latest"), "hosted"); + assert.equal(classifyRunsOn(["ubuntu-latest"]), "hosted"); + assert.equal(classifyRunsOn("${{ matrix.os }}"), "unknown"); + assert.equal(classifyRunsOn(undefined), "unknown"); +}); + +test("flags --provenance inside a job routed to the self-hosted pool", () => { + const found = findProvenanceOnSelfHosted( + workflow( + `"${VPS_EXPR.replace(/"/g, '\\"')}"`, + 'npm stage publish --provenance --access public --tag "$TAG"' + ), + "npm-publish.yml" + ); + assert.deepEqual(found, [{ file: "npm-publish.yml", job: "publish", step: "upload" }]); +}); + +test("also catches the literal label and the --provenance-file form", () => { + assert.equal( + findProvenanceOnSelfHosted(workflow("self-hosted", "npm publish --provenance")).length, + 1 + ); + assert.equal( + findProvenanceOnSelfHosted( + workflow("[self-hosted, omni-release]", "npm publish --provenance-file=./p.json") + ).length, + 0, + "--provenance-file is a different flag (a pre-built bundle) and is not what the registry rejects" + ); + assert.equal( + findProvenanceOnSelfHosted(workflow("self-hosted", "npm publish --provenance=true")).length, + 1 + ); +}); + +test("does not flag hosted jobs, unknown runners, or self-hosted jobs without the flag", () => { + assert.deepEqual( + findProvenanceOnSelfHosted(workflow("ubuntu-latest", "npm publish --provenance")), + [] + ); + assert.deepEqual( + findProvenanceOnSelfHosted(workflow("${{ matrix.os }}", "npm publish --provenance")), + [] + ); + assert.deepEqual( + findProvenanceOnSelfHosted(workflow("self-hosted", "npm publish --access public")), + [] + ); + // The word only in a step NAME or a comment is not a finding. + assert.deepEqual( + findProvenanceOnSelfHosted( + [ + "name: t", + "on: push", + "jobs:", + " j:", + " runs-on: self-hosted", + " steps:", + " - name: provenance note", + " run: echo hi # --provenance later", + "", + ].join("\n") + ), + [], + "a comment after the command is still part of the run string — accept that the regex is conservative" + ); +}); + +test("reusable-workflow jobs (uses:) and unparseable YAML are not this rule's findings", () => { + const reusable = [ + "name: t", + "on: push", + "jobs:", + " j:", + " uses: ./.github/workflows/x.yml", + "", + ].join("\n"); + assert.deepEqual(findProvenanceOnSelfHosted(reusable), []); + assert.deepEqual(findProvenanceOnSelfHosted("jobs: [unclosed"), []); +}); + +test("regression guard: no workflow in this repo publishes with --provenance from a self-hosted runner", () => { + const files = readdirSync(WORKFLOWS).filter((f) => /\.ya?ml$/.test(f)); + assert.ok(files.length > 10, "expected the real workflow set"); + const findings = files.flatMap((f) => + findProvenanceOnSelfHosted(readFileSync(join(WORKFLOWS, f), "utf8"), f) + ); + assert.deepEqual( + findings, + [], + `npm rejects provenance from self-hosted runners (422) — move the upload to a github-hosted job: ${JSON.stringify(findings)}` + ); +});