Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
fe5b3f94e1 feat(quality): fail check:workflows on --provenance from a self-hosted runner
npm rejects provenance-signed uploads from self-hosted runners:

  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 learned that at minute 76 of its 10th publish attempt, after the tag,
the GitHub Release and the Docker images were already out. USE_VPS_RUNNER had
routed the job to the .113 pool on 2026-08-02; no release ran between 07-30 and
08-28, so the pairing sat latent for four weeks.

It is pure text — a job whose runs-on resolves to self-hosted and a step whose
run contains --provenance — so the workflow lint now checks it as a hard rule:
reported in plain mode, blocking under --strict and --ratchet (the CI mode),
emitted as provenanceRunnerFindings=<n> next to the other counters.

Against origin/main the rule finds the two real offenders (the staged upload
AND the DIRECT emergency fallback in npm-publish.yml); against the #11877 split
it finds none. --provenance-file is deliberately not matched (different flag,
pre-built bundle) and an opaque runs-on expression with no literal self-hosted
is classified unknown and skipped — the check never guesses.

The unit suite's last case walks the real .github/workflows and asserts zero
findings, so it is red on main until #11877 lands and green after; that is the
regression guard working, not a flake.
2026-08-28 10:27:30 -03:00
4 changed files with 268 additions and 0 deletions

View File

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

View File

@@ -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).`);

View File

@@ -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 = "<workflow>") {
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.`;
}

View File

@@ -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)}`
);
});