ci: lint the workflow files so an uncompilable one cannot ship (#1122) - #1129
Conversation
Nothing parsed .github/workflows/*.yml, so a workflow GitHub cannot compile shipped silently — twice. deploy.yml failed on every push to main from 2026-08-02 to 2026-08-09 (staging stopped deploying, nobody noticed), and claude-review before it (#1011). That class of failure is invisible by construction: the run has zero jobs and no logs, `gh run view --log-failed` says "log not found", and the invalid run ignores its own trigger filters, so it looks like an unrelated trigger bug. YAML validity does not help — yaml.safe_load() parses these files fine; it is GitHub's expression pass that rejects them. - New workflow-lint job runs actionlint on every PR. Deliberately NOT paths-filtered: a required check reporting "skipped" is the same silence the job exists to remove, and actionlint takes about a second. - Wired into test-summary's needs AND its exit-1 condition, per the CLAUDE.md rule about gates that quietly stop gating. - actionlint is a pinned release verified by sha256 rather than a floating action — this job's whole purpose is stopping unverifiable CI config landing. - tests/test_workflow_lint_wiring_1122.py pins the wiring. Verified non-tautological by removing each half in turn; the first version of the summary-gate test passed with the gate removed because it matched the summary table row, and was tightened to the exit-1 condition itself. - CLAUDE.md records the diagnostic: zero jobs and no logs means the file will not compile, run actionlint first. Verified against the actual regression: actionlint on deploy.yml at a0a6963 reports `570:135: unexpected end of input while parsing ... [expression]` — the empty ${{ }} that caused the outage.
GLM precision review — done1 confirmed defect (1 major), posted as an inline comment + a summary comment.
Details + fix options are in the inline thread. No other concrete defects found. |
|
Review Reviewed the diff directly ( Overall Things that check out
Minor notes (non-blocking)
No bugs, security issues, or missing test coverage found. Good to merge from a code-review standpoint, modulo the checksum sanity-check note above. |
…#1122) The job failed on its first CI run, and my local verification had been invalid: actionlint runs shellcheck over every `run:` block whenever shellcheck is on PATH. It is on GitHub runners; it was not on my machine, so the entire check class silently vanished locally and the run looked clean. Reproduced after installing shellcheck: 44 findings, ALL of them shellcheck, and zero compilability findings. The workflows compile — the failures are pre-existing shell style, almost all in deploy.yml. So the job now runs with -shellcheck=. That keeps exactly what #1122 is about (workflows GitHub cannot compile, the failure mode that yields a run with zero jobs and no logs) and leaves the shell findings to #1130. Verified both halves: clean on the current tree, and still reports the a0a6963 empty-expression regression at deploy.yml:570. Fixing the 44 here would have blocked a P1 CI fix behind unrelated edits, and most are quoting changes in the deploy path that cannot be validated from a PR — which is how the original outage happened. Two tests pin that the suppression stays explained and stays narrow.
|
Good catch — the job failed on its own first run, and my local verification had been invalid.
Zero non-shellcheck findings. The workflows compile; the failures are pre-existing shell style, almost all in The job now runs I did not fix the 44 here. Two reasons: it would block a P1 CI fix behind unrelated edits, and most are quoting changes in the deploy path that cannot be validated from a PR — deploying is the only way to know an SSH heredoc still behaves, and changing it blind is how the original outage happened. Filed as #1130 (P2.34), including the two findings I think are worth real scrutiny: an Two tests pin that the suppression stays documented and stays narrow, so it cannot quietly become permanent. |
|
Claude finished @frankbria's task in 4m 10s —— View job GLM precision review — done✅ No defects found. (Re-checked the full diff of The earlier
That one result closes the two items the prior reviews explicitly could not confirm and left to "the first real run":
Gate wiring re-confirmed in
No inline findings to post — nothing rose to a concrete, reproducible failure scenario. Note: this environment has no network egress, so the checksum/flag confirmation above is read from the CI run result (the job that actually downloaded and ran the binary) rather than re-fetched from upstream — which is the stronger evidence anyway. |
|
Re-review — follow-up commit (ad6c70b) This addresses the What checks out
Still open (non-blocking, unchanged from my prior review)
No new bugs, security issues, or test-coverage gaps in this commit. Good to merge. |
Closes #1122.
Why this class of bug is invisible
An uncompilable workflow does not fail like a normal job. The run has zero
jobs and no logs,
gh run view --log-failedreturnslog not found, and theinvalid run ignores its own trigger filters — so it surfaces on branches the
workflow should never touch and reads as an unrelated trigger bug rather than an
outage.
deploy.ymlfailed that way on every push tomainfor a week; nobodynoticed staging had stopped deploying.
claude-review(#1011) was the same shape.YAML validity does not catch it:
yaml.safe_load()parses these files fine. Itis GitHub's expression pass that rejects them.
Changes
workflow-lintjob — runsactionlinton every PR.Deliberately not paths-filtered. A required check that reports "skipped"
exactly when a workflow changed is the same silence this job exists to remove,
and actionlint takes about a second.
Wired into
test-summarytwice — intoneeds, and into the exit-1condition. Being in
needsalone is not a gate:test-summaryruns underif: always(), so the failure branch is what makes it real. This is theCLAUDE.md rule about gates that quietly stop gating.
actionlint is a pinned release verified by sha256, not a floating action —
this job's whole purpose is stopping unverifiable CI config from landing, so it
should not itself pull an unpinned artifact.
CLAUDE.mdrecords the diagnostic, since the symptom is the hard part: zerojobs and no logs means the file will not compile; run
actionlintfirst.Evidence — verified against the actual regression
actionlintondeploy.ymlas it stood ata0a69636:That is the empty
${{ }}that caused the week-long outage — found in under asecond. Current
mainis clean under the checks this job enforces(
actionlint -shellcheck=,exit=0), including this PR's own additions.Correction: the first version of this PR failed its own job
My initial local verification was invalid.
actionlintrunsshellcheckoverevery
run:block whenever shellcheck is on PATH — it is on GitHub runners,it was not on my machine, so that entire check class silently vanished locally
and the run looked clean. The job failed on its first CI run.
Reproduced after installing shellcheck locally:
Zero non-shellcheck findings: the workflows compile. The job therefore runs
-shellcheck=, which keeps exactly the failure mode #1122 is about, and the 44shell findings are tracked in #1130 (P2.34) rather than fixed here — they are
pre-existing, almost all in
deploy.yml, and quoting changes in the deploy pathcannot be validated from a PR. Deploying is the only way to know an SSH heredoc
still behaves, and changing it blind is how the original outage happened.
Two tests pin that the suppression stays documented and stays narrow, so it
cannot quietly become permanent.
The wiring tests are verified non-tautological
tests/test_workflow_lint_wiring_1122.pypins that the job exists, is inneeds, is in the exit-1 condition, is not conditionally skipped, and ischecksum-pinned.
I removed each half in turn to confirm they actually fail. That caught a weak
test of my own:
test_test_summary_actually_fails_on_itoriginally searched forneeds.workflow-lint.resultanywhere in the script, which passed with the gateremoved because the summary table row also interpolates that value. It now
asserts the exact exit-1 condition.
Acceptance criteria
actionlintover.github/workflows/*.ymland fails on findingstest-summaryso it cannot be skipped silentlya0a69636— reproduced the exact findingCLAUDE.md: zero jobs + no logs means it will not compileNot done here — deliberately
The issue's "worth considering in the same change" item — notifying when a
valid workflow starts failing on
main— is not in this PR. Linting stops abroken workflow from landing; it does nothing for a workflow that compiles and
then fails, which is the other half of why staging was down for a week. That
needs a decision about where notifications go (the #560 webhook plumbing exists,
but pointing it at CI failures is a new policy, not a refactor). The issue itself
offers to split it out, so I have: say the word and I will file it as its own
P-numbered issue rather than guessing at the routing.