diff --git a/.changeset/5754-ci-runs-hook-selftests.md b/.changeset/5754-ci-runs-hook-selftests.md new file mode 100644 index 0000000000..ed261bbe01 --- /dev/null +++ b/.changeset/5754-ci-runs-hook-selftests.md @@ -0,0 +1,14 @@ +--- +--- + +CI-only change: no published package's `src/` changed. + +Adds `.github/workflows/hook-selftests.yml`, a new PR/push gate over +`.claude/hooks/*.selftest.sh` — the hermetic self-test matrices for the +PreToolUse guards behind the worktree-first and never-`git stash` rules. Runs +on any PR touching `.claude/hooks/**` or the workflow itself; fails the build +the moment either matrix goes red. Does not modify the hooks or their +self-tests. Paired writes: `content/docs/guide/ci-cd-pipeline.md` (workflow +inventory) and `scripts/dependabot-merge-gate.mjs` (classifies the new check +as `OPTIONAL_CONTEXTS`, following `Changeset Bump Policy`'s path-filtered +shape). diff --git a/.github/workflows/hook-selftests.yml b/.github/workflows/hook-selftests.yml new file mode 100644 index 0000000000..41486f6681 --- /dev/null +++ b/.github/workflows/hook-selftests.yml @@ -0,0 +1,97 @@ +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 ───────────────────────────────────────────────────────────────────── +# +# Measured 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. +# +# ── 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 (100 cases) + 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 (32 cases) + run: .claude/hooks/guard-shared-stash.selftest.sh diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index d02a1792de..59a4ebe6e0 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -45,6 +45,7 @@ one has its own section below. | `published-dist-gate.yml` | Published Dist Tooling Scan | Nightly cron `41 3 * * *`; push to `main` touching the gate; manual | No — the blocking copy runs on the publish path, not here | | `node-esm-load-gate.yml` | Node ESM Load Scan | Nightly cron `17 4 * * *`; push to `main` touching the gate; manual | No — the per-PR half is `pnpm check:esm-specifiers` in **Type Check** | | `half-state-patrol.yml` | Half-State Patrol | 6-hourly cron `37 1,7,13,19 * * *`; manual; PR touching the sweeper or the workflow | No — **report-only**; it fails only when the sweep could not run | +| `hook-selftests.yml` | Hook Self-Tests | PR / push touching `.claude/hooks/**` or the workflow | **Yes** | The path filters explain most "why did nothing run on my PR?" questions: @@ -1047,6 +1048,41 @@ so that predicate would report the convention rather than a defect and bury ever The rendered summary says that surface is **UNREAD**, never that it is clean ([#5791](https://github.com/objectstack-ai/objectui/issues/5791)). +### Hook Self-Tests (`hook-selftests.yml`) + +**Trigger:** PR to `main`/`develop`, and push to `main`, **when `.claude/hooks/**` or this +workflow file changes**. **Blocks a PR:** yes. + +Runs `.claude/hooks/guard-main-checkout-bash.selftest.sh` (100 cases) and +`.claude/hooks/guard-shared-stash.selftest.sh` (32 cases) — the hermetic self-test matrices for +the two PreToolUse guards behind the rule both `CLAUDE.md` files state as binding: worktree-first, +and never `git stash` (AGENTS.md §9). Each self-test builds its own throwaway git fixture and +needs only `jq` and `git`, both preinstalled on `ubuntu-latest` — no install, no build. + +Before this workflow ([#5754](https://github.com/objectstack-ai/objectui/issues/5754)), nothing +ran either matrix automatically: a hook is not imported by any package, so no unit test, type +check, or lint reaches it (`eslint.config.js` is scoped to `**/*.{ts,tsx}` throughout, and there +is no `shellcheck` anywhere in this repo), and both self-tests' own headers only say to run them +*after touching the hook* — an instruction with no gate behind it. The failure mode is asymmetric +and both halves are bad: a fail-open regression silently stops guarding the shared checkout, and +a fail-closed regression (a false block) trains an operator onto `OS_ALLOW_MAIN_EDITS=1` / +`OS_ALLOW_STASH=1`, switching the guard off for the whole command. Neither shows up in a PR +without a caller. + +**This job is a runner, not a rewrite.** It does not modify the hooks or their self-tests — +`.claude/**` is governed surface. It only gives the existing matrices a caller that fails the +build the moment either one goes red, the same way any other required check does. + +**Path-filtered, unlike `control-bytes.yml`.** That gate carries no `paths` filter because a raw +control byte can land in a markdown-only PR just as easily as a TypeScript one. That reasoning +does not transfer here: both self-tests assert the CURRENT hook script's behaviour against a +fixture they build themselves, so nothing about a docs-only or dependency-bump PR can move the +result. This workflow instead mirrors `changeset-guard.yml`'s inverse-filter shape, firing only +on a PR that touches `.claude/hooks/**` — which is also why `scripts/dependabot-merge-gate.mjs` +classifies **Hook Self-Tests** as `OPTIONAL_CONTEXTS` (present → must be `success`; absent → a +Dependabot bump never touches `.claude/hooks/**`, so it is never waited for) rather than +`REQUIRED_CONTEXTS`, following the same rule `Changeset Bump Policy` and `Bundle Analysis` do. + ### Dependabot Auto-Merge (`dependabot-auto-merge.yml`) **Trigger:** PRs on `main`/`develop` authored by `dependabot[bot]`. @@ -1073,8 +1109,8 @@ So the wait is now explicit and this workflow owns it every context it declares has reported `success`, and only then may the two mutations — approve, enqueue — run. The declared set is the unfiltered blocking contexts (all four shards, **Type Check**, **Lint**, **Build & E2E**, **Build Docs** and the five one-`node`-call gates); the -path-filtered ones (**Bundle Analysis**, **Changeset Bump Policy**) must be green *if they -reported*; everything else is listed with the reason it cannot gate. A context that is missing, +path-filtered ones (**Bundle Analysis**, **Changeset Bump Policy**, **Hook Self-Tests**) must be +green *if they reported*; everything else is listed with the reason it cannot gate. A context that is missing, still running at the deadline, or anything other than `success` is **not** green: nothing merges, the job goes red, and a comment on the PR names what refused. diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 12c320a90f..a85a31a714 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -166,6 +166,8 @@ export const OPTIONAL_CONTEXTS = Object.freeze({ "performance-budget.yml filters on paths: packages/**, apps/console/**, pnpm-lock.yaml. Blocking when it runs (console gzip budget); absent on a PR that touches none of them.", 'Changeset Bump Policy': 'changeset-guard.yml filters on paths: .changeset/**. A Dependabot PR carries no changeset, so it normally does not report at all.', + 'Hook Self-Tests': + 'hook-selftests.yml filters on paths: .claude/hooks/**, plus the workflow file itself (objectui#5754). Blocking when it runs (the two PreToolUse guard self-test matrices must pass); a Dependabot dependency bump never touches .claude/hooks/**, so it normally does not report at all.', }); /**