mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
Compare commits
1 Commits
docs/opena
...
fix/check-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe5b3f94e1 |
@@ -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.
|
||||||
@@ -42,6 +42,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { findProvenanceOnSelfHosted, formatProvenanceFinding } from "./lib/provenanceRunner.mjs";
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
|
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
|
||||||
@@ -275,6 +276,23 @@ export function runZizmor(workflowsDir) {
|
|||||||
// Main
|
// 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() {
|
function main() {
|
||||||
const hasActionlint = isBinaryAvailable("actionlint");
|
const hasActionlint = isBinaryAvailable("actionlint");
|
||||||
const hasZizmor = isBinaryAvailable("zizmor");
|
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;
|
const total = actionlintCount + zizmorCount;
|
||||||
process.stdout.write(`workflowFindings=${total}\n`);
|
process.stdout.write(`workflowFindings=${total}\n`);
|
||||||
process.stdout.write(`actionlintFindings=${actionlintCount}\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
|
// Read this line with the count above: a finding total is only reproducible against the
|
||||||
// version that produced it. See zizmorVersion().
|
// version that produced it. See zizmorVersion().
|
||||||
process.stdout.write(`zizmorVersion=${hasZizmor ? zizmorVersion() : "absent"}\n`);
|
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) {
|
if (STRICT && total > 0) {
|
||||||
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);
|
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);
|
||||||
|
|||||||
83
scripts/check/lib/provenanceRunner.mjs
Normal file
83
scripts/check/lib/provenanceRunner.mjs
Normal 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.`;
|
||||||
|
}
|
||||||
145
tests/unit/check-workflows-provenance-runner.test.ts
Normal file
145
tests/unit/check-workflows-provenance-runner.test.ts
Normal 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)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user