From e287209f6806eb930b535a791c9ec93411500e65 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:14:28 -0400 Subject: [PATCH 1/2] fix(checks): bound each download-heavy step with timeout-minutes A stalled dependency download in the shared `checks` lane consumed the calling job's entire `timeout-minutes` budget, so the run ended `cancelled` rather than `failure`. The `outcome` join never ran, the reusable workflow published no outputs, and the caller's `ci-status` aggregate read the lane as a failure with no named cause. Every step in the job that downloads something now carries `timeout-minutes: 8`: the four `actions/cache`-backed tarball installers (typos, gitleaks, editorconfig, lychee-offline), the two that also fetch ShellCheck (shellcheck, actionlint), the `actions/setup-node` one (markdown), and the `astral-sh/setup-uv` one (check-jsonschema). The pure-git composites (exec-bit, machine-specific-paths, eol-renormalize, comment-hygiene) and the API-only `change-detection` are unchanged. The key goes on the workflow step, not inside the composite: a composite action's `runs.steps[*]` accept no `timeout-minutes`. The runner sets a timed-out step's result to `Failed`, not `Canceled`, so `continue-on-error: true` still holds and the join names the stalled tool instead of the run dying silently. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L5ZJnwTRCgc31bmfrdugyU --- .github/workflows/checks.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c1aab03..5c1f83f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -45,6 +45,16 @@ name: checks # 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. +# +# Stalls: every composite that downloads something — a `setup-*` action, or a +# pinned release tarball behind `actions/cache` — carries `timeout-minutes: 8` +# on its STEP. On a degraded network path one download otherwise consumes the +# whole job budget and the run ends `cancelled`, a verdict the join never +# reaches and an aggregate cannot read. The runner marks a timed-out step +# `failure`, not `cancelled`, so `continue-on-error` holds and the join names +# the stalled tool. The key belongs here and not inside the composite: +# composite `runs.steps[*]` accept no `timeout-minutes`. It bounds ONE stalled +# step; a host whose whole network path collapses still exhausts the job. on: workflow_call: inputs: @@ -217,36 +227,42 @@ jobs: id: typos if: ${{ inputs.typos && fromJSON(steps.detect.outputs.results || '{}')['typos'] != 'false' }} continue-on-error: true + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/typos@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 - name: Scan for secrets id: gitleaks if: ${{ inputs.gitleaks && fromJSON(steps.detect.outputs.results || '{}')['gitleaks'] != 'false' }} continue-on-error: true + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/gitleaks@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 - name: Check editorconfig conformance id: editorconfig if: ${{ inputs.editorconfig && fromJSON(steps.detect.outputs.results || '{}')['editorconfig'] != 'false' }} continue-on-error: true + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/editorconfig@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 - name: Lint markdown id: markdown if: ${{ inputs.markdown && fromJSON(steps.detect.outputs.results || '{}')['markdown'] != 'false' }} continue-on-error: true + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/markdown@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 - name: Lint shell scripts id: shellcheck if: ${{ inputs.shellcheck && fromJSON(steps.detect.outputs.results || '{}')['shellcheck'] != 'false' }} continue-on-error: true + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/shellcheck@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 - name: Lint workflows id: actionlint if: ${{ inputs.actionlint && fromJSON(steps.detect.outputs.results || '{}')['actionlint'] != 'false' }} continue-on-error: true + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/actionlint@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 - name: Verify shebang files are executable @@ -281,6 +297,7 @@ jobs: id: lychee_offline if: ${{ inputs.lychee-offline && fromJSON(steps.detect.outputs.results || '{}')['lychee-offline'] != 'false' }} continue-on-error: true + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/lychee-offline@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 - name: Validate against JSON Schema @@ -291,6 +308,7 @@ jobs: # 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 + timeout-minutes: 8 uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@906ae7ef379ea4d2b8497f64475dce1d3d8715c4 # v0.22.0 with: files: ${{ inputs.check-jsonschema-files }} From 21d2df362c0e1bf5cef7cbb869acb64795b3e7e2 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:38:42 -0400 Subject: [PATCH 2/2] test(checks): pin the download-heavy composites' step timeout The eight `timeout-minutes: 8` values were unasserted, so a future edit that dropped one, or a new download-heavy composite added without one, would not surface until the next stall incident. `checks-outcome.test.cjs` already parses `checks.yml` structurally and enumerates the twelve `continue-on-error` composites, so the assertion reuses that enumeration: every composite except the four shell-only ones must carry `timeout-minutes: 8`. A new composite is required to declare a budget or be named as shell-only, rather than defaulting to unbounded. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L5ZJnwTRCgc31bmfrdugyU --- .github/scripts/checks-outcome.test.cjs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/scripts/checks-outcome.test.cjs b/.github/scripts/checks-outcome.test.cjs index 374f033..23bbe05 100644 --- a/.github/scripts/checks-outcome.test.cjs +++ b/.github/scripts/checks-outcome.test.cjs @@ -110,6 +110,30 @@ test("the join reads every continue-on-error step", () => { ); }); +test("every downloading composite carries the step timeout", () => { + // A composite that downloads something — a `setup-*` action, or a pinned + // release tarball behind `actions/cache` — bounds ONE stalled download on its + // step, so a degraded network path cannot consume the caller's whole job + // budget and end the run `cancelled`, a verdict the join never reaches. These + // four are shell-only: they download nothing, so they carry no budget. A new + // download-heavy composite added without the key fails here rather than at + // the next stall. + const shellOnly = new Set([ + "exec_bit", + "machine_specific_paths", + "eol_renormalize", + "comment_hygiene", + ]); + for (const step of composites) { + if (shellOnly.has(step.id)) continue; + assert.equal( + step["timeout-minutes"], + 8, + `step ${step.id} downloads but has no timeout-minutes: 8`, + ); + } +}); + test("every composite green passes and records outcome=success", () => { const result = runJoin({}); assert.equal(result.status, 0, result.stdout);