Files
OmniRoute/scripts/check/lib/provenanceRunner.mjs
Diego Rodrigues de Sa e Souza 9dc8eab70e feat(quality): fail check:workflows on --provenance from a self-hosted runner (#11895)
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 15:44:40 -03:00

84 lines
3.2 KiB
JavaScript

/**
* 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.`;
}