Three layers of defense against the dtolnay-SHA-class bug reaching main
(today's incident: agent SHA-pinned dtolnay/rust-toolchain with a pin
that was real but semantically wrong — lost 'install current stable'
meaning, locked to rust 1.94.1 branch tip, broke CI).
Layer 1 — actionlint static lint
scripts/install-actionlint.sh (65 LOC) — installs rhysd/actionlint
v1.7.12 [VERIFIED] to ~/.local/bin or suggests brew install.
scripts/lint-workflows.sh (40 LOC) — runs actionlint on
.github/workflows/*.yml, exit 0 on clean, advisory when binary
missing.
Layer 2 — SHA existence check (today's bug class)
scripts/validate-workflow-shas.sh (98 LOC) — extracts every
'uses: <repo>@<40-hex>' from workflow files + dependabot.yml,
checks each via GitHub REST commits API (exit 200/404/422).
Supports 'validate-workflow-shas: skip=<reason>' trailing
comment for intentional exceptions. Falls back to anonymous
API (60/hr quota) if GITHUB_TOKEN probe fails.
DESIGN PIVOT from spec: spec said 'git ls-remote <repo> <sha>'
but that only resolves REFS (branch/tag tips), not arbitrary
commit SHAs — would have given false-positive 100% MISSING
report. Switched to REST API /commits/{sha} for unambiguous
200/404/422.
Layer 3 — CI gate
.github/workflows/ci.yml — new 'workflow-lint' job after
shell-lint. Installs actionlint + runs both scripts on every
push to main and PR. Blocks CI on any fabricated SHA.
Layer 4 — optional pre-commit hook
scripts/pre-commit-workflow-lint.sh (54 LOC) — detects staged
.github/workflows/*.{yml,yaml} + .github/dependabot.yml
changes, runs layers 1+2, blocks commit on failure.
Install via: ln -sf ../../scripts/pre-commit-workflow-lint.sh
.git/hooks/pre-commit
REAL EXECUTION VERIFIED (not claim-only):
- actionlint ran: zero findings on current workflows
- validate-workflow-shas.sh ran: 21 SHA pins checked, 21 OK,
0 MISSING (confirms all current v0.19.1+ pins resolve)
- bash -n on every new script: clean
- bash-3.2 parser bug workaround: case-in-subshell → grep -E
RULE 0.2 exception #6 (shell is external convention for git hooks
+ GH Actions runs — Rust rewrite would add zero value).
RULE 0.13 respected — no git invocations except read-only API calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
54 lines
1.5 KiB
Bash
Executable file
54 lines
1.5 KiB
Bash
Executable file
#!/bin/sh
|
|
# pre-commit-workflow-lint.sh — pre-commit gate for workflow-file edits.
|
|
# Install: ln -sf ../../scripts/pre-commit-workflow-lint.sh .git/hooks/pre-commit
|
|
#
|
|
# Runs lint-workflows.sh + validate-workflow-shas.sh iff any staged file
|
|
# matches .github/workflows/*.y(a)ml or .github/dependabot.yml. No-op
|
|
# otherwise. Mirrors scripts/precommit-counts-check.sh in spirit.
|
|
|
|
set -eu
|
|
|
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
|
|
STAGED=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true)
|
|
|
|
# Match workflow-file edits via grep rather than a case-inside-subshell
|
|
# (macOS bash 3.2 mis-parses `;;` inside a $(... | while ... case ... esac)).
|
|
HIT_OUT=$(printf '%s\n' "${STAGED}" \
|
|
| grep -E '^\.github/(workflows/.*\.(yml|yaml)|dependabot\.yml)$' \
|
|
|| true)
|
|
|
|
if [ -z "${HIT_OUT}" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
printf 'workflow files staged — running lint + SHA validation\n'
|
|
printf '%s\n' "${HIT_OUT}" | sed 's/^/ staged: /'
|
|
|
|
RC=0
|
|
"${ROOT}/scripts/lint-workflows.sh" || RC=$?
|
|
if [ "${RC}" -ne 0 ]; then
|
|
cat >&2 <<EOF
|
|
|
|
actionlint reported findings. Fix them or unstage the workflow files, then retry.
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
"${ROOT}/scripts/validate-workflow-shas.sh" || RC=$?
|
|
if [ "${RC}" -ne 0 ]; then
|
|
cat >&2 <<EOF
|
|
|
|
validate-workflow-shas.sh reported MISSING SHAs. A pinned SHA does not
|
|
resolve at the upstream remote. Possible causes:
|
|
|
|
- Fabricated SHA (hallucinated digits)
|
|
- Force-pushed branch on upstream (rare, historical)
|
|
- Typo
|
|
|
|
Fix the SHA or unstage the workflow file.
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
exit 0
|