This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Repository navigation
feat(hooks): guard the origin/main ENUMERATION half, and record the incident in AGENTS.md #10
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Hook Self-Tests | |
| # The standing caller for `.claude/hooks/*.selftest.sh` — objectui#5754. | |
| # | |
| # ── The gap this closes ───────────────────────────────────────────────────── | |
| # | |
| # `.claude/hooks/guard-main-checkout-bash.sh` and `.claude/hooks/guard-shared-stash.sh` | |
| # are the PreToolUse guards behind the rule both `CLAUDE.md` files state as binding — | |
| # worktree-first, and never `git stash` (see those files' "⛔" sections, and | |
| # AGENTS.md §9). Each ships a hermetic self-test matrix | |
| # (`guard-main-checkout-bash.selftest.sh`, `guard-shared-stash.selftest.sh`) that | |
| # needs only `jq` and `git` — no install, no build, no network — and both files' | |
| # own headers say to run the matching self-test after touching the hook. Nothing | |
| # ever did: `git grep -rn selftest .github/workflows/` returned no matches before | |
| # this workflow existed. A hook is not imported by any package, so no unit test, | |
| # type check, or lint reaches it either (`eslint.config.js` is scoped to | |
| # `**/*.{ts,tsx}` throughout, and there is no `shellcheck` anywhere in this repo). | |
| # The self-test matrix was therefore exercised only when a human — or an agent — | |
| # remembered, in both repos, and a regression in either direction is invisible | |
| # until it bites: fail-open silently stops guarding the shared checkout, fail-closed | |
| # trains an operator onto the `OS_ALLOW_MAIN_EDITS=1` / `OS_ALLOW_STASH=1` escape | |
| # hatch, switching the guard off for the whole command. Neither shows up in a PR. | |
| # | |
| # ── What this job is, and is not ──────────────────────────────────────────── | |
| # | |
| # This is a RUNNER, not a rewrite of what it runs. It does not touch the hooks or | |
| # their self-tests — `.claude/**` is governed surface, and the self-test matrices | |
| # already assert their own pass/fail per case; this job only gives that matrix | |
| # a caller that runs on every PR the matrix could catch a regression on, and | |
| # turns "which self-tests failed" into a red check instead of scrollback nobody | |
| # reads. If a self-test itself is ever found broken, that is a finding for a | |
| # *separate* PR against the governed surface, not something to patch here. | |
| # | |
| # ── Why path-filtered, unlike `control-bytes.yml` ─────────────────────────── | |
| # | |
| # `control-bytes.yml`'s header explains at length why ITS gate carries no `paths` | |
| # filter: a markdown-only PR can trip a raw control byte just as easily as a | |
| # TypeScript one, so an unfiltered gate is the only kind that can see it. That | |
| # reasoning does not transfer here — both self-tests build their own hermetic | |
| # git fixtures and assert the CURRENT hook script's behaviour; nothing about a | |
| # docs-only or dependency-bump PR can move that needle. So this workflow mirrors | |
| # `changeset-guard.yml`'s shape instead: it fires only on a PR (or push to `main`) | |
| # that touches `.claude/hooks/**` or this workflow file itself. | |
| # | |
| # ── Cost — a dated measurement, not a live claim ───────────────────────────── | |
| # | |
| # Measured 2026-08-24 on `origin/main` @ 53dc89db8: | |
| # `guard-main-checkout-bash.selftest.sh` (100 cases) ~3.2s wall, | |
| # `guard-shared-stash.selftest.sh` (32 cases) ~0.6s wall. Both need only `jq` | |
| # and `git`, both preinstalled on `ubuntu-latest` — no `setup-node`, no | |
| # install, no build. | |
| # | |
| # Those counts stay, and the distinction is deliberate (objectui#6089): a | |
| # measurement is HISTORY. It is read together with its date and its sha, and a | |
| # matrix that grows afterwards does not make it false — it makes it older. A | |
| # step NAME is a LIVE CLAIM about the run it labels, so it carries no count at | |
| # all. See the next section. | |
| # | |
| # ── Why the step names carry no case counts ────────────────────────────────── | |
| # | |
| # Each `run:` step below is named for the self-test it calls and nothing else. | |
| # Do not add "(N cases)" back to either name — this file used to do exactly | |
| # that, and it was the declared≠actual shape: | |
| # | |
| # - Nothing derived those numbers and nothing re-checked them. A step name | |
| # has no runtime behaviour, so a wrong count never turns a check red; it | |
| # just shows a wrong number on the checks page, which is the one place a | |
| # reviewer actually looks. | |
| # - The number is already published by the only thing that cannot get it | |
| # wrong: each run's own tail prints `N passed, N failed`, produced by the | |
| # matrix being counted. | |
| # - Where a count belongs beside prose, the hook header owns it WITH the | |
| # recipe to re-derive it: `guard-shared-stash.sh` states its total, how to | |
| # re-derive it (`grep -c '^expect ' <selftest>` plus the inline specials) | |
| # and that it must equal the run's tail. `guard-main-checkout-bash.sh` | |
| # describes its matrix with no count at all. A second copy here would be a | |
| # second thing to keep in sync, and the one with no recipe behind it. | |
| # | |
| # The drift was structural, not carelessness: this file is a RUNNER, so the | |
| # matrices it calls are meant to grow in `.claude/**` WITHOUT this file being | |
| # touched (both #6042 / PR #6087 and #5789 / PR #6046 do exactly that, and are | |
| # right to). Dropping the duplicate is what makes that independence safe. | |
| # | |
| # ── Fail-closed, deliberately ──────────────────────────────────────────────── | |
| # | |
| # Each self-test script already exits non-zero on any failing case (its own | |
| # `pass`/`fail` tally), so a plain `run:` step is enough to fail the job — and | |
| # therefore the workflow — the moment either matrix regresses. A gate that ran | |
| # these and reported without failing the build would reproduce exactly the defect | |
| # this workflow exists to close. | |
| on: | |
| pull_request: | |
| branches: [main, develop] | |
| paths: | |
| - '.claude/hooks/**' | |
| - '.github/workflows/hook-selftests.yml' | |
| push: | |
| branches: [main] | |
| paths: | |
| - '.claude/hooks/**' | |
| - '.github/workflows/hook-selftests.yml' | |
| workflow_dispatch: | |
| concurrency: | |
| group: hook-selftests-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| hook-selftests: | |
| name: Hook Self-Tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| # Hermetic: builds its own throwaway git repo, worktree and non-repo | |
| # directory under $TMPDIR. Exits non-zero on any failing case. | |
| - name: guard-main-checkout-bash self-test | |
| run: .claude/hooks/guard-main-checkout-bash.selftest.sh | |
| # Hermetic in the same sense. Exits non-zero on any failing case. | |
| - name: guard-shared-stash self-test | |
| run: .claude/hooks/guard-shared-stash.selftest.sh | |
| # Hermetic in the same sense — it feeds the hook JSON payloads and needs | |
| # no git fixture at all. Exits non-zero on any failing case. | |
| # | |
| # ⚠️ This step had to be ADDED BY HAND, and that is the standing cost of | |
| # the shape above: the steps here are a hand-kept ENUMERATION of | |
| # `.claude/hooks/*.selftest.sh`, so a new matrix ships uncalled unless | |
| # someone remembers this file — the workflow name claims to be the | |
| # standing caller for all of them, and nothing makes the claim true. | |
| # objectstack's twin does not have this failure mode: its collector runs | |
| # `find .claude/hooks -type f -name '*.selftest.sh'`, so a new self-test is | |
| # picked up with no workflow edit. Converting this job to that shape is | |
| # worth doing and is deliberately NOT done here (it is a change to the | |
| # runner's design, not to the matrix it runs); it is filed separately. | |
| - name: guard-tree-enum self-test | |
| run: .claude/hooks/guard-tree-enum.selftest.sh |