Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/5754-ci-runs-hook-selftests.md
Original file line numberDiff line numberDiff line change
@@ -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).
97 changes: 97 additions & 0 deletions .github/workflows/hook-selftests.yml
Original file line numberDiff line numberDiff line change
@@ -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
40 changes: 38 additions & 2 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:

Expand DownExpand Up@@ -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]`.
Expand All@@ -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.

Expand Down
2 changes: 2 additions & 0 deletions scripts/dependabot-merge-gate.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
});

/**
Expand Down
Loading