mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 11:43:10 +03:00
* fix(ci): pin Build (advisory) to a hosted runner with memory provisioning `Build (advisory)` has been reporting a permanent red on every PR while producing no usable signal at all. Measured over the last 25 quality.yml runs (2026-08-14): not one instance of the job reached a conclusion. Every sample was either queued on the self-hosted pool — 2 runners, omniroute-113-6/7, both permanently busy; one job sat queued for over 2 hours and was still unclaimed — or, when it did land on a runner, killed mid-build by this workflow's own cancel-in-progress concurrency. All 6 sampled "failures" are exit 143 / "The runner has received a shutdown signal" at ~3.5 min into `npm run build`. Zero OOM, zero build errors. The job was consuming a runner the real gates compete for while telling every PR author it was broken. Gap 19 deliberately left USE_VPS_RUNNER governing build-like jobs, on the premise that the build needs the .113's RAM. That premise no longer holds: `Fast Production Build` (build.yml) runs `build:release` — a superset of this job's `npm run build`, plus the CLI bundle — on plain ubuntu-latest and passed 24 of its last 25 runs in ~15 min. The difference is memory PROVISIONING, not the machine: a 10 GB swapfile plus a 12 GB V8 heap. Swap is the part that matters, because --max-old-space-size bounds only V8's JS heap and never Turbopack's native Rust allocation (#6409). Pins the job to ubuntu-latest and mirrors both settings from build.yml. USE_VPS_RUNNER keeps its other consumers (ci.yml Build, nightly-release-green, npm-publish), so the variable stays meaningful. Fork safety is strictly improved: no PR can reach the LAN runner through this job any more. check:workflows --ratchet: 186 zizmor findings, baseline 190, no regression. prettier + YAML parse: clean. * fix(ci): scope Build (advisory) to fork PRs Follow-up to the hosted-runner pin in this same PR, after measuring what the job is actually for. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]` and runs `build:release` — a superset of this job's `npm run build`, plus the CLI bundle. For an own-origin branch that push fires here, so the tree was being built twice per PR. A fork contributor pushes to THEIR repo, so build.yml never runs in this repo and this job is their only pre-merge build signal. That could have argued for deleting the job, except the traffic says otherwise: 72 of the last 100 PRs into release/** come from forks. The fork case is the majority, not the exception. So the job earns its place — it just should not duplicate build.yml for the own-origin 28%. Added the fork filter to the existing `if`. Also corrects the reliability claim in the previous commit message. Over a wider window the job is not literally never-green: across 2026-08-13/14 it reached `success` on roughly 10-15% of runs (13/138 on 08-14, 7/53 sampled on 08-13). Chronically unreliable, not permanently dead — the conclusion and the fix are unchanged. The #7307 guard in tests/unit/build/check-workflows.test.ts pinned the old self-hosted expression, so it is realigned here: it now asserts the hosted pin, the absence of self-hosted/USE_VPS_RUNNER in the job's DIRECTIVES (the comment legitimately explains why the pool was abandoned, so the scan strips comments), both memory settings, and the fork filter. Mutation-validated — restoring self-hosted, dropping the swapfile, or flipping the fork filter each turns it red. check-workflows.test.ts: 32 pass, 0 fail. check:workflows --ratchet: 186 findings, baseline 190, no regression. --------- Co-authored-by: Xiangzhe <bakryun0718@proton.me>
374 lines
18 KiB
TypeScript
374 lines
18 KiB
TypeScript
// tests/unit/build/check-workflows.test.ts
|
|
// TDD unit tests for scripts/check/check-workflows.mjs — Task 7.19.
|
|
//
|
|
// Strategy: test the exported pure functions without spawning actionlint,
|
|
// zizmor, or touching the real .github/workflows directory.
|
|
// - parseActionlintOutput() — line-based finding counting
|
|
// - parseZizmorOutput() — JSON / text parsing + counting
|
|
// - collectWorkflowFiles() — directory listing helper
|
|
// - isBinaryAvailable() — PATH probe (tested structurally, not by
|
|
// spawning real processes)
|
|
//
|
|
// All tests are fast and hermetic (no network, no child processes except where
|
|
// explicitly exercising the PATH probe against a non-existent binary name).
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
parseActionlintOutput,
|
|
parseZizmorOutput,
|
|
collectWorkflowFiles,
|
|
isBinaryAvailable,
|
|
evaluateZizmorRatchet,
|
|
readBaselineZizmorValue,
|
|
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
|
} from "../../../scripts/check/check-workflows.mjs";
|
|
|
|
type RatchetVerdict = { regressed: boolean; improved: boolean };
|
|
const evaluateZizmor = evaluateZizmorRatchet as (
|
|
current: number,
|
|
baseline: number
|
|
) => RatchetVerdict;
|
|
const readZizmorBaseline = readBaselineZizmorValue as (p?: string) => number | null;
|
|
const qualityWorkflowPath = new URL("../../../.github/workflows/quality.yml", import.meta.url);
|
|
|
|
function readQualityWorkflow(): string {
|
|
return fs.readFileSync(qualityWorkflowPath, "utf8");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseActionlintOutput
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("parseActionlintOutput: empty stdout returns count=0 and empty lines", () => {
|
|
const result = parseActionlintOutput("");
|
|
assert.equal(result.count, 0);
|
|
assert.deepEqual(result.lines, []);
|
|
});
|
|
|
|
test("parseActionlintOutput: whitespace-only stdout returns count=0", () => {
|
|
const result = parseActionlintOutput(" \n \t \n");
|
|
assert.equal(result.count, 0);
|
|
assert.deepEqual(result.lines, []);
|
|
});
|
|
|
|
test("parseActionlintOutput: one finding line returns count=1", () => {
|
|
const stdout =
|
|
".github/workflows/ci.yml:12:7: shellcheck reported issue in this script: SC2086:info:1:12: Double quote to prevent globbing and word splitting. [shellcheck]\n";
|
|
const result = parseActionlintOutput(stdout);
|
|
assert.equal(result.count, 1);
|
|
assert.equal(result.lines.length, 1);
|
|
assert.ok(result.lines[0].includes("shellcheck"));
|
|
});
|
|
|
|
test("parseActionlintOutput: multiple finding lines returns correct count", () => {
|
|
const stdout = [
|
|
'.github/workflows/ci.yml:5:1: "on" is the key of workflow trigger. Use quoted "on" [syntax-check]',
|
|
".github/workflows/ci.yml:42:9: event name 'pull_request' is not available for 'workflow_dispatch' [events]",
|
|
".github/workflows/deploy.yml:8:5: unknown key 'runs-ons' in step config [syntax-check]",
|
|
].join("\n");
|
|
const result = parseActionlintOutput(stdout);
|
|
assert.equal(result.count, 3);
|
|
assert.equal(result.lines.length, 3);
|
|
});
|
|
|
|
test("parseActionlintOutput: trailing newline does not add phantom finding", () => {
|
|
const stdout = ".github/workflows/ci.yml:10:3: some issue [rule]\n\n\n";
|
|
const result = parseActionlintOutput(stdout);
|
|
assert.equal(result.count, 1);
|
|
});
|
|
|
|
test("parseActionlintOutput: preserves finding text exactly (trimmed)", () => {
|
|
const finding = ".github/workflows/ci.yml:99:1: missing required key 'runs-on' [runner]";
|
|
const result = parseActionlintOutput(` ${finding} \n`);
|
|
assert.equal(result.lines[0], finding);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseZizmorOutput
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("parseZizmorOutput: empty string returns count=0", () => {
|
|
const result = parseZizmorOutput("");
|
|
assert.equal(result.count, 0);
|
|
assert.deepEqual(result.diagnostics, []);
|
|
});
|
|
|
|
test("parseZizmorOutput: JSON with empty diagnostics array returns count=0", () => {
|
|
const result = parseZizmorOutput(JSON.stringify({ diagnostics: [] }));
|
|
assert.equal(result.count, 0);
|
|
assert.deepEqual(result.diagnostics, []);
|
|
});
|
|
|
|
test("parseZizmorOutput: JSON { diagnostics: [...] } counts correctly", () => {
|
|
const diagnostics = [
|
|
{
|
|
id: "unpinned-uses",
|
|
severity: "medium",
|
|
message: "uses: actions/checkout@v4 is not pinned to a SHA",
|
|
},
|
|
{ id: "script-injection", severity: "high", message: "Untrusted input in run step" },
|
|
];
|
|
const result = parseZizmorOutput(JSON.stringify({ diagnostics }));
|
|
assert.equal(result.count, 2);
|
|
assert.equal(result.diagnostics.length, 2);
|
|
});
|
|
|
|
test("parseZizmorOutput: bare JSON array (older zizmor format) counts correctly", () => {
|
|
const findings = [
|
|
{ id: "unpinned-uses", workflow: "ci.yml" },
|
|
{ id: "excessive-permissions", workflow: "deploy.yml" },
|
|
{ id: "pull-request-target", workflow: "docker.yml" },
|
|
];
|
|
const result = parseZizmorOutput(JSON.stringify(findings));
|
|
assert.equal(result.count, 3);
|
|
assert.equal(result.diagnostics.length, 3);
|
|
});
|
|
|
|
test("parseZizmorOutput: invalid JSON falls back to line counting", () => {
|
|
// Non-JSON output (e.g. text format or error message) — each non-empty line = 1
|
|
const textOutput = "warning: unpinned action\nerror: script injection risk\n";
|
|
const result = parseZizmorOutput(textOutput);
|
|
assert.equal(result.count, 2);
|
|
// diagnostics is empty array in fallback mode
|
|
assert.deepEqual(result.diagnostics, []);
|
|
});
|
|
|
|
test("parseZizmorOutput: JSON with unknown shape returns count=0 (graceful)", () => {
|
|
// Unexpected but valid JSON — neither array nor { diagnostics }
|
|
const result = parseZizmorOutput(JSON.stringify({ errors: [], warnings: [] }));
|
|
assert.equal(result.count, 0);
|
|
});
|
|
|
|
test("parseZizmorOutput: whitespace-only returns count=0", () => {
|
|
const result = parseZizmorOutput(" \n\t\n ");
|
|
assert.equal(result.count, 0);
|
|
});
|
|
|
|
test("parseZizmorOutput: large diagnostics array counted correctly", () => {
|
|
const diagnostics = Array.from({ length: 47 }, (_, i) => ({
|
|
id: "unpinned-uses",
|
|
step: `step-${i}`,
|
|
}));
|
|
const result = parseZizmorOutput(JSON.stringify({ diagnostics }));
|
|
assert.equal(result.count, 47);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// collectWorkflowFiles
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("collectWorkflowFiles: returns empty array for non-existent directory", () => {
|
|
const result = collectWorkflowFiles("/this/path/does/not/exist/at/all-99999");
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test("collectWorkflowFiles: returns .yml files from directory", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-"));
|
|
try {
|
|
fs.writeFileSync(path.join(dir, "ci.yml"), "name: CI\n");
|
|
fs.writeFileSync(path.join(dir, "deploy.yml"), "name: Deploy\n");
|
|
fs.writeFileSync(path.join(dir, "README.md"), "# docs\n"); // not a workflow
|
|
|
|
const files = collectWorkflowFiles(dir);
|
|
assert.equal(files.length, 2);
|
|
assert.ok(files.some((f) => f.endsWith("ci.yml")));
|
|
assert.ok(files.some((f) => f.endsWith("deploy.yml")));
|
|
assert.ok(!files.some((f) => f.endsWith("README.md")));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("collectWorkflowFiles: also collects .yaml extension", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-"));
|
|
try {
|
|
fs.writeFileSync(path.join(dir, "ci.yaml"), "name: CI\n");
|
|
fs.writeFileSync(path.join(dir, "deploy.yml"), "name: Deploy\n");
|
|
|
|
const files = collectWorkflowFiles(dir);
|
|
assert.equal(files.length, 2);
|
|
assert.ok(files.some((f) => f.endsWith(".yaml")));
|
|
assert.ok(files.some((f) => f.endsWith(".yml")));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("collectWorkflowFiles: returns absolute paths", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-"));
|
|
try {
|
|
fs.writeFileSync(path.join(dir, "ci.yml"), "name: CI\n");
|
|
const files = collectWorkflowFiles(dir);
|
|
assert.equal(files.length, 1);
|
|
assert.ok(path.isAbsolute(files[0]));
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("collectWorkflowFiles: empty directory returns empty array", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-workflows-test-"));
|
|
try {
|
|
const files = collectWorkflowFiles(dir);
|
|
assert.deepEqual(files, []);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// isBinaryAvailable
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("isBinaryAvailable: returns false for a nonsense binary name", () => {
|
|
// A binary named like this cannot exist in any real PATH.
|
|
const result = isBinaryAvailable("__this_binary_definitely_does_not_exist_zzz99999__");
|
|
assert.equal(result, false);
|
|
});
|
|
|
|
test("isBinaryAvailable: returns boolean (not null/undefined)", () => {
|
|
const result = isBinaryAvailable("node");
|
|
// node IS in PATH in this environment — but we only assert the type here
|
|
// to avoid environment coupling.
|
|
assert.equal(typeof result, "boolean");
|
|
});
|
|
|
|
test("isBinaryAvailable: node is available (sanity check for test environment)", () => {
|
|
// node must be in PATH for this test suite to even run.
|
|
assert.equal(isBinaryAvailable("node"), true);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// evaluateZizmorRatchet — ratchet direction:down, zizmorFindings ONLY (Etapa 2)
|
|
// Regression when measured > baseline. actionlint is reported, not ratcheted.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("evaluateZizmorRatchet: measured == baseline passes (192 vs 192)", () => {
|
|
const r = evaluateZizmor(192, 192);
|
|
assert.equal(r.regressed, false);
|
|
assert.equal(r.improved, false);
|
|
});
|
|
|
|
test("evaluateZizmorRatchet: one more than baseline is a regression (193 vs 192)", () => {
|
|
const r = evaluateZizmor(193, 192);
|
|
assert.equal(r.regressed, true, "a single new zizmor finding must block");
|
|
assert.equal(r.improved, false);
|
|
});
|
|
|
|
test("evaluateZizmorRatchet: fewer than baseline is an improvement (190 vs 192)", () => {
|
|
const r = evaluateZizmor(190, 192);
|
|
assert.equal(r.regressed, false);
|
|
assert.equal(r.improved, true);
|
|
});
|
|
|
|
test("evaluateZizmorRatchet: strict integer comparison — any increase regresses", () => {
|
|
assert.equal(evaluateZizmor(193, 192).regressed, true);
|
|
assert.equal(evaluateZizmor(192, 192).regressed, false);
|
|
assert.equal(evaluateZizmor(191, 192).regressed, false);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// readBaselineZizmorValue — tolerant read of quality-baseline.json
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function withTmpBaseline(content: string | null, fn: (p: string) => void) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workflows-baseline-"));
|
|
const p = path.join(dir, "quality-baseline.json");
|
|
if (content !== null) fs.writeFileSync(p, content);
|
|
try {
|
|
fn(p);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test("readBaselineZizmorValue: reads metrics.zizmorFindings.value", () => {
|
|
withTmpBaseline(JSON.stringify({ metrics: { zizmorFindings: { value: 192 } } }), (p) => {
|
|
assert.equal(readZizmorBaseline(p), 192);
|
|
});
|
|
});
|
|
|
|
test("readBaselineZizmorValue: missing file returns null (graceful SKIP)", () => {
|
|
assert.equal(readZizmorBaseline("/tmp/does-not-exist-88888/quality-baseline.json"), null);
|
|
});
|
|
|
|
test("readBaselineZizmorValue: missing metric returns null", () => {
|
|
withTmpBaseline(JSON.stringify({ metrics: {} }), (p) => {
|
|
assert.equal(readZizmorBaseline(p), null);
|
|
});
|
|
});
|
|
|
|
test("readBaselineZizmorValue: non-numeric value returns null", () => {
|
|
withTmpBaseline(JSON.stringify({ metrics: { zizmorFindings: { value: "192" } } }), (p) => {
|
|
assert.equal(readZizmorBaseline(p), null);
|
|
});
|
|
});
|
|
|
|
test("readBaselineZizmorValue: invalid JSON returns null (does not throw)", () => {
|
|
withTmpBaseline("{ broken", (p) => {
|
|
assert.equal(readZizmorBaseline(p), null);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// quality.yml — release PR build gate regression coverage (#7307)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
test("#7307 quality.yml adds an advisory production build for release PR code changes", () => {
|
|
const source = readQualityWorkflow();
|
|
const buildJob = source.match(/\n build:\n[\s\S]*?\n # Docs\/OpenAPI contract gates only/);
|
|
|
|
assert.match(source, /pull_request:\n\s+branches: \["release\/\*\*"\]/);
|
|
assert.ok(buildJob, "quality.yml must define the build job before docs-gates");
|
|
assert.match(buildJob[0], /name: Build \(advisory\)/);
|
|
assert.match(buildJob[0], /needs: changes/);
|
|
assert.match(buildJob[0], /needs\.changes\.outputs\.code == 'true'/);
|
|
assert.match(buildJob[0], /github\.event\.pull_request\.draft == false/);
|
|
assert.match(buildJob[0], /startsWith\(github\.head_ref, 'mergify\/merge-queue\/'\)/);
|
|
// FORK PRs ONLY (2026-08-14). build.yml's `Fast Production Build` fires on
|
|
// `push: branches: ["**"]` and runs the superset `build:release`, so own-origin branches
|
|
// were building twice; a fork's push never reaches this repo, making this their only
|
|
// pre-merge build signal — and forks are 72 of the last 100 PRs into release/**.
|
|
assert.match(
|
|
buildJob[0],
|
|
/github\.event\.pull_request\.head\.repo\.full_name != github\.repository/
|
|
);
|
|
// Runner PINNED to hosted. The self-hosted pool is 2 permanently-busy runners, where this
|
|
// job either queued for hours or was killed by cancel-in-progress — ~10-15% of runs ever
|
|
// reached a conclusion across 2026-08-13/14. It must NOT go back on the USE_VPS_RUNNER
|
|
// switch (other workflows keep that variable).
|
|
assert.match(buildJob[0], /\n {4}runs-on: ubuntu-latest\n/);
|
|
// Check the DIRECTIVES, not the prose: the comment above legitimately explains why the
|
|
// self-hosted pool was abandoned, so a naive /self-hosted/ scan over the whole block would
|
|
// match its own rationale.
|
|
const buildDirectives = buildJob[0]
|
|
.split("\n")
|
|
.filter((line) => !/^\s*#/.test(line))
|
|
.join("\n");
|
|
assert.doesNotMatch(buildDirectives, /self-hosted/);
|
|
assert.doesNotMatch(buildDirectives, /USE_VPS_RUNNER/);
|
|
// Memory provisioning mirrored from build.yml: --max-old-space-size bounds only V8's heap,
|
|
// never Turbopack's native Rust allocation (#6409), so the swapfile is the load-bearing
|
|
// half. Dropping either one puts the hosted build back at risk of an OOM.
|
|
assert.match(buildJob[0], /fallocate -l 10G \/mnt\/swapfile/);
|
|
assert.match(buildJob[0], /swapon \/mnt\/swapfile/);
|
|
assert.match(buildJob[0], /NODE_OPTIONS: "--max-old-space-size=12288"/);
|
|
assert.match(buildJob[0], /OMNIROUTE_BUILD_MEMORY_MB: "12288"/);
|
|
assert.match(buildJob[0], /continue-on-error: true/);
|
|
assert.match(buildJob[0], /uses: actions\/checkout@[0-9a-f]{40} # v7/);
|
|
assert.match(buildJob[0], /uses: actions\/setup-node@[0-9a-f]{40} # v7/);
|
|
assert.match(buildJob[0], /uses: \.\/\.github\/actions\/npm-ci-retry/);
|
|
assert.match(buildJob[0], /npm run check:node-runtime/);
|
|
assert.match(buildJob[0], /npm run build/);
|
|
assert.match(buildJob[0], /OMNIROUTE_USE_TURBOPACK: "1"/);
|
|
assert.doesNotMatch(buildJob[0], /actions\/upload-artifact/);
|
|
assert.match(
|
|
buildJob[0],
|
|
/remove\s+# continue-on-error after the production-build signal is stable/
|
|
);
|
|
});
|