KeiSeiKit-1.0/scripts/validate-workflow-shas.sh
Parfii-bot c778b7d9a3 feat(v0.20.1): workflow-file validation infrastructure
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>
2026-04-22 17:50:23 +08:00

98 lines
3.8 KiB
Bash
Executable file

#!/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