Skip to content

feat(workflows): add the checks reusable and dogfood it - #551

Merged
kyle-sexton merged 5 commits into
mainfrom
ci-perf/phase-6a-checks
Sep 5, 2026
Merged

feat(workflows): add the checks reusable and dogfood it#551
kyle-sexton merged 5 commits into
mainfrom
ci-perf/phase-6a-checks

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

No related issue: melodic-software/github-iac#378 tracks the ci-perf program (Phase 6a)

Summary

checks.yml is the consolidated hygiene lane the ci-perf program's Phase 6a
specifies, and the one the private repositories adopt in Phase 4: one job, one
runner spin-up
. It runs change-detection once against caller-supplied
filters, then each content-agnostic composite as a step, and publishes
results (the detection JSON, verbatim) and outcome.

  • Twelve boolean toggles (typos, gitleaks, editorconfig, markdown,
    shellcheck, actionlint, exec-bit, machine-specific-paths,
    eol-renormalize, comment-hygiene, lychee-offline, check-jsonschema),
    all default true except check-jsonschema.
  • Each step is skipped when the caller declares a filter group named after its
    toggle
    and that group evaluated false. An undeclared group leaves the
    composite ungated: fail-open is the detection contract.
  • Every composite runs under continue-on-error: true; one join step reports
    each outcome, names the first failure, and fails the job. Run-everything,
    fail-at-end — one failing tool never hides the rest.
  • permissions are contents: read plus pull-requests: read (the detection
    pass reads the pull request's file listing). A called workflow cannot elevate,
    so the caller's job block grants both.
  • runner is required with no default: a hosted default would silently bill a
    private caller's pool, which is the placement accident this consolidation
    exists to end. timeout-minutes defaults to 15, the per-tool reusable value.

Composites stay the unit of reuse — the reusable never calls a per-tool reusable
workflow, which is another job and another spin-up.

Two composites of the plan's thirteen are not toggles:

  • zizmor has no composite, only .github/workflows/zizmor.yml, which needs
    security-events: write and an optional SARIF upload. Callers keep a separate
    job (this repository does).
  • check-jsonschema defaults off: its files input is required with no
    universal default, and one call validates one schema family.

A composites-head job keeps the moved composites covered at HEAD. Because
checks.yml can only reach its composites at a pinned SHA (below), this
repository's dogfood ran nine composite bodies at
449157aaa8e30f7b1457305d8048ebe6168e174a, not at the commit under test, so a
pull request breaking one of their run.sh bodies could have gone green here.
The new job (display name "Composites at HEAD") runs typos, gitleaks,
editorconfig, markdown, exec-bit, machine-specific-paths,
eol-renormalize, comment-hygiene and lychee-offline through
./.github/actions/<x> with the same exclusions the checks caller passes.
actionlint, shellcheck and check-jsonschema are absent because each
already runs at HEAD in its own job. ci-status aggregates the lane.

GitHub's $/ self-repository syntax is deferred to Phase 6b. It would
resolve against the workflow's own repository and remove both the pin lag and
the extra job, but actionlint 1.7.12, the version this repository's own
actionlint composite pins, rejects it: specifying action "$/..." in invalid format because ref is missing. Promotion needs three things: actionlint
shipping the $/ support of rhysd/actionlint#732 in a version this repository
pins, actions/runner#4669 (composite manifests) merging, and one measured
cross-repository $/ run.

Fix

The 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 (actions/runner#1348),
so a relative reference would fail in every consumer. The pins therefore lag one
release behind the workflow carrying them; Dependabot's existing
github-actions group bumps them like any other uses:. The composites-head
job is the counterweight to that lag inside this repository, and the checks.yml
header records both the reason it exists and the Phase 6b promotion trigger.

Dogfood in this repository's own ci.yml: the checks job replaces the typos,
gitleaks, markdown, links and hygiene jobs and the Validate dependabot.yml step, and ci-status aggregates needs.checks.result in their
place. actionlint and shellcheck keep their own jobs with those toggles off —
each carries extra dogfood steps that run against the pinned binary the composite
installs, which no uses: in a shared reusable can hand back. The
comment-hygiene prefilter superset test moves to selector-contract, since a
shared reusable cannot run a repo-local script. changes stays for the language
and toolchain lanes this repository fans out and Phase 6b converges; the checks
lane runs its own detection pass over the groups its composites are gated on.
composites-head copies its sibling lanes (ubuntu-24.04, needs: changes,
timeout-minutes: 15, the contract-only gate, the pinned checkout with
persist-credentials: false) and gates on the actionlint change-detection
group, the narrowest group naming composite actions; every group in changes
includes .github/**, so any composite edit selects the lane.

.github/scripts/checks-outcome.test.cjs extracts the join's run: block with
workflow-yaml.cjs and executes it under fixture outcomes: all green, all
skipped, one failure, two failures (names the first, counts the rest), and a step
that never ran. It also proves structurally that every continue-on-error step
reaches the join and references its composite by pinned full path, so a composite
added without a join entry fails the build instead of riding to silent green.

ci-fanout-consolidation.test.cjs is updated where the job set legitimately
changed: it now locks the checks caller's shape, its two permission grants, and
the absence of the five replaced jobs from needs and from the aggregation. A
new case locks composites-head: its display name, its needs: changes, all
nine uses: ./.github/actions/<x> references, the absence of any pinned
melodic-software/ci-workflows/... reference inside it, and its presence in both
the ci-status needs list and the aggregated results. The contract-only
predicate gate is unchanged and still asserted on every job including the new
one.

.github/dependabot.yml already carries a github-actions group; no change was
needed.

Verification

  • ls .github/workflows/checks.yml — succeeds.
  • yq '.on.workflow_call.outputs | keys' .github/workflows/checks.yml — prints
    results and outcome.
  • yq '.jobs[] | select(.uses != null) | .uses' .github/workflows/ci.yml
    includes ./.github/workflows/checks.yml.
  • actionlint .github/workflows/checks.yml .github/workflows/ci.yml — clean
    (actionlint 1.7.12).
  • zizmor --persona regular .github/workflows/checks.yml — no findings. On
    ci.yml the pre-existing low self-repository findings went 59 to 46 with the
    consolidation and back to 55 with composites-head, since that audit is the
    one recommending $/, the syntax actionlint 1.7.12 rejects. The lane is
    advisory (fail-on-severity defaults to never).
  • git diff 449157aaa8e30f7b1457305d8048ebe6168e174a..HEAD --stat -- .github/actions/ — empty. No composite body changed since the pin, so
    checks.yml at the next tag runs bodies byte-identical to HEAD and no
    follow-up pin bump is owed after the tag.
  • node --test .github/scripts/*.test.cjs — 782 tests, 777 pass, 5 fail, all
    five in probe-billing-usage.test.cjs and failing identically on an untouched
    tree (environment, not this change).
  • npx @biomejs/biome@2.5.11 ci --config-path=fixtures/typescript/good/biome.json --error-on-warnings fixtures/typescript/good .github/scripts — clean.
  • typos, editorconfig-checker and markdownlint-cli2 — clean.
  • This pull request's own run is the live proof of both lanes: the checks job
    for the reusable's contract, and Composites at HEAD for the nine composite
    bodies at the commit under test.

Related

Refs melodic-software/github-iac#378

One job, one runner spin-up: `checks.yml` runs `change-detection` once and then
every content-agnostic hygiene composite as a step, each behind a boolean toggle
and the change-detection filter group named after that toggle, each under
`continue-on-error: true`, joined by one outcome step that names the first
failure. Outputs `results` (the detection JSON, verbatim) and `outcome`.

Composites stay the unit of reuse: the reusable never calls a per-tool reusable
workflow. They are referenced by full path at a pinned SHA rather than `./`,
because inside a CALLED workflow a relative action path resolves against the
CALLER's checkout (actions/runner#1348) and would fail in every consumer.

`zizmor` is absent: it has no composite, only the reusable that needs
`security-events: write`. `check-jsonschema` is the one toggle that defaults
off; its `files` input is required and one call carries one schema family.

ci.yml dogfoods it: `checks` replaces the `typos`, `gitleaks`, `markdown`,
`links` and `hygiene` jobs and the dependabot.yml schema step, and `ci-status`
aggregates it in their place. `actionlint` and `shellcheck` keep their own jobs
with those toggles off, since both carry extra dogfood steps that need the
composite's pinned binary on PATH.

Refs melodic-software/github-iac#378

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T21:16:36.848457Z 8c66413 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 13s —— View job


Code review: PR #551

No correctness bugs found. This is a careful, well-tested consolidation — traced the fan-out replacement end to end and everything checks out.

What I verified:

  • Every composite checks.yml references (typos, gitleaks, editorconfig, markdown, shellcheck, actionlint, exec-bit, machine-specific-paths, eol-renormalize, comment-hygiene, lychee-offline, check-jsonschema) exists at .github/actions/<name>/action.yml, and each is pinned by full path + 40-char SHA — checks.yml#L232-L233 is representative. The self-referential pin (pointing at the prior commit, not HEAD) is explained and is the only way around Local composite actions always relative to top level repository actions/runner#1348 for a same-repo dogfood.
  • checks.yml's twelve continue-on-error: true steps match the count checks-outcome.test.cjs asserts (>= 12), and the header-comment's own use of the phrase doesn't get miscounted, since the test filters on the actual step property, not text search.
  • ci.yml's checks: job (ci.yml#L181-L214) carries forward every filter behavior from the jobs it replaces: markdown and check-jsonschema are the only declared groups (matching what typos/gitleaks/links/hygiene had — unconditional, fail-open) and check-jsonschema: .github/** matches the pre-existing jsonschema group exactly, so dependabot.yml validation keeps the same trigger surface it had inside the old jsonschema job.
  • The exec_bit/machine-specific-paths-exclude/comment-hygiene-exclude values carried over byte-for-byte from the deleted hygiene job.
  • Manually walked every regex in ci-fanout-consolidation.test.cjs's rewritten test against the actual ci.yml content (indentation, job absence, needs/results lines) — all match.
  • The outcome step's !cancelled() design: if change-detection itself fails (no continue-on-error), all composite steps auto-skip (implicit success()), the join still runs and would report outcome=success internally, but the job's real conclusion is failure and a failed reusable-workflow job publishes no outputs — which is exactly why the docs tell callers to key off needs.<job>.result, not outputs.outcome. Not a bug, just worth confirming the escape hatch is real, and it is.

One behavioral note (not a bug): the comment-hygiene prefilter superset test moved from the always-run hygiene job into selector-contract (ci.yml#L501-L534), which is gated on .github/**/.node-version changes rather than unconditional. That's a strictly safe narrowing, not a coverage gap — the invariant it protects can only break via edits under .github/actions/comment-hygiene/**, which .github/** fully covers. Flagging only because it's a quiet gating change the PR description doesn't call out explicitly.

Not independently verified: this review environment's Bash tool required interactive approval for node --test and actionlint/zizmor invocations, which wasn't available here, so I could not re-run .github/scripts/*.test.cjs or the linters myself. I instead hand-traced every assertion in the two test files against the actual workflow YAML (see above) rather than relying on the PR description's reported pass counts.
· ci-perf/phase-6a-checks

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Security review in progress

  • Gather context (PR diff, changed files)
  • Review .github/workflows/checks.yml (new reusable workflow)
  • Review .github/workflows/ci.yml changes (dogfooding)
  • Review .github/scripts/checks-outcome.test.cjs and ci-fanout-consolidation.test.cjs
  • Review README changes
  • Post findings

Branch

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c664132c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/checks.yml
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 10s —— View job


Security review complete

  • Gather context (PR diff, changed files, CLAUDE.md rules)
  • Run /review:security-review per custom instructions
  • Review .github/workflows/checks.yml (new reusable workflow)
  • Review .github/workflows/ci.yml changes (dogfooding)
  • Review .github/scripts/checks-outcome.test.cjs and ci-fanout-consolidation.test.cjs
  • Review README changes
  • Post findings

I reviewed this PR under the /review:security-review criteria: logic/trust-boundary/authz gaps, injection surfaces, secret handling, and dangerous Actions patterns. Supply-chain/pinning, permission-widening, and trigger-pattern concerns are deferred to zizmor's advisory lane per the skill's scope, and general code-quality/correctness issues (e.g. the check-jsonschema-files fail-open already flagged by Codex) belong to /review:code-review, not this lane.

No security issues found.

What I checked specifically:

  • No pull_request_target/workflow_run, no elevated trigger patterns — checks.yml is workflow_call-only.
  • Permissions are least-privilege and non-elevating: checks.yml grants only contents: read at the workflow level and contents: read + pull-requests: read at the job level (needed because change-detection reads the PR file listing). The ci.yml caller correctly mirrors both grants — a called workflow can't elevate beyond what the caller's job block gives it, and neither does here.
  • No script injection surface: the outcome join step is the only run: block in the new workflow. It reads exclusively from env: populated with steps.*.outcome (a fixed enum: success/failure/skipped/cancelled), never interpolates ${{ }} expressions or caller-supplied strings directly into the shell body — no attacker-influenced text reaches bash -c.
  • No secrets referenced anywhere in checks.yml; composites run with the default token scope only.
  • Caller-supplied strings (filters, check-jsonschema-files, check-jsonschema-builtin-schema, machine-specific-paths-exclude, comment-hygiene-exclude) are passed through with: to composite actions (which weren't modified in this PR and are out of this review's scope), not interpolated into any run: block in the changed files.
  • Actions are referenced by full path at pinned 40-char SHAs (melodic-software/ci-workflows/.github/actions/<name>@<sha>), consistent with the documented rationale that a relative ./ reference would resolve against the wrong checkout in a called workflow.
  • Fork-PR handling in ci.yml is unchanged by this diff and remains correct: the contract-only gate is scoped to same-repository PRs, and a fork's token stays read-only regardless of any permissions: requested.
  • The two test files (checks-outcome.test.cjs, ci-fanout-consolidation.test.cjs) only read repo-local, trusted files and spawnSync fixture-controlled env values — no untrusted input reaches subprocess execution.

Branch ·

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 2 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Last security-reviewed head: d5bbab496527ff9ff19df4ae71d912069c46ef9b. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

An enabled `check-jsonschema` with an empty or misspelled
`check-jsonschema-files` skipped the validation step and the join reported
success, so a schema gate the caller believed was on could be silently off — the
failure mode this lane exists to prevent. A `continue-on-error` guard step now
fires on that combination and the join names it `configuration`, so the job goes
red and says why. Raised by the Codex reviewer on this pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 56s —— View job


Security review complete

  • Gather context (PR diff, prior review state)
  • Apply skip gate (commits since last reviewed head)
  • Review new commit since last security review
  • Post findings

Skip gate applied: the last successful security review on this PR covered head 74bca0ba7b218127ef8ec4b2b609d2bf1fde7c2d. Per that review's own note, only commits since that SHA are in scope for this pass. The sole commit since then is 4783cc4 ("fix(workflows): fail checks when a schema toggle has no files"), which adds the "Check the input combination" guard step addressing Codex's check-jsonschema-files fail-open finding.

No security issues found in that commit. Specifically checked:

  • The new run: block (checks.yml#L179-L186) contains only a literal ::error:: string with no ${{ }} expression interpolated into the shell body — no script-injection surface via the github context or caller-supplied strings.
  • The guard's if: condition (inputs.check-jsonschema && inputs.check-jsonschema-files == '') is a boolean expression evaluated by the Actions runner, not shell-interpolated — not an injection vector even though both operands are caller-controlled.
  • continue-on-error: true on the guard is consistent with the existing join-step pattern; it still reaches outcome (checks.yml#L293) and correctly fails the job rather than silently succeeding — this is a fail-closed fix (a missing/misspelled check-jsonschema-files now hard-fails instead of quietly disabling schema validation), which improves the security posture rather than weakening it.
  • No changes to permissions:, triggers, secrets handling, or action pins in this commit.
  • The corresponding test additions in checks-outcome.test.cjs only read repo-local trusted YAML and exercise the join script with fixture env values — no untrusted input reaches subprocess execution.

This finding (the Codex P2 report) was already resolved by this commit before this review ran, so there is nothing further to flag.
· Branch

The input description and the step comment still described the pre-guard
behaviour, which is the one shape a reader must not be told twice about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 54s —— View job


Security review complete

  • Gather context (PR diff, prior review state)
  • Invoke /review:security-review
  • Apply skip gate (commits since last reviewed head)
  • Review new commit since last security review
  • Post findings

Skip gate applied: the last security review on this PR covered head 4783cc4. The sole commit since then is 9fe1298 ("docs(workflows): say the schema toggle fails rather than skips").

No security issues found. That commit is comment/description-text only:

  • checks.yml#L114-L118 — rewords the check-jsonschema-files input description to state the fail-closed behavior explicitly (no functional change).
  • checks.yml#L271-L276 — rewords the comment above the check_jsonschema step's if: condition to clarify that the empty-files case is caught by the separate configuration guard step, not silently skipped.

I verified the configuration guard step this comment now points to is unchanged from the commit already reviewed: checks.yml#L181-L188 still runs if: inputs.check-jsonschema && inputs.check-jsonschema-files == '', emits a literal ::error:: string (no interpolated/attacker-controlled text in the shell body), and is continue-on-error: true feeding into the same outcome join already verified as fail-closed in the prior pass. No changes to permissions:, triggers, secrets, or action pins in this commit.
· Branch

checks.yml references its composites by full path at a pinned SHA, because a
relative action path inside a called workflow resolves against the caller's
checkout. The dogfood call in ci.yml therefore ran nine composite bodies at
449157a rather than at the commit under test,
so a pull request that broke one of their run.sh bodies could pass this
repository's own CI. That is deviation 9 of the Phase 6a report.

The new composites-head job runs typos, gitleaks, editorconfig, markdown,
exec-bit, machine-specific-paths, eol-renormalize, comment-hygiene and
lychee-offline through ./.github/actions/<x>, with the same exclusions the
checks caller passes. actionlint, shellcheck and check-jsonschema are absent
because each already runs at HEAD in its own job. The job copies its sibling
lanes: ubuntu-24.04, needs changes, a fifteen minute budget, the contract-only
gate, and the pinned checkout with persist-credentials false. It gates on the
actionlint change-detection group, the narrowest group naming composite
actions; every group in that job includes .github/** so any composite edit
selects the lane. ci-status gains the lane in both its needs list and its
aggregated results.

GitHub's $/ self-repository syntax would remove the pin lag and this job
together, but actionlint 1.7.12, which this repository's own actionlint
composite pins, rejects it as an invalid format with a missing ref. Phase 6b
promotes it once actionlint ships the support of rhysd/actionlint#732 in a
version this repository pins, actions/runner#4669 merges, and one
cross-repository $/ run is measured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019APkoJh8NVN773d82fBBSG
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 37s —— View job


Security review complete

  • Gather context (PR diff, prior review state)
  • Invoke /review:security-review
  • Apply skip gate (commits since last reviewed head)
  • Review new commit since last security review
  • Post findings

Skip gate applied: the last security review on this PR covered head 9fe1298. The sole commit since then is d5bbab4 ("ci(6a): run the moved composites at HEAD in a dogfood job"), which adds a composites-head job to ci.yml and explanatory comments/README text about the pin-lag it closes.

No security issues found. Specifically checked:

  • New job's trust boundary matches an already-audited pattern. composites-head is structurally identical to the pre-existing powershell job (ci.yml#L268-L274): same fork/label/edited if: gate shape, no job-level permissions: override (inherits the workflow-level contents: read only — ci.yml#L25-L26), and persist-credentials: false on checkout.
  • No injection surface. Every step is a uses: ./.github/actions/<x> call; the only with: values are fixed literal pathspec strings (':(exclude).github/actions/machine-specific-paths/**', ':(exclude).github/actions/comment-hygiene/**'), not interpolated ${{ }} expressions or caller/attacker-controlled text. No run: blocks were added.
  • No secrets referenced or newly exposed; the job runs with the default read-only token scope only.
  • Trigger/permission model unchanged. Still pull_request (not pull_request_target), no elevated triggers, no write permissions anywhere in the diff.
  • Risk profile of running local composite actions against fork-PR code is not new. These nine composites (typos, gitleaks, editorconfig, markdown, exec-bit, machine-specific-paths, eol-renormalize, comment-hygiene, lychee-offline) already ran as separate top-level jobs against the same checkout before this consolidation; this commit only relocates them into one job to close the pin-lag gap the checks.yml reusable's SHA-pinned references create (explained in the new checks.yml and README comments). No change in what untrusted code can reach or what it's granted.
  • Test-only diff (ci-fanout-consolidation.test.cjs) reads repo-local trusted YAML and asserts via regex — no new subprocess/untrusted-input surface. README changes are documentation only.

This is a docs/CI-shape commit with no functional security delta from the already-reviewed baseline.

Branch ·

@kyle-sexton
kyle-sexton merged commit b260ba0 into main Sep 5, 2026
61 checks passed
@kyle-sexton
kyle-sexton deleted the ci-perf/phase-6a-checks branch September 5, 2026 01:35
kyle-sexton added a commit that referenced this pull request Sep 8, 2026
No related issue: runner-host download stall consumed the full checks
budget on 2026-09-08 (dotfiles run 34190855785)

## Summary

On 2026-09-08 the self-hosted runner host's network path collapsed to
roughly
12 KB/s for about fifteen minutes. `dotfiles`' `checks / checks` job —
which
calls this repository's `checks.yml` reusable at `v0.22.2` with
`timeout-minutes: 15` — stalled inside the `check-jsonschema` composite,
on
`astral-sh/setup-uv`'s download. No step had its own budget, so the
stall
consumed the whole job budget and the run ended **`cancelled`**.

`cancelled` is the worst possible verdict for this lane. The `outcome`
join is
gated on `!cancelled()`, so it never ran and named nothing; a
reusable-workflow
job that does not complete publishes no outputs; and the caller's
`ci-status`
aggregate read the lane as a failure with no cause attached. Three
reruns were
needed to get green. A healthy run of that job takes 1 to 4.5 minutes.

## Fix

Every step in the `checks` job that downloads something now carries
`timeout-minutes: 8` — comfortably above the 4.5-minute healthy ceiling
for the
whole job, well under the 15-minute default job budget:

| Step | What it downloads |
| --- | --- |
| `typos` | `crate-ci/typos` release tarball behind `actions/cache` |
| `gitleaks` | `gitleaks/gitleaks` release tarball behind
`actions/cache` |
| `editorconfig` | `editorconfig-checker` release tarball behind
`actions/cache` |
| `markdown` | `actions/setup-node`, then `markdownlint-cli2` |
| `shellcheck` | `koalaman/shellcheck` release tarball behind
`actions/cache` |
| `actionlint` | `rhysd/actionlint` **and** `koalaman/shellcheck`
tarballs |
| `lychee_offline` | `lycheeverse/lychee` release tarball behind
`actions/cache` |
| `check_jsonschema` | `astral-sh/setup-uv`, then `uvx check-jsonschema`
— the step that stalled |

Untouched, because they download nothing: `configuration`, `detect`
(`actions/github-script`, API only), `exec_bit`,
`machine_specific_paths`,
`eol_renormalize`, `comment_hygiene`, and the `outcome` join. No
composite
action file is modified.

### Why the key goes on the workflow step and not inside the composite

A composite action's `runs.steps[*]` accepts `run`, `shell`, `if`,
`name`,
`id`, `env`, `working-directory`, `uses`, `with` and `continue-on-error`
— and
no `timeout-minutes`
([Metadata syntax for GitHub
Actions](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax)).
The feature request to add it is
[actions/runner#1979](actions/runner#1979),
still
open. The workflow step's own `timeout-minutes` *is* honoured for the
whole
composite invocation:
[actions/runner#599](actions/runner#599) (merged
2020-07-22) wired the step's `timeout-minutes` into the cancellation
token of
the composite action step's execution context.

### Why this restores the signal rather than just shortening the stall

A step killed by its own `timeout-minutes` is marked **`Failed`**, not
`Canceled` —
[`StepsRunner.cs`](https://github.com/actions/runner/blob/main/src/Runner.Worker/StepsRunner.cs)
distinguishes the two by whether the *job* token also fired, and sets
`TaskResult.Failed` for the step-timeout branch. So `continue-on-error:
true`
still holds, `steps.<id>.outcome` is `failure`, the `outcome` join runs
and
emits `::error::check-jsonschema failed (outcome=failure).`, and the
caller's
aggregate gets a named cause instead of a bare `cancelled`.

### Known ceiling

This bounds **one** stalled step. In the 2026-09-08 incident the host's
whole
network path was degraded, so several downloads would have stalled in
turn:
the first fails at 8:00, the next stalls, and the job still hits its
15-minute
budget and ends `cancelled`. Narrowing the per-step budget, or deriving
it from
`inputs.timeout-minutes`, is a separate change and is deliberately not
made
here.

## Verification

Run from the worktree `D:/worktrees/ciw-checks-step-timeout` on
`ci/checks-step-timeouts`:

- `actionlint --version` → `1.7.12` — the exact version
  `.github/actions/actionlint/action.yml` pins as its default.
- `actionlint .github/workflows/checks.yml` → no output, **exit 0**.
actionlint
parses the file as YAML before linting it, so this covers the parse too.
- `git diff --stat` → `.github/workflows/checks.yml | 18
++++++++++++++++++`,
  1 file changed, 18 insertions, 0 deletions.
- `git diff` → exactly eight `+        timeout-minutes: 8` lines and a
ten-line `# Stalls:` paragraph appended to the file header. No other
key,
value, pin, or SHA changed. No file outside
`.github/workflows/checks.yml`
  is touched.

Change-contract check: this repository has no `CHANGELOG.md` (`grep -rli
changelog --include='*.md' .` returns nothing) and no version file —
`README.md`
§Versioning states the version lives only in the git tag, cut by
`release.yml`'s
manual `workflow_dispatch`. No bump or changelog entry is owed for this
PR.

This PR's own CI executes the modified file: `ci.yml` dogfoods it
through
`uses: ./.github/workflows/checks.yml` at the commit under test, so the
runner
accepts the new keys on a real run. Not exercised: the timeout firing.
Reproducing a 12 KB/s network path on a runner is out of reach here, so
that
half rests on the two runner citations above.

## Related

- Follows the `checks` reusable added in
  #551 and the pin convergence in #558.
- Consumer affected: `melodic-software/dotfiles`
`.github/workflows/ci.yml`,
which pins `checks.yml@5776760 #
v0.22.2`.
It picks this up only after a release is cut and the pin is advanced,
and
neither hop is automatic: `release.yml` is `workflow_dispatch`-only, and
dotfiles' `dependabot.yml` `ignore`s `melodic-software/ci-workflows/*`
so
  that every ci-workflows bump moves through a deliberate reviewed PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01L5ZJnwTRCgc31bmfrdugyU

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant