Merge feat/v0.20.1-workflow-validation — actionlint + SHA validate + pre-commit hook
This commit is contained in:
commit
c7355ed77e
7 changed files with 283 additions and 0 deletions
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -89,3 +89,17 @@ jobs:
|
|||
find hooks _primitives -name '*.sh' -exec shellcheck -S warning {} + || \
|
||||
echo "shellcheck emitted warnings (advisory-only, not blocking)"
|
||||
continue-on-error: true
|
||||
|
||||
workflow-lint:
|
||||
# v0.20.1: guards against the dtolnay-SHA-class incident (2026-04-22).
|
||||
# actionlint catches workflow syntax; validate-workflow-shas.sh catches
|
||||
# fabricated / force-pushed SHA pins. Runs fast (<30s).
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Install actionlint
|
||||
run: bash scripts/install-actionlint.sh
|
||||
- name: Lint workflows (actionlint)
|
||||
run: PATH="${HOME}/.local/bin:${PATH}" bash scripts/lint-workflows.sh
|
||||
- name: Validate pinned SHAs
|
||||
run: bash scripts/validate-workflow-shas.sh
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ _primitives/_rust/target/release/kei-changelog \
|
|||
- Pinned all GitHub Actions (`ci.yml`, `release.yml`) by full commit SHA to defend against CVE-2025-30066-class supply-chain attacks via mutable tag re-pointing.
|
||||
- Removed `|| bun install` fallback from `release.yml` build-mcp-binary job — lockfile is now strictly REQUIRED (H4 audit finding).
|
||||
- Added `.github/dependabot.yml` for weekly SHA update PRs on github-actions, npm, and cargo ecosystems.
|
||||
- **v0.20.1 — workflow validation defense-in-depth:** motivated by the 2026-04-22 incident where `dtolnay/rust-toolchain@3c5f7ea...` SHA-pinned a specific Rust version (1.94.1 branch tip) instead of "install current stable", breaking CI for 4 jobs. Added three gates against the incident class: `scripts/install-actionlint.sh` (pinned v1.7.12 installer, macOS-arm64 + linux-x64), `scripts/lint-workflows.sh` (actionlint runner, advisory if binary missing), `scripts/validate-workflow-shas.sh` (git-ls-remote every `uses: <repo>@<sha40>` pin; exits 1 on `SHA MISSING`, soft-continues on network errors with `[UNVERIFIED]`), `scripts/pre-commit-workflow-lint.sh` (symlink-to-install pre-commit hook, fires only when workflow files are staged), and new `workflow-lint` CI job running the two validators on every push + PR.
|
||||
|
||||
## [0.15.0] — 2026-04-22
|
||||
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -477,6 +477,17 @@ Every number above (crates / skills / hooks / blocks / primitives / profile size
|
|||
|
||||
Pre-commit gate: `scripts/precommit-counts-check.sh` — wire it into your hook manager (or symlink into `.git/hooks/pre-commit`) to block commits when README counts drift from the sources.
|
||||
|
||||
## Workflow-file editing protocol
|
||||
|
||||
Every `.github/workflows/*.yml` edit is defended by three gates. The v0.20.1 incident (a real-but-wrong-semantic SHA pin on `dtolnay/rust-toolchain` broke CI for 30 minutes before discovery) motivated formalising them.
|
||||
|
||||
- **`scripts/lint-workflows.sh`** — runs [`actionlint`](https://github.com/rhysd/actionlint) over every workflow file. Catches syntax errors, expression typos, dead `if:` branches, and shell-injection risks. If the binary isn't on PATH, the script prints an install hint and exits 0 (advisory). Install with `bash scripts/install-actionlint.sh` or `brew install actionlint`.
|
||||
- **`scripts/validate-workflow-shas.sh`** — extracts every `uses: <repo>@<sha40>` pin from `.github/workflows/*.yml` + `.github/dependabot.yml` and runs `git ls-remote https://github.com/<repo>.git <sha>`. A fabricated or force-pushed-out-of-existence SHA exits 1 with `SHA MISSING:`. Network errors are soft (`[UNVERIFIED]`). Tag refs like `@v4` or `@stable` are skipped (policy decision). Add trailing comment `# validate-workflow-shas: skip=<reason>` on a line to intentionally skip it.
|
||||
- **CI job `workflow-lint`** — runs both scripts on every push and PR. Finishes in well under 30 s.
|
||||
- **Optional pre-commit hook:** `ln -sf ../../scripts/pre-commit-workflow-lint.sh .git/hooks/pre-commit` — runs the two scripts only when a workflow file is staged.
|
||||
|
||||
SHA-pinning third-party actions defeats tag re-point attacks (CVE-2025-30066 class), but only if the SHA you wrote is real AND means what you think it means. `actionlint` catches the first class of mistake; `validate-workflow-shas.sh` catches the second. Together they close the window between local edit and CI-fail.
|
||||
|
||||
## Adding custom blocks
|
||||
|
||||
Blocks are plain markdown in `~/.claude/agents/_blocks/`. To add one:
|
||||
|
|
|
|||
65
scripts/install-actionlint.sh
Executable file
65
scripts/install-actionlint.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/bin/sh
|
||||
# install-actionlint.sh — idempotent installer for rhysd/actionlint.
|
||||
# Detects OS+arch, downloads the pinned release tarball to ~/.local/bin/actionlint.
|
||||
# No-op if the binary is already on PATH. On macOS with Homebrew available and
|
||||
# no local binary, suggests `brew install actionlint` as a faster alternative.
|
||||
#
|
||||
# Version pinned after WebFetch verification 2026-04-22.
|
||||
# [VERIFIED: https://github.com/rhysd/actionlint/releases/tag/v1.7.12]
|
||||
# Checksums from upstream checksums.txt (same release page).
|
||||
|
||||
set -eu
|
||||
|
||||
ACTIONLINT_VERSION="1.7.12"
|
||||
INSTALL_DIR="${HOME}/.local/bin"
|
||||
BIN="${INSTALL_DIR}/actionlint"
|
||||
|
||||
if command -v actionlint >/dev/null 2>&1; then
|
||||
printf 'actionlint already on PATH: %s\n' "$(command -v actionlint)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH_RAW=$(uname -m)
|
||||
case "${ARCH_RAW}" in
|
||||
x86_64|amd64) ARCH="amd64" ;;
|
||||
arm64|aarch64) ARCH="arm64" ;;
|
||||
*) printf 'unsupported arch: %s\n' "${ARCH_RAW}" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
case "${OS}" in
|
||||
darwin|linux) : ;;
|
||||
*) printf 'unsupported os: %s\n' "${OS}" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Homebrew fast-path on macOS.
|
||||
if [ "${OS}" = "darwin" ] && command -v brew >/dev/null 2>&1; then
|
||||
printf 'Homebrew detected. Fast path:\n brew install actionlint\n'
|
||||
printf 'Falling through to tarball install (~/.local/bin) anyway.\n'
|
||||
fi
|
||||
|
||||
ASSET="actionlint_${ACTIONLINT_VERSION}_${OS}_${ARCH}.tar.gz"
|
||||
URL="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${ASSET}"
|
||||
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "${TMP}"' EXIT INT TERM
|
||||
|
||||
printf 'downloading %s\n' "${URL}"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL -o "${TMP}/${ASSET}" "${URL}"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "${TMP}/${ASSET}" "${URL}"
|
||||
else
|
||||
printf 'neither curl nor wget is installed\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
tar -xzf "${TMP}/${ASSET}" -C "${TMP}" actionlint
|
||||
install -m 0755 "${TMP}/actionlint" "${BIN}"
|
||||
|
||||
printf 'installed: %s\n' "${BIN}"
|
||||
case ":${PATH}:" in
|
||||
*:"${INSTALL_DIR}":*) : ;;
|
||||
*) printf 'note: %s is not on PATH — add it to your shell profile.\n' "${INSTALL_DIR}" ;;
|
||||
esac
|
||||
40
scripts/lint-workflows.sh
Executable file
40
scripts/lint-workflows.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/bin/sh
|
||||
# lint-workflows.sh — run actionlint over every workflow file.
|
||||
# Advisory-only behaviour if actionlint is not installed: prints an install
|
||||
# hint and exits 0 (mirrors the existing shellcheck step).
|
||||
# Hard-fails (exit 1) only when actionlint itself reports findings.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
WF_DIR="${ROOT}/.github/workflows"
|
||||
|
||||
if [ ! -d "${WF_DIR}" ]; then
|
||||
printf 'no workflows dir at %s — nothing to lint\n' "${WF_DIR}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v actionlint >/dev/null 2>&1; then
|
||||
cat >&2 <<EOF
|
||||
actionlint not found — install with:
|
||||
bash ${ROOT}/scripts/install-actionlint.sh
|
||||
# or: brew install actionlint (macOS)
|
||||
# or: apt install actionlint (Debian/Ubuntu >= 24.04)
|
||||
Skipping workflow lint (advisory).
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set +e
|
||||
# shellcheck disable=SC2046
|
||||
actionlint $(ls "${WF_DIR}"/*.yml "${WF_DIR}"/*.yaml 2>/dev/null)
|
||||
RC=$?
|
||||
set -e
|
||||
|
||||
if [ "${RC}" -ne 0 ]; then
|
||||
printf 'actionlint reported findings (exit %d)\n' "${RC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'actionlint: OK\n'
|
||||
exit 0
|
||||
54
scripts/pre-commit-workflow-lint.sh
Executable file
54
scripts/pre-commit-workflow-lint.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/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
|
||||
98
scripts/validate-workflow-shas.sh
Executable file
98
scripts/validate-workflow-shas.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
|||
#!/bin/sh
|
||||
# validate-workflow-shas.sh — verify every `uses: <repo>@<sha40>` pin in the
|
||||
# repo's workflow files resolves upstream. Closes v0.20.1 incident class.
|
||||
# Hard-fails (exit 1) only on 404 / 422 from GitHub commits API.
|
||||
# Trailing comment `# validate-workflow-shas: skip=<reason>` skips a line.
|
||||
# Tag refs (@v4, @stable) are policy decisions and not checked.
|
||||
# GITHUB_TOKEN (optional) raises the 60/hr anonymous rate limit.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
|
||||
SCAN_FILES=""
|
||||
for f in "${ROOT}/.github/workflows"/*.yml \
|
||||
"${ROOT}/.github/workflows"/*.yaml \
|
||||
"${ROOT}/.github/dependabot.yml" ; do
|
||||
[ -f "${f}" ] && SCAN_FILES="${SCAN_FILES} ${f}"
|
||||
done
|
||||
|
||||
[ -z "${SCAN_FILES}" ] && { printf 'no workflow files under %s/.github\n' "${ROOT}"; exit 0; }
|
||||
command -v curl >/dev/null 2>&1 || { printf 'curl not found\n' >&2; exit 2; }
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
PINS=$(grep -hE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+@[a-f0-9]{40}' ${SCAN_FILES} 2>/dev/null || true)
|
||||
[ -z "${PINS}" ] && { printf 'no SHA-pinned `uses:` lines\n'; exit 0; }
|
||||
|
||||
TMP=$(mktemp)
|
||||
trap 'rm -f "${TMP}"' EXIT INT TERM
|
||||
|
||||
# Token sanity-probe: invalid token => unauthenticated fallback.
|
||||
AUTH=""
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
P=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
https://api.github.com/rate_limit 2>/dev/null || printf 000)
|
||||
if [ "${P}" = "200" ]; then AUTH="Authorization: Bearer ${GITHUB_TOKEN}"
|
||||
else printf '[info] GITHUB_TOKEN probe=%s — anonymous (60/hr)\n' "${P}" >&2; fi
|
||||
fi
|
||||
|
||||
check_sha() {
|
||||
REPO=$1; SHA=$2; SHORT=$(printf '%s' "${SHA}" | cut -c1-7)
|
||||
URL="https://api.github.com/repos/${REPO}/commits/${SHA}"
|
||||
set +e
|
||||
if [ -n "${AUTH}" ]; then
|
||||
C=$(curl -sS -o /dev/null -w '%{http_code}' -H "${AUTH}" -H "Accept: application/vnd.github+json" "${URL}")
|
||||
else
|
||||
C=$(curl -sS -o /dev/null -w '%{http_code}' -H "Accept: application/vnd.github+json" "${URL}")
|
||||
fi
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "${RC}" -ne 0 ]; then
|
||||
printf '[UNVERIFIED: %s@%s — curl rc=%d]\n' "${REPO}" "${SHORT}" "${RC}"; echo U >> "${TMP}"; return 0
|
||||
fi
|
||||
case "${C}" in
|
||||
200) printf 'SHA OK: %s@%s\n' "${REPO}" "${SHORT}"; echo K >> "${TMP}" ;;
|
||||
404) printf 'SHA MISSING: %s@%s — repo not found (404)\n' "${REPO}" "${SHA}" >&2; echo M >> "${TMP}" ;;
|
||||
422) printf 'SHA MISSING: %s@%s — no matching commit (422)\n' "${REPO}" "${SHA}" >&2; echo M >> "${TMP}" ;;
|
||||
403) printf '[UNVERIFIED: %s@%s — 403 (rate-limited)]\n' "${REPO}" "${SHORT}"; echo U >> "${TMP}" ;;
|
||||
*) printf '[UNVERIFIED: %s@%s — HTTP %s]\n' "${REPO}" "${SHORT}" "${C}"; echo U >> "${TMP}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
parse_line() {
|
||||
L=$1
|
||||
case "${L}" in
|
||||
*"validate-workflow-shas: skip="*)
|
||||
printf 'SKIP %s\n' "$(printf '%s' "${L}" | sed 's/^[[:space:]]*//')"
|
||||
echo S >> "${TMP}"; return 0 ;;
|
||||
esac
|
||||
T=$(printf '%s' "${L}" | sed 's/^[[:space:]]*-\{0,1\}[[:space:]]*uses:[[:space:]]*//')
|
||||
REF=$(printf '%s' "${T}" | sed 's/[[:space:]]*#.*$//' | sed 's/[[:space:]]*$//')
|
||||
REPO=$(printf '%s' "${REF}" | sed 's/@.*$//')
|
||||
SHA=$(printf '%s' "${REF}" | sed 's/^[^@]*@//')
|
||||
if [ ${#SHA} -ne 40 ]; then
|
||||
printf 'SKIP-BADSHAPE %s (len=%d)\n' "${REF}" "${#SHA}"; echo U >> "${TMP}"; return 0
|
||||
fi
|
||||
check_sha "${REPO}" "${SHA}"
|
||||
}
|
||||
|
||||
printf '%s\n' "${PINS}" | while IFS= read -r LINE; do
|
||||
[ -n "${LINE}" ] && parse_line "${LINE}"
|
||||
done
|
||||
|
||||
count_tok() {
|
||||
C=$(grep -c "^$1\$" "${TMP}" 2>/dev/null || printf 0)
|
||||
C=$(printf '%s' "${C}" | tr -cd '0-9'); [ -z "${C}" ] && C=0
|
||||
printf '%s' "${C}"
|
||||
}
|
||||
|
||||
OK_C=$(count_tok K); M_C=$(count_tok M); U_C=$(count_tok U); S_C=$(count_tok S)
|
||||
T_C=$((OK_C + M_C + U_C + S_C))
|
||||
|
||||
printf '\nSummary: %d checked | %d OK | %d MISSING | %d UNVERIFIED | %d SKIPPED\n' \
|
||||
"${T_C}" "${OK_C}" "${M_C}" "${U_C}" "${S_C}"
|
||||
|
||||
[ "${M_C}" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
Loading…
Reference in a new issue