Skip to content

Commit dce6a01

Browse files
os-steveclaude
andauthored
ci(lint): run the .claude/hooks self-test matrices in the required job (#11799)
`.claude/hooks/` holds the enforcement behind worktree-first and the stash ban, and each guard ships a hermetic self-test matrix whose header says to re-run it after touching the hook. Nothing ran them — a grep for `selftest` over `.github/workflows/` matched nothing — so a guard that had silently stopped guarding landed green. Adds one step to the `lint` job (`Lint & Repo Gates`, a required status context) that DISCOVERS every `.claude/hooks/**/*.selftest.sh` at run time and runs it. No hook is modified: `.claude/**` is governed surface and this workflow is not. - discovered, never listed, so a matrix added tomorrow is picked up with no edit here - an empty discovery is red, not green (#4690) - tolerate-and-collect (#10814): a bare loop under `bash -e` would abort at the first red matrix and leave the rest unrun, neither green nor red - a step of the required job rather than a job of its own, so a red guard stops the merge queue instead of publishing an advisory context (#5617) Baseline measured on main before wiring: guard-main-checkout-bash 121/0, guard-shared-stash 32/0. Non-vacuity: with the bash guard's sole BLOCK verdict mutated to `exit 0` (scratch, restored byte-identical), the step exits 1, names the failing matrix, and the second matrix still runs. Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6684fd3 commit dce6a01

1 file changed

Lines changed: 100 additions & 0 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,106 @@ jobs:
784784
- name: PM ci-failure self-test
785785
run: node scripts/pm/ci-failure.mjs --self-test
786786

787+
# Claude hook guard self-tests (#11514, objectstack half of
788+
# objectstack-ai/objectui#5754). `.claude/hooks/` holds the enforcement
789+
# behind the two rules whose violation is most expensive in this repo —
790+
# worktree-first and the stash ban — and each guard ships a hermetic
791+
# matrix asserting its block/allow verdict case by case, with a header
792+
# telling you to re-run it after touching the hook. Nothing ran them:
793+
# `grep -rn 'selftest' .github/workflows/` returned no match when this
794+
# card was filed, so those headers WERE the enforcement. A guard that has
795+
# silently stopped guarding is worse than no guard, because everyone keeps
796+
# behaving as though it works — the stash ban's own receipt is two
797+
# parallel agents losing their in-flight changes to a shared LIFO stack.
798+
#
799+
# Baseline on `main` before this step existed, measured rather than
800+
# assumed: `guard-main-checkout-bash` 121 cases pass, `guard-shared-stash`
801+
# 32 cases pass. Both guards were healthy; this wires the alarm, it does
802+
# not fix an outage.
803+
#
804+
# ⭐ DISCOVERED at run time, never listed. A hard-coded list is this
805+
# card's own defect one level up: add a hook with a matrix tomorrow and it
806+
# silently is not run, and nothing goes red. The glob is the contract, so
807+
# a new `.claude/hooks/**/*.selftest.sh` is picked up with no edit here.
808+
# `find`, not a flat glob, so a matrix in a subdirectory is not a silent
809+
# miss either.
810+
#
811+
# ...and an EMPTY discovery is RED, not green (#4690). A renamed or moved
812+
# directory would otherwise make this step pass by running nothing, which
813+
# is the identical green line the whole self-test family above exists to
814+
# distrust. The count is printed on success so a SHRINKING set is visible
815+
# in the log rather than inferred.
816+
#
817+
# ⭐ Collected rather than sequenced, for the reason spelled out at the
818+
# `Shallow-history guard self-tests` step above (#10814): a `run:` block
819+
# is executed as `bash -e`, so a bare loop would abort at the FIRST red
820+
# matrix and leave the rest unrun — neither green nor red, and nothing in
821+
# the log tells those apart. The matrices are independent by construction
822+
# (each builds its own throwaway fixture), so collecting loses nothing.
823+
# ⚠️ `check:step-collectors` CANNOT see this block — its population is
824+
# `scripts/`|`packages/` paths carrying `--self-test`, and these are
825+
# `.claude/hooks/*.selftest.sh` — so the collector shape here is held by
826+
# review, not by that gate. Do not "simplify" it into a bare loop.
827+
#
828+
# ⛔ This step RUNS the matrices; it does not modify any hook. `.claude/**`
829+
# is governed surface and this workflow is not — that separation is what
830+
# keeps the wiring on the ordinary merge path.
831+
#
832+
# Home: a STEP of this job rather than a job of its own, deliberately.
833+
# `Lint & Repo Gates` is a required status context (pinned by
834+
# `check:required-contexts`); a new job would publish a context that is in
835+
# no ruleset, so a red guard would be advisory and the merge queue would
836+
# not stop for it — #5617 verbatim, which is the exact shape this card
837+
# exists to close.
838+
#
839+
# Hermetic by construction, and measured that way: each matrix builds its
840+
# own git repo and linked worktree under $TMPDIR, and both were re-run
841+
# from a primary checkout, a detached HEAD and a non-repo cwd with
842+
# identical results, so neither this checkout's depth nor its branch is an
843+
# input. Needs `jq` and `git` and nothing else — no pnpm, no node, no
844+
# build, no network (`jq` is in the runner image and is already relied on
845+
# bare by cut-rc.yml and release.yml). ~2 s measured.
846+
- name: Claude hook guard self-tests (worktree-first · stash ban)
847+
run: |
848+
# Tolerate-and-collect (#10814) — see the note above this step. Each
849+
# matrix runs unconditionally and prints its own verdict; the step
850+
# still FAILS when any of them does, naming every one that failed.
851+
# ⛔ Never let the collector swallow the exit code — a green step over
852+
# a red self-test looks identical to success.
853+
mapfile -t selftests < <(find .claude/hooks -type f -name '*.selftest.sh' | sort)
854+
if [ "${#selftests[@]}" -eq 0 ]; then
855+
echo "Claude hook guard self-tests — DISCOVERED NOTHING under .claude/hooks/."
856+
echo "This step verified nothing, which is a failure and not a pass (#4690):"
857+
echo "the matrices were moved, renamed or deleted. Re-point the search above"
858+
echo "rather than deleting the step."
859+
exit 1
860+
fi
861+
echo "discovered ${#selftests[@]} hook self-test(s):"
862+
printf ' %s\n' "${selftests[@]}"
863+
echo ""
864+
failed=""
865+
run_self_test() {
866+
echo "-- $*"
867+
if "$@"; then
868+
echo "PASS $*"
869+
else
870+
echo "FAIL $*"
871+
failed="${failed} $*"$'\n'
872+
fi
873+
return 0
874+
}
875+
for selftest in "${selftests[@]}"; do
876+
run_self_test "$selftest"
877+
done
878+
if [ -n "$failed" ]; then
879+
echo ""
880+
echo "Claude hook guard self-tests — the following FAILED:"
881+
printf "%s" "$failed"
882+
exit 1
883+
fi
884+
echo ""
885+
echo "Claude hook guard self-tests — all ${#selftests[@]} ran and passed"
886+
787887
# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in
788888
# Markdown/MDX are not type-checked or ESLinted, so skills/ and
789889
# content/docs/ can drift back to teaching the bare `: Page = {}` literal

0 commit comments

Comments
 (0)