Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions .github/scripts/checks-outcome.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"use strict";

// The `checks.yml` outcome join decides the consolidated lane's verdict. It is
// the one place where a bug is silently catastrophic rather than noisy: every
// composite runs under `continue-on-error: true`, so a join that forgot a
// composite reports success for a run in which that composite failed, and
// ci-status aggregates the green.
//
// The join is expression-free shell (env carries every `${{ }}` value), so this
// executes it against fixture outcomes rather than pattern-matching its text,
// and separately proves that every `continue-on-error` step in the workflow is
// wired into it.

const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { test } = require("node:test");

const { parseWorkflow } = require("./workflow-yaml.cjs");

const workflowPath = path.join(__dirname, "..", "workflows", "checks.yml");
const workflow = parseWorkflow(fs.readFileSync(workflowPath, "utf8"));
const steps = workflow.jobs.checks.steps;
const joinStep = steps.find((step) => step?.id === "outcome");
assert.ok(joinStep !== undefined, "checks.yml has no `outcome` join step");

// Every step the join owns: the composites, plus the input-combination guard
// that must go red rather than let an enabled toggle skip silently.
const joined = steps.filter(
(step) => String(step?.["continue-on-error"] ?? "") === "true",
);
const composites = joined.filter((step) => step.uses !== undefined);

// Every joined step's outcome reaches the join under the env name the join
// reads, and the join reports it under that step's own (kebab-case) name.
const environment = Object.fromEntries(
joined.map((step) => [
step.id.toUpperCase(),
`\${{ steps.${step.id}.outcome }}`,
]),
);

function runJoin(outcomes) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "checks-outcome-"));
try {
const githubOutput = path.join(directory, "github-output");
fs.writeFileSync(githubOutput, "");
const result = spawnSync("bash", ["-c", joinStep.run], {
encoding: "utf8",
env: {
...process.env,
...Object.fromEntries(
Object.keys(environment).map((name) => [
name,
outcomes[name] ?? "success",
]),
),
GITHUB_OUTPUT: githubOutput,
},
});
return {
status: result.status,
stdout: `${result.stdout}${result.stderr}`,
output: fs.readFileSync(githubOutput, "utf8"),
};
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}

test("the join reads every continue-on-error step", () => {
assert.equal(
composites.length,
12,
`expected the twelve hygiene composites, found ${composites.length}`,
);
for (const step of joined) {
const name = step.id.replaceAll("_", "-");
assert.equal(
joinStep.env[step.id.toUpperCase()],
`\${{ steps.${step.id}.outcome }}`,
`step ${step.id} is continue-on-error but its outcome never reaches the join`,
);
assert.match(
joinStep.run,
new RegExp(`^ *report ${name} "\\$${step.id.toUpperCase()}"$`, "mu"),
`step ${step.id} is never reported by the join`,
);
}
for (const step of composites) {
const name = step.id.replaceAll("_", "-");
assert.match(
String(step.uses ?? ""),
new RegExp(
`^melodic-software/ci-workflows/\\.github/actions/${name}@[0-9a-f]{40}$`,
"u",
),
// A relative `./` reference inside a CALLED workflow resolves against the
// CALLER's checkout, so it would fail in every consumer.
`step ${step.id} does not reference its composite by pinned full path`,
);
}
// The join reports nothing the steps do not produce: an env name left behind
// after a composite is removed would report a permanently empty outcome.
assert.deepEqual(
Object.keys(joinStep.env).sort(),
Object.keys(environment).sort(),
);
});

test("every composite green passes and records outcome=success", () => {
const result = runJoin({});
assert.equal(result.status, 0, result.stdout);
assert.match(result.output, /^outcome=success$/mu);
assert.match(result.stdout, /^checks passed\.$/mu);
});

test("every composite skipped passes: nothing ran, nothing failed", () => {
const skipped = Object.fromEntries(
Object.keys(environment).map((name) => [name, "skipped"]),
);
const result = runJoin(skipped);
assert.equal(result.status, 0, result.stdout);
assert.match(result.output, /^outcome=success$/mu);
});

