mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-29 18:42:17 +03:00
Compare commits
1 Commits
fix/releas
...
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 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).`);
|
||||
|
||||
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