diff --git a/.github/scripts/checks-outcome.test.cjs b/.github/scripts/checks-outcome.test.cjs new file mode 100644 index 0000000..374f033 --- /dev/null +++ b/.github/scripts/checks-outcome.test.cjs @@ -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); +}); diff --git a/.github/scripts/ci-fanout-consolidation.test.cjs b/.github/scripts/ci-fanout-consolidation.test.cjs index 53fa9ab..715abff 100644 --- a/.github/scripts/ci-fanout-consolidation.test.cjs +++ b/.github/scripts/ci-fanout-consolidation.test.cjs @@ -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", () => { diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..3e14b7e --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,364 @@ +name: checks + +# One job, one runner, every content-agnostic hygiene composite as a step — +# the consolidation lane the ci-perf program's Phase 6a specifies. It replaces +# the fan-out of one job (and one runner spin-up) per tool: a caller runs +# `change-detection` once here, then each composite behind its own toggle, and a +# single outcome step decides the job. +# +# Composites are the unit of reuse. This workflow never calls a per-tool +# reusable workflow: a called workflow is another job with another spin-up, +# which is the cost this lane exists to remove. +# +# `zizmor` is deliberately absent — it has no composite, only the reusable +# `.github/workflows/zizmor.yml`, which needs `security-events: write` and an +# optional SARIF upload. Callers that want it keep a separate job. +# +# Composites are referenced by FULL PATH at a pinned SHA, never +# `./.github/actions/...`: inside a CALLED workflow a relative action path +# resolves against the CALLER's checkout, so a relative reference would fail in +# every consumer (actions/runner#1348). The pins therefore lag one release +# behind the workflow that carries them; Dependabot's `github-actions` group +# bumps them like any other `uses:`. +# +# That pin lag is why ci.yml carries a `composites-head` job alongside its +# dogfood call of this workflow. A pull request against this repository that +# breaks one of the composite bodies would otherwise run the OLD body here and +# pass, so the job runs the same composites through `./.github/actions/` at +# the commit under test and restores behavioral coverage at HEAD. +# +# The promotion path is Phase 6b. GitHub's `$/` self-repository syntax +# resolves against the workflow's own repository, which would remove the pin lag +# and the extra job together, but actionlint 1.7.12 (the version this +# repository's own actionlint composite pins) rejects it as an invalid format +# with a missing ref. Three conditions promote it: actionlint ships the `$/` +# support of rhysd/actionlint#732 in a version this repository pins, +# actions/runner#4669 (composite manifests) merges, and one cross-repository +# `$/` run is measured. Until then `composites-head` stays. +# +# Skipping: every composite step is gated twice — by its boolean toggle, and by +# the `change-detection` filter group NAMED AFTER that toggle. A group the +# caller did not declare is not `'false'`, so an undeclared composite runs on +# every event. Fail-open is the composite's own documented contract: a lane +# wasted on an out-of-scope pull request beats a silently skipped check. +# +# Failure: every composite runs under `continue-on-error: true`, so one failing +# tool does not hide the rest (run-everything-fail-at-end). The `outcome` step +# joins them, names the first failure, and fails the job. +on: + workflow_call: + inputs: + runner: + description: >- + Runner label for the job. Required with no default: a hosted default + would silently bill a private caller's pool, which is exactly the + placement accident this consolidation exists to end. + type: string + required: true + filters: + description: >- + Newline-separated `change-detection` filter groups (see that action + for the grammar). A group whose name matches a composite toggle + (`markdown`, `shellcheck`, ...) gates that composite's step; a group + the caller does not declare leaves its composite ungated. Declare the + groups the caller's own lanes need too — `results` is published + verbatim as an output, so one detection pass serves both. + type: string + required: true + timeout-minutes: + description: >- + Job budget. The default matches the per-tool reusables (zizmor, + osv-scanner, semantic-pr, do-not-merge-gate); raise it for a caller + whose tree makes the sequential scans slower than the fan-out was. + type: number + default: 15 + typos: + description: Run the `typos` spell-check composite. + type: boolean + default: true + gitleaks: + description: Run the `gitleaks` secret-scan composite. + type: boolean + default: true + editorconfig: + description: Run the `editorconfig` conformance composite. + type: boolean + default: true + markdown: + description: Run the `markdown` (markdownlint-cli2) composite. + type: boolean + default: true + shellcheck: + description: Run the `shellcheck` composite. + type: boolean + default: true + actionlint: + description: Run the `actionlint` composite. + type: boolean + default: true + exec-bit: + description: Run the `exec-bit` (shebang files are executable) composite. + type: boolean + default: true + machine-specific-paths: + description: Run the `machine-specific-paths` composite. + type: boolean + default: true + eol-renormalize: + description: Run the `eol-renormalize` (index-level EOL drift) composite. + type: boolean + default: true + comment-hygiene: + description: Run the `comment-hygiene` marker scan composite. + type: boolean + default: true + lychee-offline: + description: Run the `lychee-offline` link and anchor composite. + type: boolean + default: true + check-jsonschema: + description: >- + Run the `check-jsonschema` composite. OFF by default, unlike every + other toggle: its `files` input is required and has no universal + default, and one call validates one schema family. A caller that + validates several (dependabot.yml, workflow metadata, action + metadata) passes the most valuable one here and keeps its own job for + the rest. + type: boolean + default: false + check-jsonschema-files: + description: >- + Files or globs for `check-jsonschema`. Required when + `check-jsonschema` is true: enabling the toggle and leaving this empty + FAILS the job rather than skipping the validation, so a schema gate + cannot disappear on a typo. + type: string + default: '' + check-jsonschema-builtin-schema: + description: >- + Builtin schema name for `check-jsonschema`, for example + `vendor.dependabot`. + type: string + default: '' + machine-specific-paths-exclude: + description: >- + Git pathspec exclusion for `machine-specific-paths`. A repository + whose own sources carry the literal patterns (this one does) must + exclude them or the scan self-matches. + type: string + default: '' + comment-hygiene-exclude: + description: >- + Git pathspec exclusion for `comment-hygiene`, for the same + self-matching reason. + type: string + default: '' + outputs: + results: + description: >- + The `change-detection` JSON, verbatim: a map from filter-group name + to the string "true" or "false". Gate a downstream lane with + `fromJSON(needs..outputs.results || '{}')[''] != 'false'` + — never `== 'true'`. The fail-open form matters twice over here, + because a reusable workflow publishes no outputs when its job fails. + value: ${{ jobs.checks.outputs.results }} + outcome: + description: >- + `success` when no enabled composite failed, `failure` otherwise. It + mirrors the job's own result and is published for callers that want + the verdict as a value; `needs..result` stays authoritative, + since a failed reusable-workflow job publishes no outputs at all. + value: ${{ jobs.checks.outputs.outcome }} + +permissions: + contents: read + +jobs: + checks: + runs-on: ${{ inputs.runner }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + # change-detection reads the pull request's file listing from the API. + # A called workflow cannot elevate permissions, so the CALLER's job block + # must grant both of these too. + pull-requests: read + outputs: + results: ${{ steps.detect.outputs.results }} + outcome: ${{ steps.outcome.outputs.outcome }} + steps: + # An enabled toggle whose required configuration is missing must go RED, + # not quiet. Without this the `check-jsonschema` step's own files guard + # would skip on a misspelled or forgotten `check-jsonschema-files`, the + # join would report success, and a schema gate the caller believes is on + # would be silently off. It is `continue-on-error` like the composites so + # the join owns the verdict and names it alongside them. + - name: Check the input combination + id: configuration + if: ${{ inputs.check-jsonschema && inputs.check-jsonschema-files == '' }} + continue-on-error: true + shell: bash + run: | + echo '::error::check-jsonschema is enabled but check-jsonschema-files is empty; set it, or set check-jsonschema: false.' + exit 1 + + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Detect relevant lanes + id: detect + uses: melodic-software/ci-workflows/.github/actions/change-detection@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + with: + filters: ${{ inputs.filters }} + + - name: Spell-check + id: typos + if: ${{ inputs.typos && fromJSON(steps.detect.outputs.results || '{}')['typos'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/typos@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Scan for secrets + id: gitleaks + if: ${{ inputs.gitleaks && fromJSON(steps.detect.outputs.results || '{}')['gitleaks'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/gitleaks@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Check editorconfig conformance + id: editorconfig + if: ${{ inputs.editorconfig && fromJSON(steps.detect.outputs.results || '{}')['editorconfig'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/editorconfig@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Lint markdown + id: markdown + if: ${{ inputs.markdown && fromJSON(steps.detect.outputs.results || '{}')['markdown'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/markdown@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Lint shell scripts + id: shellcheck + if: ${{ inputs.shellcheck && fromJSON(steps.detect.outputs.results || '{}')['shellcheck'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/shellcheck@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Lint workflows + id: actionlint + if: ${{ inputs.actionlint && fromJSON(steps.detect.outputs.results || '{}')['actionlint'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/actionlint@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Verify shebang files are executable + id: exec_bit + if: ${{ inputs.exec-bit && fromJSON(steps.detect.outputs.results || '{}')['exec-bit'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/exec-bit@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Check for machine-specific paths + id: machine_specific_paths + if: ${{ inputs.machine-specific-paths && fromJSON(steps.detect.outputs.results || '{}')['machine-specific-paths'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/machine-specific-paths@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + with: + exclude: ${{ inputs.machine-specific-paths-exclude }} + + - name: Check index-level EOL drift + id: eol_renormalize + if: ${{ inputs.eol-renormalize && fromJSON(steps.detect.outputs.results || '{}')['eol-renormalize'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/eol-renormalize@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Scan comment hygiene + id: comment_hygiene + if: ${{ inputs.comment-hygiene && fromJSON(steps.detect.outputs.results || '{}')['comment-hygiene'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/comment-hygiene@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + with: + exclude: ${{ inputs.comment-hygiene-exclude }} + + - name: Check links and anchors (offline) + id: lychee_offline + if: ${{ inputs.lychee-offline && fromJSON(steps.detect.outputs.results || '{}')['lychee-offline'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/lychee-offline@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + + - name: Validate against JSON Schema + id: check_jsonschema + # The empty-files term keeps this step from failing on a configuration + # mistake rather than on a finding. It is not how that mistake is + # caught: the `configuration` guard at the top of the job fails the + # whole run for it, so the skip here is never silent. + if: ${{ inputs.check-jsonschema && inputs.check-jsonschema-files != '' && fromJSON(steps.detect.outputs.results || '{}')['check-jsonschema'] != 'false' }} + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@449157aaa8e30f7b1457305d8048ebe6168e174a # v0.20.0 + with: + files: ${{ inputs.check-jsonschema-files }} + builtin-schema: ${{ inputs.check-jsonschema-builtin-schema }} + + # The join. `!cancelled()` rather than `always()`: a cancelled run has no + # verdict to report, but a failed composite must still be named. The + # `outcome` output is written BEFORE the exit so a caller reading it gets + # the verdict on the failure path too (when GitHub propagates outputs from + # a failed reusable-workflow job at all — `needs..result` is the + # reliable signal). + - name: Join composite outcomes + id: outcome + if: ${{ !cancelled() }} + shell: bash + env: + CONFIGURATION: ${{ steps.configuration.outcome }} + TYPOS: ${{ steps.typos.outcome }} + GITLEAKS: ${{ steps.gitleaks.outcome }} + EDITORCONFIG: ${{ steps.editorconfig.outcome }} + MARKDOWN: ${{ steps.markdown.outcome }} + SHELLCHECK: ${{ steps.shellcheck.outcome }} + ACTIONLINT: ${{ steps.actionlint.outcome }} + EXEC_BIT: ${{ steps.exec_bit.outcome }} + MACHINE_SPECIFIC_PATHS: ${{ steps.machine_specific_paths.outcome }} + EOL_RENORMALIZE: ${{ steps.eol_renormalize.outcome }} + COMMENT_HYGIENE: ${{ steps.comment_hygiene.outcome }} + LYCHEE_OFFLINE: ${{ steps.lychee_offline.outcome }} + CHECK_JSONSCHEMA: ${{ steps.check_jsonschema.outcome }} + run: | + set -euo pipefail + first='' + failures=0 + report() { + local name="$1" + local result="${2:-not-run}" + if [[ "$result" == failure ]]; then + failures=$((failures + 1)) + if [[ -z "$first" ]]; then + first="$name" + fi + echo "::error::$name failed (outcome=$result)." + else + echo "$name: $result" + fi + } + report configuration "$CONFIGURATION" + report typos "$TYPOS" + report gitleaks "$GITLEAKS" + report editorconfig "$EDITORCONFIG" + report markdown "$MARKDOWN" + report shellcheck "$SHELLCHECK" + report actionlint "$ACTIONLINT" + report exec-bit "$EXEC_BIT" + report machine-specific-paths "$MACHINE_SPECIFIC_PATHS" + report eol-renormalize "$EOL_RENORMALIZE" + report comment-hygiene "$COMMENT_HYGIENE" + report lychee-offline "$LYCHEE_OFFLINE" + report check-jsonschema "$CHECK_JSONSCHEMA" + if [[ "$failures" -eq 0 ]]; then + echo "outcome=success" >>"$GITHUB_OUTPUT" + echo "checks passed." + exit 0 + fi + echo "outcome=failure" >>"$GITHUB_OUTPUT" + if [[ "$failures" -gt 1 ]]; then + echo "::error::checks failed: $first (and $((failures - 1)) more)." + else + echo "::error::checks failed: $first." + fi + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32fff31..1ba8b30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,12 +57,22 @@ concurrency: # Each gate reads `!= 'false'`, never `== 'true'`: with `!cancelled()` a # failed or unset detection output runs the lane rather than skipping it, and # `changes` itself is aggregated by `ci-status` so a broken detection goes -# red instead of riding fail-open to silent green forever. Content-agnostic -# cheapest hygiene checks (editorconfig, exec-bit, machine-specific-paths, -# eol-renormalize, comment-hygiene) share one `hygiene` lane with -# continue-on-error + final outcome aggregation. Other unconditional dogfood -# lanes (typos, gitleaks, links, action-metadata-filename) stay separate so -# each composite keeps a dedicated failure surface. osv-scanner is also +# red instead of riding fail-open to silent green forever. +# +# The content-agnostic hygiene composites (typos, gitleaks, editorconfig, +# markdown, exec-bit, machine-specific-paths, eol-renormalize, comment-hygiene, +# lychee-offline, and one check-jsonschema family) run inside the `checks` +# reusable, which is the consolidation lane every consumer adopts — dogfooding +# it here is the only way its contract is exercised before they do. It runs its +# own change-detection pass for the composites it owns; `changes` stays for the +# language and toolchain lanes this repo fans out and Phase 6b converges. The +# `composites-head` job runs those same composite bodies from this commit, +# because the reusable can only reach them at a pinned SHA and would otherwise +# let a broken body pass its own repository's CI. +# `actionlint` and `shellcheck` stay separate jobs with their toggles off: each +# carries extra dogfood steps that need the composite's pinned binary on PATH, +# which no `uses:` in a shared reusable can hand back. `action-metadata-filename` +# stays separate for its dedicated failure surface, and osv-scanner is also # ungated — see its job comment. jobs: changes: @@ -88,10 +98,6 @@ jobs: # scoped lane; language groups also glob their file types repo-wide, # not just the fixture trees the lanes currently point at. filters: | - markdown: - .github/** - **/*.md - **/.markdownlint* powershell: .github/** fixtures/composite-action/** @@ -170,9 +176,62 @@ jobs: fixtures/composite-action/** **/action.yml - markdown: + # The consolidation lane (ci-perf Phase 6a). A local reusable-workflow + # reference executes checks.yml from this exact commit, so the dogfood proves + # the contract a consumer pins by tag. It runs its own change-detection pass + # over the groups its composites are gated on; the groups this repo's other + # lanes read stay in `changes`. + checks: + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} + permissions: + contents: read + # change-detection lists the pull request's files. A called workflow + # cannot elevate, so the grant has to be here as well as in checks.yml. + pull-requests: read + uses: ./.github/workflows/checks.yml + with: + # This public repository's hosted lane is free; private consumers pass + # their hardcoded fleet label instead. + runner: ubuntu-24.04 + # actionlint and shellcheck keep their own jobs here: both carry extra + # dogfood steps that run against the pinned binary the composite installs. + actionlint: false + shellcheck: false + # One schema family per call. The remaining two (workflow and action + # metadata) stay in the `jsonschema` job. + check-jsonschema: true + check-jsonschema-files: .github/dependabot.yml + check-jsonschema-builtin-schema: vendor.dependabot + # The bundled pattern script and marker policy contain the literal regex + # bodies and marker tokens, so each scan skips its own action's source to + # avoid self-matching. + machine-specific-paths-exclude: ':(exclude).github/actions/machine-specific-paths/**' + comment-hygiene-exclude: ':(exclude).github/actions/comment-hygiene/**' + # Only the composites this repo gates on file class get a group; the rest + # are ungated here exactly as their separate jobs were. + filters: | + markdown: + .github/** + **/*.md + **/.markdownlint* + check-jsonschema: + .github/** + + # The pin-lag counterweight to `checks`. Inside a CALLED workflow a relative + # action path resolves against the CALLER's checkout, so checks.yml references + # its composites by full path at a pinned SHA and therefore runs the bodies of + # the release it was pinned at, not the bodies in this commit. Without this + # job a pull request that breaks one of those composite bodies would go green + # here while the dogfood ran the old body. These nine run from this commit, so + # their behavior is covered at HEAD as it was before the consolidation. + # `actionlint`, `shellcheck` and `check-jsonschema` are deliberately absent: + # each already runs at HEAD in its own job. Gated on the `actionlint` group, + # which is the narrowest `changes` group naming composite actions; every group + # in that job includes `.github/**`, so any composite edit selects this lane. + composites-head: + name: Composites at HEAD needs: changes - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['markdown'] != 'false' }} + if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['actionlint'] != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -180,8 +239,31 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Spell-check + uses: ./.github/actions/typos + - name: Scan for secrets + uses: ./.github/actions/gitleaks + - name: Check editorconfig conformance + uses: ./.github/actions/editorconfig - name: Lint markdown uses: ./.github/actions/markdown + - name: Verify shebang files are executable + uses: ./.github/actions/exec-bit + - name: Check for machine-specific paths + # Same exclusions the `checks` caller passes: the bundled pattern script + # and marker policy carry the literal bodies, so each scan skips its own + # action's source to avoid self-matching. + uses: ./.github/actions/machine-specific-paths + with: + exclude: ':(exclude).github/actions/machine-specific-paths/**' + - name: Check index-level EOL drift + uses: ./.github/actions/eol-renormalize + - name: Scan comment hygiene + uses: ./.github/actions/comment-hygiene + with: + exclude: ':(exclude).github/actions/comment-hygiene/**' + - name: Check links and anchors (offline) + uses: ./.github/actions/lychee-offline powershell: needs: changes @@ -204,18 +286,6 @@ jobs: - name: Prove a broken composite pwsh block fails run: pwsh -File .github/scripts/Invoke-CompositeRunPssa.test.ps1 - links: - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Check out - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Check links and anchors (offline) - uses: ./.github/actions/lychee-offline - reference-integrity: needs: changes if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['reference-integrity'] != 'false' }} @@ -331,30 +401,6 @@ jobs: with: project: fixtures/dotnet/good/good.csproj - typos: - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Check out - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Spell-check - uses: ./.github/actions/typos - - gitleaks: - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Check out - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Scan for secrets - uses: ./.github/actions/gitleaks - actionlint: needs: changes if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && !cancelled() && fromJSON(needs.changes.outputs.results || '{}')['actionlint'] != 'false' }} @@ -423,11 +469,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Validate dependabot.yml - uses: ./.github/actions/check-jsonschema - with: - builtin-schema: vendor.dependabot - files: .github/dependabot.yml + # dependabot.yml is validated by the `checks` lane instead: one + # check-jsonschema call carries one schema family, and that is the family + # a consumer of the reusable is most likely to want. - name: Validate workflows uses: ./.github/actions/check-jsonschema with: @@ -451,91 +495,6 @@ jobs: - name: Reject action.yaml metadata (action.yml is enforced exclusively) uses: ./.github/actions/action-metadata-filename - # Cheapest unconditional hygiene composites share one worker (#122). Each - # check step is continue-on-error with an id; the final aggregation fails on - # any steps..outcome == 'failure' (run-everything-fail-at-end). Language - # / toolchain dogfood jobs stay separate so composite contracts keep a - # dedicated failure surface. - hygiene: - if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} - runs-on: ubuntu-24.04 - # Sequential steps replace five formerly-parallel 15m jobs; 20m keeps - # margin as the cheapest scans grow with the tree. - timeout-minutes: 20 - steps: - - name: Check out - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Check editorconfig conformance - id: editorconfig - continue-on-error: true - uses: ./.github/actions/editorconfig - - name: Verify shebang files are executable - id: exec_bit - continue-on-error: true - uses: ./.github/actions/exec-bit - - name: Check for machine-specific paths - id: machine_specific_paths - continue-on-error: true - # The bundled pattern script contains the literal regex bodies, so skip - # the action's own dir to avoid self-matching. - uses: ./.github/actions/machine-specific-paths - with: - exclude: ':(exclude).github/actions/machine-specific-paths/**' - - name: Check index-level EOL drift - id: eol_renormalize - continue-on-error: true - uses: ./.github/actions/eol-renormalize - - name: Verify prefilter is a superset of the policy library - id: comment_hygiene_superset - continue-on-error: true - # Self-enforces the scan's load-bearing invariant: the coarse git-grep - # prefilter must admit every line the bundled default policy flags, or - # the gate fails open. - shell: bash - run: bash .github/actions/comment-hygiene/superset-test.sh - - name: Scan comment hygiene - id: comment_hygiene - continue-on-error: true - # The bundled scan and policy reference the literal marker tokens in - # their own comments, so skip the action's source to avoid self-matching. - uses: ./.github/actions/comment-hygiene - with: - exclude: ':(exclude).github/actions/comment-hygiene/**' - - name: Aggregate hygiene checks - if: always() - shell: bash - env: - EDITORCONFIG: ${{ steps.editorconfig.outcome }} - EXEC_BIT: ${{ steps.exec_bit.outcome }} - MACHINE_SPECIFIC_PATHS: ${{ steps.machine_specific_paths.outcome }} - EOL_RENORMALIZE: ${{ steps.eol_renormalize.outcome }} - COMMENT_HYGIENE_SUPERSET: ${{ steps.comment_hygiene_superset.outcome }} - COMMENT_HYGIENE: ${{ steps.comment_hygiene.outcome }} - run: | - set -euo pipefail - failed=0 - report() { - local name="$1" - local outcome="$2" - if [[ "$outcome" == failure ]]; then - echo "::error::$name failed (outcome=$outcome)." - failed=1 - else - echo "$name: $outcome" - fi - } - report editorconfig "$EDITORCONFIG" - report exec-bit "$EXEC_BIT" - report machine-specific-paths "$MACHINE_SPECIFIC_PATHS" - report eol-renormalize "$EOL_RENORMALIZE" - report comment-hygiene-superset "$COMMENT_HYGIENE_SUPERSET" - report comment-hygiene "$COMMENT_HYGIENE" - if [[ "$failed" -ne 0 ]]; then - exit 1 - fi - # Advisory (#208): reject hand-edits of sync-manifest-managed files. Not in # ci-status yet — promote after a clean soak. Exempts standards-sync labelled / # melodic-standards-sync[bot] PRs. @@ -623,6 +582,12 @@ jobs: run: bash .github/scripts/release-tag-drift.test.sh - name: Test Gitleaks scan policy guard run: bash .github/actions/gitleaks/scan.test.sh + - name: Verify the comment-hygiene prefilter is a superset of the policy library + # Self-enforces the scan's load-bearing invariant: the coarse git-grep + # prefilter must admit every line the bundled default policy flags, or + # the gate fails open. It lives here now that the scan itself runs + # inside the `checks` reusable, which cannot run a repo-local script. + run: bash .github/actions/comment-hygiene/superset-test.sh - name: Test the pull-request contract runner run: bash .github/actions/pr-contract/run.test.sh - name: Test the ci-status aggregation and carry-forward runner @@ -704,7 +669,7 @@ jobs: # `changes` is aggregated alongside the lanes it gates: a failed detection # job fails ci-status even though every gated lane fails open and runs, so # a broken filter config cannot ride the fallback to green indefinitely. - needs: [changes, markdown, powershell, links, reference-integrity, ruff, pyright, biome, tsc, dotnet-build, dotnet-format, typos, gitleaks, shellcheck, shfmt, selector-contract, actionlint, lefthook-validate, jsonschema, action-metadata-filename, hygiene, pester, go-quality-dogfood, zizmor, osv-scanner] + needs: [changes, checks, composites-head, powershell, reference-integrity, ruff, pyright, biome, tsc, dotnet-build, dotnet-format, shellcheck, shfmt, selector-contract, actionlint, lefthook-validate, jsonschema, action-metadata-filename, pester, go-quality-dogfood, zizmor, osv-scanner] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -728,4 +693,4 @@ jobs: if: ${{ !cancelled() }} uses: ./.github/actions/ci-status with: - results: ${{ needs.changes.result }} ${{ needs.markdown.result }} ${{ needs.powershell.result }} ${{ needs.links.result }} ${{ needs.reference-integrity.result }} ${{ needs.ruff.result }} ${{ needs.pyright.result }} ${{ needs.biome.result }} ${{ needs.tsc.result }} ${{ needs.dotnet-build.result }} ${{ needs.dotnet-format.result }} ${{ needs.typos.result }} ${{ needs.gitleaks.result }} ${{ needs.shellcheck.result }} ${{ needs.shfmt.result }} ${{ needs.selector-contract.result }} ${{ needs.actionlint.result }} ${{ needs.lefthook-validate.result }} ${{ needs.jsonschema.result }} ${{ needs.action-metadata-filename.result }} ${{ needs.hygiene.result }} ${{ needs.pester.result }} ${{ needs.go-quality-dogfood.result }} ${{ needs.zizmor.result }} ${{ needs.osv-scanner.result }} + results: ${{ needs.changes.result }} ${{ needs.checks.result }} ${{ needs.composites-head.result }} ${{ needs.powershell.result }} ${{ needs.reference-integrity.result }} ${{ needs.ruff.result }} ${{ needs.pyright.result }} ${{ needs.biome.result }} ${{ needs.tsc.result }} ${{ needs.dotnet-build.result }} ${{ needs.dotnet-format.result }} ${{ needs.shellcheck.result }} ${{ needs.shfmt.result }} ${{ needs.selector-contract.result }} ${{ needs.actionlint.result }} ${{ needs.lefthook-validate.result }} ${{ needs.jsonschema.result }} ${{ needs.action-metadata-filename.result }} ${{ needs.pester.result }} ${{ needs.go-quality-dogfood.result }} ${{ needs.zizmor.result }} ${{ needs.osv-scanner.result }} diff --git a/README.md b/README.md index b9fddd5..d3b56ad 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,68 @@ Hosted workflow defaults use explicit GA operating-system generations keeps hosted/self-hosted parity reviews tied to a declared image contract while GitHub continues the normal weekly patching of each hosted image generation. +- `.github/workflows/checks.yml` — the consolidated hygiene lane: **one job, one + runner spin-up**, `change-detection` once, then every content-agnostic + composite as a step. It replaces a fan-out of one job per tool, which is the + cost it exists to remove, and it never calls a per-tool reusable workflow — + composites are the unit of reuse. Required inputs: `runner` (no default; a + hosted default would silently bill a private caller's pool) and `filters` + (`change-detection` filter groups). `timeout-minutes` defaults to `15`. + Twelve boolean toggles — `typos`, `gitleaks`, `editorconfig`, `markdown`, + `shellcheck`, `actionlint`, `exec-bit`, `machine-specific-paths`, + `eol-renormalize`, `comment-hygiene`, `lychee-offline`, `check-jsonschema` — + turn composites off; all default `true` except `check-jsonschema`, whose + `files` input is required and has no universal default (pass + `check-jsonschema-files` and `check-jsonschema-builtin-schema`; one call + carries one schema family). Enabling it with no `check-jsonschema-files` + **fails the job** rather than skipping the validation: a gate that disappears + on a typo is the failure mode this lane exists to prevent. + `machine-specific-paths-exclude` and + `comment-hygiene-exclude` pass a Git pathspec exclusion to those two scans. + Every other composite input keeps its composite-side default. **Skipping is + by filter group name**: a composite step is skipped when the caller declares + a group named exactly after its toggle and that group evaluated `false`; an + undeclared group leaves the composite ungated, because fail-open is the + detection contract. Outputs: `results` (the detection JSON, verbatim) and + `outcome` (`success` or `failure`). Every composite runs under + `continue-on-error: true` and one join step names the first failure and fails + the job, so one failing tool never hides the rest. Gate downstream lanes with + `fromJSON(needs.checks.outputs.results || '{}')[''] != 'false'`: a + reusable workflow publishes no outputs when its job fails, so + `needs.checks.result` stays the authoritative verdict. The caller's job block + must grant `contents: read` and `pull-requests: read` — a called workflow + cannot elevate, and the detection pass reads the pull request's file listing. + `zizmor` is not among the toggles: it has no composite, only the reusable + below, so callers that want it keep a separate job. + + The composites run by full path at a pinned SHA, because a relative action + path inside a called workflow resolves against the caller's checkout. A + tagged release therefore runs the composite bodies its pins name, one tag + behind after a bump, and Dependabot's `github-actions` group moves those pins + like any other reference. That pin lag is why this repository keeps a + `composites-head` job in its own `ci.yml`: it runs the same composites + through `./.github/actions/` so a pull request that changes a composite + body is still exercised at HEAD instead of passing against the pinned copy. + Phase 6b retires that job when GitHub's `$/` self-repository syntax becomes + usable, which needs three things: actionlint shipping the `$/` support of + rhysd/actionlint#732 in a version this repository pins, actions/runner#4669 + merging, and one measured cross-repository `$/` run. + + ```yaml + jobs: + checks: + permissions: + contents: read + pull-requests: read + uses: melodic-software/ci-workflows/.github/workflows/checks.yml@ + with: + runner: ubuntu-24.04 + filters: | + markdown: + .github/** + **/*.md + ``` + - `.github/workflows/pulumi-version-drift-check.yml` — reusable-only maintenance job for GitHub IaC callers. It accepts only a hosted default-branch push, schedule, or manual dispatch, compares the exact `.pulumi.version` pin with