test("one failure fails the job and names the composite", () => {
const result = runJoin({ MARKDOWN: "failure" });
assert.equal(result.status, 1, result.stdout);
// The verdict is written BEFORE the exit, so a caller reading the output on
// the failure path sees `failure` rather than nothing.
assert.match(result.output, /^outcome=failure$/mu);
assert.match(
result.stdout,
/^::error::markdown failed \(outcome=failure\)\.$/mu,
);
assert.match(result.stdout, /^::error::checks failed: markdown\.$/mu);
});

test("two failures name the first in declaration order and count the rest", () => {
const result = runJoin({ GITLEAKS: "failure", LYCHEE_OFFLINE: "failure" });
assert.equal(result.status, 1, result.stdout);
assert.match(result.output, /^outcome=failure$/mu);
assert.match(
result.stdout,
/^::error::checks failed: gitleaks \(and 1 more\)\.$/mu,
);
// Fail at the end, not at the first failure: the later composite still ran
// and its failure is still annotated.
assert.match(
result.stdout,
/^::error::lychee-offline failed \(outcome=failure\)\.$/mu,
);
});

test("an enabled toggle with no configuration fails, it does not skip quietly", () => {
// The guard step fires only when `check-jsonschema` is enabled with empty
// `check-jsonschema-files`; the join is what turns it into the job's verdict,
// so a silently skipped schema gate cannot report success.
const guard = steps.find((step) => step?.id === "configuration");
assert.ok(guard !== undefined, "checks.yml has no input-combination guard");
assert.equal(
guard.if,
`\${{ inputs.check-jsonschema && inputs.check-jsonschema-files == '' }}`,
);
const result = runJoin({ CONFIGURATION: "failure" });
assert.equal(result.status, 1, result.stdout);
assert.match(result.output, /^outcome=failure$/mu);
assert.match(result.stdout, /^::error::checks failed: configuration\.$/mu);
});

test("a composite that never ran is reported, not treated as a failure", () => {
const result = runJoin({ CHECK_JSONSCHEMA: "" });
assert.equal(result.status, 0, result.stdout);
assert.match(result.stdout, /^check-jsonschema: not-run$/mu);
});
113 changes: 97 additions & 16 deletions .github/scripts/ci-fanout-consolidation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,36 +197,117 @@ test("selector-conformance.yml matches the same concurrency pattern", () => {
);
});

test("ci.yml consolidates cheapest hygiene checks into one lane", () => {
assert.match(ciWorkflow, /^ {2}hygiene:$/mu);
assert.match(ciWorkflow, /^ {8}id: editorconfig$/mu);
assert.match(ciWorkflow, /^ {8}id: exec_bit$/mu);
assert.match(ciWorkflow, /^ {8}id: machine_specific_paths$/mu);
assert.match(ciWorkflow, /^ {8}id: eol_renormalize$/mu);
assert.match(ciWorkflow, /^ {8}id: comment_hygiene_superset$/mu);
assert.match(ciWorkflow, /^ {8}id: comment_hygiene$/mu);
assert.match(ciWorkflow, /^ {8}continue-on-error: true$/mu);
assert.match(ciWorkflow, /^ {6}- name: Aggregate hygiene checks$/mu);
assert.match(ciWorkflow, /\[\[ "\$outcome" == failure \]\]/u);
test("ci.yml consolidates the hygiene composites into the checks reusable", () => {
// The hygiene fan-out first collapsed into a local `hygiene` job (#122); it
// now lives in the `checks` reusable every consumer adopts (ci-perf Phase
// 6a), so this repository dogfoods the same contract it publishes.
assert.match(ciWorkflow, /^ {2}checks:$/mu);
assert.match(
ciWorkflow,
/^ {4}uses: \.\/\.github\/workflows\/checks\.yml$/mu,
);

// change-detection reads the PR file listing, and a called workflow cannot
// elevate: without the caller's own grant the job fails at startup.
const checksJob = ciWorkflow.slice(
ciWorkflow.search(/^ {2}checks:$/mu),
ciWorkflow.search(/^ {2}composites-head:$/mu),
);
assert.match(checksJob, /^ {6}contents: read$/mu);
assert.match(checksJob, /^ {6}pull-requests: read$/mu);
assert.match(checksJob, /^ {6}runner: ubuntu-24\.04$/mu);

for (const job of [
"hygiene",
"editorconfig",
"exec-bit",
"machine-specific-paths",
"eol-renormalize",
"comment-hygiene",
"typos",
"gitleaks",
"markdown",
"links",
]) {
assert.doesNotMatch(ciWorkflow, new RegExp(`^ {2}${job}:$`, "mu"));
}

assert.match(ciWorkflow, /^ {4}needs: \[[^\n]*\bhygiene\b[^\n]*\]$/mu);
assert.match(ciWorkflow, /^ {4}needs: \[[^\n]*\bchecks\b[^\n]*\]$/mu);
assert.match(
ciWorkflow,
/^ {10}results: [^\n]*\$\{\{ needs\.checks\.result \}\}[^\n]*$/mu,
);
for (const lane of [
"hygiene",
"editorconfig",
"exec-bit",
"comment-hygiene",
"typos",
"gitleaks",
"markdown",
"links",
]) {
assert.doesNotMatch(
ciWorkflow,
new RegExp(`needs\\.${lane}\\.result`, "u"),
);
}

// The comment-hygiene prefilter superset test is the scan's load-bearing
// invariant and cannot ride inside the reusable (a shared reusable cannot run
// a repo-local script), so it must still run somewhere in this workflow.
assert.match(
ciWorkflow,
/^ {8}run: bash \.github\/actions\/comment-hygiene\/superset-test\.sh$/mu,
);
});

test("ci.yml runs the moved composites at HEAD alongside the reusable", () => {
// checks.yml can only reach its composites at a pinned SHA (a relative path
// inside a called workflow resolves against the caller's checkout), so the
// reusable runs the bodies of the release it was pinned at. This job runs the
// same bodies from the commit under test; without it a pull request that
// breaks one of them passes this repository's own CI.
const start = ciWorkflow.search(/^ {2}composites-head:$/mu);
assert.notEqual(start, -1, "ci.yml has no composites-head job");
const composites = ciWorkflow.slice(
start,
ciWorkflow.search(/^ {2}powershell:$/mu),
);
assert.match(composites, /^ {4}name: Composites at HEAD$/mu);
assert.match(composites, /^ {4}needs: changes$/mu);

// Every composite the reusable moved off HEAD, and only those: actionlint,
// shellcheck and check-jsonschema already run at HEAD in their own jobs.
for (const composite of [
"typos",
"gitleaks",
"editorconfig",
"markdown",
"exec-bit",
"machine-specific-paths",
"eol-renormalize",
"comment-hygiene",
"lychee-offline",
]) {
assert.match(
composites,
new RegExp(`^ {8}uses: \\./\\.github/actions/${composite}$`, "mu"),
`composites-head does not run ${composite} at HEAD`,
);
}
// A pinned reference here would reintroduce the lag the job exists to close.
assert.doesNotMatch(composites, /uses: melodic-software\/ci-workflows\//u);

// The lane is only real if the required check aggregates it.
assert.match(
ciWorkflow,
/^ {4}needs: \[[^\n]*\bcomposites-head\b[^\n]*\]$/mu,
);
assert.match(
ciWorkflow,
/^ {10}results: [^\n]*\$\{\{ needs\.hygiene\.result \}\}[^\n]*$/mu,
/^ {10}results: [^\n]*\$\{\{ needs\.composites-head\.result \}\}[^\n]*$/mu,
);
assert.doesNotMatch(ciWorkflow, /needs\.editorconfig\.result/u);
assert.doesNotMatch(ciWorkflow, /needs\.exec-bit\.result/u);
assert.doesNotMatch(ciWorkflow, /needs\.comment-hygiene\.result/u);
});

test("ADR records #122 COMPLETED with Shape A done", () => {
Expand Down
Loading
Loading