From 14dc4288d5cb491e541f77b983ef435481e9a082 Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Fri, 9 Oct 2026 10:53:11 -0400 Subject: [PATCH 01/13] ci: add make ci-remote to run the pre-push gate's make ci elsewhere A code push needs tmp/ci-passed-tree-, and only a make ci in the same checkout wrote it, so every push paid for a full local run and the gate only ever exercised the contributor's own OS and architecture. make ci-remote HOST=|docker: streams HEAD to the host as a git bundle on the transport's stdin, checks it out in a fresh directory, runs make tools && make ci there and streams the output back. The same markers are written locally only if the host exits 0, the tree it reports having checked out is HEAD^{tree}, make ci left that checkout clean, and the local worktree is clean before and after the run and still on that tree. Report lines carry a per-run nonce so make's own output can't forge them. A provenance file beside the marker records where the run happened, and the pre-push hook prints it. scripts/ci/remote-ci.test.sh (a make verify leaf) runs the script through stand-in ssh and docker commands in a scratch repository. The host run stops whenever the script exits, however it ends: the host keeps its stdin open and a watcher stops the run's process group when it closes. Each run writes its own log, named with its nonce. The docs cover interrupts, host-network containers and the host's requirements. Closes #764. Part of #740. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH --- .githooks/pre-push | 10 +- AGENTS.md | 6 +- CHANGELOG.md | 1 + CONTRIBUTING.md | 2 +- Makefile | 29 ++- docs/src/content/docs/claude-code.md | 2 +- docs/src/content/docs/development.md | 19 +- scripts/ci-marker.sh | 4 +- scripts/ci/remote-ci.sh | 160 +++++++++++++ scripts/ci/remote-ci.test.sh | 339 +++++++++++++++++++++++++++ 10 files changed, 560 insertions(+), 12 deletions(-) create mode 100755 scripts/ci/remote-ci.sh create mode 100755 scripts/ci/remote-ci.test.sh diff --git a/.githooks/pre-push b/.githooks/pre-push index 6050848b..f762d95c 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,7 +4,8 @@ # allowlist CI uses (scripts/classify-paths.sh), so local and CI agree on # what a change "is": # - any code change → full `make ci` must have passed (tree-keyed marker -# tmp/ci-passed-tree-), exactly as before; +# tmp/ci-passed-tree-), exactly as before, here or on another +# machine via `make ci-remote`; # - docs/prose-only → `make verify` is enough (CI skips the Go/SDK suites # for these too), so only the verify marker is required — no spinning # up the local Docker suites for a typo fix. pre-commit already runs @@ -38,9 +39,14 @@ block_ci() { # — require the full make-ci marker } if [ ! -f "$marker" ]; then tree="${marker#tmp/ci-passed-tree-}" - echo "🛑 pre-push: 'make ci' has not been run for ${sha:0:8} (tree ${tree:0:8}). Run 'make ci' or 'git push --no-verify'." >&2 + echo "🛑 pre-push: 'make ci' has not been run for ${sha:0:8} (tree ${tree:0:8}). Run 'make ci' (or 'make ci-remote HOST=') or 'git push --no-verify'." >&2 exit 1 fi + # `make ci-remote` leaves a provenance file beside its marker. + if [ -f "$marker.provenance" ]; then + prov() { sed -n "s/^$1=//p" "$marker.provenance"; } + echo "✔ pre-push: make ci passed on $(prov host) ($(prov os)/$(prov arch)) for ${sha:0:8}." >&2 + fi } while read -r _ local_sha _ remote_sha; do diff --git a/AGENTS.md b/AGENTS.md index 60ed76dc..8ec029cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This file provides context for AI coding agents (Copilot, Cursor, Cody, Aider, e The non-negotiables, ordered by how often agents miss them. Each links to its detail section — read that before acting. These override convenience: if a rule blocks you, satisfy it; don't work around it. -1. **Validate locally before every push** — run `make ci` the documented way ([§Running `make ci`](#running-make-ci-for-agents)). Don't use CI as your first feedback loop. +1. **Validate locally before every push** — run `make ci` the documented way, here or on a configured host with `make ci-remote` ([§Running `make ci`](#running-make-ci-for-agents)). Don't use CI as your first feedback loop. 2. **A PR-branch push needs every pre-push reviewer satisfied** — run **`/prepush`**, which discovers the reviewers from `scripts/pre-push-reviewers.sh`, runs the ones the change needs in parallel (fresh context), skips any with nothing to do *on the record*, and loops until each it ran returns `ship_it`. Every reviewer needs a marker for the commit being pushed — earned by a `ship_it` or a logged skip; the set is the single source of truth and grows over time (code, docs, security, …), so never hardcode it ([§Pre-push self-review](#pre-push-self-review-is-mandatory-on-pr-branches)). 3. **Every code change updates its docs + `CHANGELOG.md` in the same PR** — a code change without its doc update is incomplete ([§Documentation Sync](#documentation-sync)). 4. **Address and resolve every review finding** — substantive reply, fix it or track it in an issue, @-mention the bot, then resolve; never silently drop one ([§Review Response](#review-response)). @@ -169,6 +169,8 @@ NO_COLOR=1 make ci > tmp/ci.log 2>&1 On success `make ci` writes the tree-keyed `tmp/ci-passed-tree-` marker (see §Enforced via git hooks for the tree-keying and the commit-after-green rule; `tmp/` is gitignored, so the marker never enters the tree). That's one of the markers a PR-branch push requires — the rest are written by the mandatory review subagents, one per reviewer in `scripts/pre-push-reviewers.sh` (see §Agent PR Discipline → Pre-push self-review). End to end: `make ci` green → commit → run every pre-push reviewer in parallel (fresh context) via `/prepush` → loop until each reaches `ship_it` → push. Re-run `make ci` only if a finding makes you edit a tracked file. +**On another machine.** When a host is configured (an ssh destination, or `docker:`), you may satisfy the pre-push gate with `make ci-remote HOST=` instead. It runs `make ci` for `HEAD` there and writes the same `tmp/ci-passed-tree-` marker here only if that run passed for exactly this tree, so the gate's meaning is unchanged. Only `HEAD` travels, so commit first (it refuses a dirty worktree), and don't commit while it runs. Run it in the background exactly like `make ci` (`NO_COLOR=1 make ci-remote HOST= > tmp/ci.log 2>&1`). Host requirements: `docs/src/content/docs/development.md` § Running `make ci` on another machine. + ### Enforced via git hooks `make tools` installs team-wide git hooks via `git config core.hooksPath .githooks`. They apply to humans and Claude Code alike: @@ -286,7 +288,7 @@ That's all: the marker is `tmp/-passed-` automatically, the push gate ### Don't bypass the gates - `--no-verify` on `git commit` / `git push` exists for human WIP / draft pushes. Agents should not use it. -- Markers are written by tooling, never by hand: `tmp/ci-passed-tree-*` by `make ci`; `tmp/-passed-*` (one per reviewer in `scripts/pre-push-reviewers.sh`) by the `review-marker.sh` SubagentStop hook on `ship_it`, **or** by `scripts/skip-pre-push-review.sh` for a deliberately-skipped reviewer (which logs the reason to `tmp/review-skips-.log`). Don't `touch` / `Write` / `Edit` a marker by hand — to skip a reviewer, use the skip command so the skip is recorded; if you're tempted to hand-write a review marker any other way, the marker is wrong-shaped for your situation. Run `make ci`, run or skip each reviewer, get the verdicts. +- Markers are written by tooling, never by hand: `tmp/ci-passed-tree-*` by `make ci` or `make ci-remote`; `tmp/-passed-*` (one per reviewer in `scripts/pre-push-reviewers.sh`) by the `review-marker.sh` SubagentStop hook on `ship_it`, **or** by `scripts/skip-pre-push-review.sh` for a deliberately-skipped reviewer (which logs the reason to `tmp/review-skips-.log`). Don't `touch` / `Write` / `Edit` a marker by hand — to skip a reviewer, use the skip command so the skip is recorded; if you're tempted to hand-write a review marker any other way, the marker is wrong-shaped for your situation. Run `make ci`, run or skip each reviewer, get the verdicts. These are policy, not mechanically enforced. Bash can write a file a dozen ways; an agent can edit `.claude/hooks/agent-bash-gate.sh` itself. Trust beats whack-a-mole regex. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f54b243..9242e0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Docs-site analytics for search, code copies, 404s, docs section, and live-demo connectivity** (`docs/src/components/DocsTracking.astro` (new), `docs/src/components/{PostHog,Footer,LiveDemo}.astro`): the site tracked its own CTAs but nothing a reader did on the way to one, so the questions that decide what to write next — what people search for and *don't* find, which snippets get copied, which dead links keep getting followed — had no data behind them. `docs_search` fires a second after the query settles rather than once per keystroke, carrying `query` and `result_count` read off Pagefind's own results message (the rendered list is capped at its page size, so counting the DOM would under-report); `result_count: 0` is the event worth having. `code_copied` (`page`, `language`) watches Expressive Code's copy buttons from the document rather than re-binding every code block on every navigation — the hero's install chip is not an EC block and keeps its own `hero_install_copied`. `docs_404` (`path`, `referrer`) turns broken inbound links into a list instead of a hunch. A `doc_section` property (the first path segment, `home` for `/`) puts every event in a docs area without each tracker carrying its own copy; it's stamped at capture time by a `before_send` hook in `posthog.init()` rather than `register()`, because a queued `register()` replays only after init has already captured the first hard-load `$pageview` — which would then carry the previous visit's persisted value — and `history_change` navigations update the URL before capture fires, so reading `location` in the hook is always current. `live_demo_connected` fires once per mount when the hero's SSE feed comes up rather than on its first row — named for what it measures (the demo backend answered), since a quiet minute on the repo is not a disengaged reader. The three site-wide trackers share one new `DocsTracking.astro` rendered from the footer (like `MermaidZoom` / `ScrollHints`) and delegate from `document`, since Pagefind, Expressive Code, and the 404 route all own their own markup — some of it created after page load. - **Dedupe retention per tenant and table, and a sweep that deletes expired ids** (`internal/settings/{settings,validate,store}.go` (+ tests), `internal/settings/seed/config.json`, `internal/dedupe/{embedded,sweep}.go` (+ tests), `internal/api/ingest.go` (+ tests), `internal/testutil/mocks.go`, `deployments/compose/settings/config.json`, `tests/e2e/fixtures/settings/config.json`, `config.yaml`, `docs/src/content/docs/{settings-directory.mdx,deployment,durability,architecture}.md`, `AGENTS.md`): [#220](https://github.com/Wave-RF/WaveHouse/issues/220). `config.json` gains an optional **`dedupe.retention`** key, overridable per table in `dedupe.tables..retention`: how long a committed id stays a duplicate, as a duration string (`"720h"`), or `"0"` to keep it forever, which is the seed value and the behaviour before this release. A `config.json` without the key keeps ids forever, and a table override without one inherits the tenant's, so an existing directory needs no change. A finite retention below `"2m"`, the ingest queue's duplicate window, is refused rather than raised to the minimum: every deduped record is published under an idempotency key derived from its id, so an id re-sent after a shorter retention would be claimed again and dropped by the queue as a copy while the client was told it was accepted. It is hot-reloadable and read per record like `id_field`; a change applies to ids committed after it, and a reload landing mid-window commits each record with the retention it was prepared under. The embedded Pebble store already treated an expired id as new; it now also deletes expired keys, and the version-0 keys the key-layout change left behind, in a background sweep that starts a minute after the instance opens and repeats hourly. It reads 1,024 keys per chunk without a lock, then re-reads the expired and version-0 ones under a lock `Commit` also takes and deletes, without fsync, those that still are, so an id committed again after the sweep read it is never deleted, and a `Commit` waits for at most one chunk's re-reads, never for the deleted keys a chunk steps over. New metric `wavehouse_dedupe_swept_keys_total{reason="expired"|"version_0"}`. `settings.Store.DedupeFor` now returns a `settings.Dedupe` struct rather than three values. - **`make build-ts` loads the built SDK entry points at the oldest supported Node** (`clients/ts/scripts/smoke-dist.mjs` (new), `clients/ts/{package.json,README.md}`, `clients/ts/scripts/fetch-node.sh` (new), `Makefile`, `biome.json`, `docs/src/content/docs/development.md`): closes [#498](https://github.com/Wave-RF/WaveHouse/issues/498). Nothing had loaded a built artifact, so a packaging break shipped green: an ESM-only dependency such as `eventsource-parser@4` would have made `require("@wavehouse/sdk")` throw `ERR_REQUIRE_ESM` on Node 22.0 to 22.11, inside the advertised `engines.node: ">=22"`. `make smoke-ts-dist`, which `build-ts` now runs after the build (so `test-e2e`, `build-all` and `make ci` all gate on it), reads the minimum from `engines.node` and runs `smoke-dist.mjs` under that exact Node downloaded once into `.bin/` by `clients/ts/scripts/fetch-node.sh` (verified against a pinned sha256 per platform, like shellcheck; `pnpm dlx` was rejected because pnpm 11 asks nodejs.org on every run and resolves a bare `node@x.y.z` to an unrelated npm package) and fails unless that is the Node it ran on, since `.nvmrc`'s floating `22` sits above the `require(esm)` line and cannot see the break. The script imports `@wavehouse/sdk` and `require()`s it (through the `exports` map, which must resolve to the declared files) and runs `dist/index.global.js` as a classic script in a fresh `vm` context, runs the codegen bin with `--help`, checks `main`, `module` and `jsdelivr` agree with `exports` and `unpkg`, takes the ESM build's export names as the reference surface, and fails if the CJS or IIFE surface differs in either direction. Entry point paths come from `package.json`, so none is hand-copied. Run by hand with `pnpm --filter @wavehouse/sdk run smoke:dist` on any Node. +- **`make ci-remote` runs `make ci` on another machine, and a pass there satisfies the pre-push gate** (`scripts/ci/remote-ci.sh` (new, + `remote-ci.test.sh`), `Makefile`, `.githooks/pre-push`, `scripts/ci-marker.sh`, `docs/src/content/docs/{development,claude-code}.md`, `AGENTS.md`, `CONTRIBUTING.md`): closes [#764](https://github.com/Wave-RF/WaveHouse/issues/764). A code push needs `tmp/ci-passed-tree-`, and only a `make ci` in the same checkout wrote it, so every push cost a full local run (the race detector, coverage, and a Docker daemon carrying every suite's containers) and only ever tested the contributor's own OS and architecture. `make ci-remote HOST=` takes an ssh destination or `docker:`, streams `HEAD` there as a `git bundle` (nothing is pushed), checks it out in a fresh directory, runs `make tools && make ci`, and streams the output back, the host's `uname -sm` first. Stopping it, by Ctrl-C or by a signal to the script alone, stops the run on the host as well: the host's stdin stays open only while the script runs, and a watcher stops the run's process group when it closes. It writes the markers a local `make ci` does only if the host exits 0, the tree the host reports having checked out is `HEAD^{tree}`, `make ci` left the host's checkout clean, and the local worktree is clean before and after the run and still on that tree; the host's report lines carry a per-run nonce, so `make ci`'s own output can't forge them. A provenance file beside the marker records the host, its OS and architecture, the start and end times and the run's own local copy of the log, and the pre-push hook prints `make ci passed on (/)` from it. What the marker means, and a local `make ci`, are unchanged. `make test-remote-ci`, a new `make verify` leaf, drives the script through stand-in `ssh` and `docker` commands against a scratch repository. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b473f715..5c5e2af9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ Open a [feature request issue](https://github.com/Wave-RF/WaveHouse/issues/new?t make ci # full local pipeline: verify + builds + all test suites (Docker required) ``` - The pre-push hook (installed by `make tools`) blocks a push until the tree has been validated locally: a code change needs `make ci`, a docs/prose-only change needs only `make verify` (the same split CI makes). `make lint` / `make test` / `make build` are fast inner-loop subsets. + The pre-push hook (installed by `make tools`) blocks a push until the tree has been validated: a code change needs `make ci`, run locally or on another machine with `make ci-remote`, and a docs/prose-only change needs only `make verify` (the same split CI makes). `make lint` / `make test` / `make build` are fast inner-loop subsets. 2. Write tests for new functionality. Unit tests go alongside the code in `internal/`. Integration tests carry the `//go:build integration` tag and go in `tests/integration/`, or beside the package when they test one package against its own external server (e.g. `internal/cache/redis_integration_test.go`), with the package added to a part of the integration suite in the `Makefile` (`make test-integration` fails while it is in none). A test that must import NATS, which only `internal/mq` may do, goes in `internal/mq/natsspike`, or in `internal/mq` itself with the `integration` tag when it needs the package's internals (the external NATS broker's tests and the NATS KV lease tests, which `make test-integration` selects by that tag). diff --git a/Makefile b/Makefile index 2367d29f..7e23950f 100644 --- a/Makefile +++ b/Makefile @@ -96,6 +96,7 @@ ARCH := $(shell uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/') # LDFLAGS="-s -w" extra ldflags (e.g. force-strip a local build) # LIMIT=50 top-N for `make dep-cut` # JOBS=4 parallel slots for the -j fan-outs (default: CPU count) +# HOST=box KEEP=1 REMOTE_DIR=d where `make ci-remote` runs (see that target) # # Add binaries here as the project grows (e.g., wavehouse-api, wavehouse-worker). BINARIES := wavehouse @@ -511,6 +512,15 @@ test-prune-pr-build-cache: test-review-gate: $(call run,review-gate test,.claude/hooks/review-gate.test.sh,) +# test-remote-ci: drive scripts/ci/remote-ci.sh through stand-in ssh and docker +# commands in a scratch repository: only a passing run of exactly HEAD's tree, +# on a worktree still clean afterwards, may write the make-ci marker. Also +# checks the pre-push hook's line for that marker. A verify leaf, same as +# test-classify-paths. +.PHONY: test-remote-ci +test-remote-ci: + $(call run,remote-ci test,scripts/ci/remote-ci.test.sh,) + .PHONY: vulncheck vulncheck: go-mod-download ## Run govulncheck -scan package (V=1: symbol-level scan with example traces) ifdef V @@ -601,13 +611,14 @@ fix-prose: $(MISSPELL) # slowest tool, not the slowest *group* (e.g. golangci no longer drags Biome + # markdownlint along behind it). # -# Leaves (20): tidy, fmt-go (gofumpt), lint-go (golangci), vulncheck on the Go +# Leaves (21): tidy, fmt-go (gofumpt), lint-go (golangci), vulncheck on the Go # side; lint-ts (biome check) + lint-md (markdownlint) + lint-prose (misspell, # docs spelling) + test-md-rules (node --test over the WH001/WH002 fixtures) # for JS/TS + Markdown + prose; lint-sh (shellcheck), lint-gha (actionlint), # test-classify-paths, test-release-channel, test-go-toolchain, check-dockerfile-go, test-tagged-tests, -# test-integration-parts, test-prune-pr-build-cache and test-review-gate for the -# tooling; +# test-classify-paths, test-release-channel, test-go-toolchain, check-dockerfile-go, test-tagged-tests, +# test-integration-parts, test-prune-pr-build-cache, test-review-gate and +# test-remote-ci for the tooling; # check-docs (astro check — the only leaf that writes, to docs/.astro/, and # nothing else touches it) and typecheck-ts (tsc --noEmit). It runs lint-ts # (`biome check`) but NOT fmt-ts (`biome format`) — check already covers @@ -621,7 +632,7 @@ verify: ## Run all static checks across the repo (Go + TS + docs, parallelized) @printf "$(GREEN)$(BOLD)✔ All static checks passed$(RESET)\n" .PHONY: verify-parallel -verify-parallel: tidy fmt-go lint-go lint-ts lint-md lint-prose lint-sh lint-gha test-classify-paths test-md-rules test-release-channel test-go-toolchain check-dockerfile-go test-tagged-tests test-integration-parts test-prune-pr-build-cache test-review-gate vulncheck check-docs typecheck-ts +verify-parallel: tidy fmt-go lint-go lint-ts lint-md lint-prose lint-sh lint-gha test-classify-paths test-md-rules test-release-channel test-go-toolchain check-dockerfile-go test-tagged-tests test-integration-parts test-prune-pr-build-cache test-review-gate test-remote-ci vulncheck check-docs typecheck-ts # typecheck-ts: tsc --noEmit on the SDK. Its own target (was inline in verify's # recipe) so it can run as a parallel leaf of verify-parallel. @@ -1018,6 +1029,16 @@ ci: ## Full pipeline — parallel checks, then sequential heavy suites + coverag @scripts/ci-marker.sh write @echo "$(GREEN)$(BOLD)✔ All CI checks passed$(RESET)" +# ci-remote: run `make ci` for HEAD on another machine, an ssh destination or +# docker:, and write the same markers here if it passed there for +# exactly this tree. Host requirements and the checks: scripts/ci/remote-ci.sh. +HOST ?= +KEEP ?= +REMOTE_DIR ?= +.PHONY: ci-remote +ci-remote: ## Run `make ci` for HEAD on HOST= or HOST=docker: (KEEP=1, REMOTE_DIR=…) + @KEEP='$(KEEP)' REMOTE_DIR='$(REMOTE_DIR)' scripts/ci/remote-ci.sh '$(HOST)' + ##@ Release # Releases are cut by pushing ONE tag — no version bump, no commit, no release diff --git a/docs/src/content/docs/claude-code.md b/docs/src/content/docs/claude-code.md index 75d927e0..555176c0 100644 --- a/docs/src/content/docs/claude-code.md +++ b/docs/src/content/docs/claude-code.md @@ -37,7 +37,7 @@ Two scripts, both committed to the repo: | Hook | Behavior | | ---- | -------- | | `pre-commit` | Runs `make verify` (the static checks listed in [Development → Makefile Targets](/development#makefile-targets), ~30s) — **blocks on failure**. Skipped if `make ci` or `make verify` already ran for the current tree state (cached via `scripts/ci-marker.sh`). | -| `pre-push` | Scales the bar to the change set (same classifier CI uses, `scripts/classify-paths.sh`): a **code** change requires the `make ci` marker (`tmp/ci-passed-tree-`); a **docs/prose-only** push requires only the `make verify` marker (`tmp/verify-passed-tree-`) — CI skips the Go/SDK suites for those too. **Blocks** if the required marker is absent. Fail-closed: an unclassifiable push falls back to requiring `make ci`. | +| `pre-push` | Scales the bar to the change set (same classifier CI uses, `scripts/classify-paths.sh`): a **code** change requires the `make ci` marker (`tmp/ci-passed-tree-`, from `make ci` or `make ci-remote`); a **docs/prose-only** push requires only the `make verify` marker (`tmp/verify-passed-tree-`) — CI skips the Go/SDK suites for those too. **Blocks** if the required marker is absent. Fail-closed: an unclassifiable push falls back to requiring `make ci`. | `--no-verify` is for intentional WIP / draft pushes. Agents should not use it — policy in AGENTS.md §"Agent PR Discipline", not regex-enforced. diff --git a/docs/src/content/docs/development.md b/docs/src/content/docs/development.md index 37a4332c..888a5364 100644 --- a/docs/src/content/docs/development.md +++ b/docs/src/content/docs/development.md @@ -340,6 +340,22 @@ Each test target writes `covdata` to `tmp/coverage//data/`, renders a tex **Build cache across worktrees**: `make` exports `GOFLAGS=-trimpath` (your own `GOFLAGS` are kept), so compiled packages don't embed the checkout's path and every worktree of the same source shares one build cache entry per package and variant, instead of building its own. Opt out with `GOFLAGS=-trimpath=false make …`. `make build` is the exception: its debug binary keeps absolute source paths, so a debugger finds the files. Release and coverage binaries and test binaries built through `make` are trimmed; their frames read `github.com/Wave-RF/WaveHouse/internal/…` (standard-library frames are plain import paths such as `runtime/proc.go`), so map the module path to your checkout, for example, at the `(dlv)` prompt, `config substitute-path github.com/Wave-RF/WaveHouse /absolute/path/to/your/checkout` (or a `substitute-path` entry with `from` and `to` in `~/.config/dlv/config.yml`, or your editor's `substitutePath`). Code that needs a file from the repository finds it from the working directory, never from `runtime.Caller`, whose paths are module-relative under `-trimpath`. +### Running `make ci` on another machine + +`make ci-remote HOST=` runs `make ci` for your `HEAD` commit on another machine and streams its output back, so the heavy suites can run off your laptop, or on another OS or architecture than yours (CI runs on Linux amd64). + +```bash +make ci-remote HOST=build-box # any ssh destination: user@host, a ~/.ssh/config alias +make ci-remote HOST=docker:wavehouse-dev # a running local container or devcontainer +make ci-remote HOST=build-box KEEP=1 REMOTE_DIR=/scratch/wavehouse-ci +``` + +Only the commit travels, so the worktree must be clean: `HEAD` is streamed as a `git bundle` over the ssh or `docker exec` connection, and nothing is pushed. The host checks it out in a fresh directory under `REMOTE_DIR` (default `.cache/wavehouse-ci`; a relative path is under the host's home), runs `make tools && make ci` there, and removes the directory afterwards unless `KEEP=1`. The host's `uname -sm` is the first line of the output, and each run keeps a copy of its log in `tmp/ci-remote--.log`, so two runs of one tree, say on two architectures at once, don't overwrite each other. If `make ci-remote` is stopped, by Ctrl-C or a signal, the host stops its `make ci` within a second or two: the host's end of the connection stays open only while the local command runs, and the host stops the run when it closes. + +The host needs what a local `make ci` needs: bash, git, curl, jq, GNU Make 4+, Go, Node and pnpm from [Prerequisites](#prerequisites), network access for `make tools`, and a Docker daemon for the integration and e2e suites. Over ssh the commands run in a non-interactive login shell, so the tools must be on the `PATH` the host's profile sets; in a container they must be on the container's own `PATH`, and the container must reach a Docker daemon (a mounted socket or Docker-in-Docker). If `make ci` runs inside a container on the host's network, testcontainers may try to reach the suites' containers through the Docker bridge gateway and time out; setting `TESTCONTAINERS_HOST_OVERRIDE=localhost` in the host's environment points it at the published ports instead. A Linux host without Chromium's system libraries needs them installed once for the docs build, as Prerequisites describes (`pnpm --filter wavehouse-docs exec playwright install-deps chromium`). + +**How the pre-push gate treats it.** The gate's meaning doesn't change: a code push still needs `tmp/ci-passed-tree-`, which says `make ci` passed for exactly that tree. `make ci-remote` writes it (and the verify marker) only if the host exits 0, the tree the host reports having checked out equals your `HEAD^{tree}`, `make ci` left the host's checkout clean, and your worktree is still clean and on that tree when the run ends; otherwise it writes nothing. Beside the marker, `tmp/ci-passed-tree-.provenance` records the host, its OS and architecture, the start and end times and the log path, and the pre-push hook prints `make ci passed on (/)` from it. A marker from a local `make ci` has no provenance file and works exactly as before. + ### Test Structure | Category | Location | Docker? | Command | @@ -533,7 +549,7 @@ Run `make help` to see all targets. Key ones: | `make tidy` | Verify `go.mod`/`go.sum` are tidy (run `make fix` to apply) | | `make lint` | Run linters across Go (`golangci-lint`) + TS (Biome) + Markdown/MDX (markdownlint) + prose (misspell) | | `make vulncheck` | Run `govulncheck -scan package` (`V=1`: the default symbol-level scan, with example traces) | -| `make verify` | Repo-wide static checks: Go (tidy + fmt + vulncheck + lint) + TS (Biome + `tsc` typecheck) + Markdown/MDX (markdownlint + rule fixtures) + prose (misspell) + shell (shellcheck) + workflows (actionlint) + path-classifier fixtures + release-channel fixtures + go-toolchain fixtures + Dockerfile Go-version check + tagged-test selector and integration-parts fixtures + PR build-cache prune fixtures + review-gate hook tests + docs type-check (`astro check` — not a full build, so link validation is left to `make build-docs`, which `make ci` runs); a bare `make verify` runs these in parallel | +| `make verify` | Repo-wide static checks: Go (tidy + fmt + vulncheck + lint) + TS (Biome + `tsc` typecheck) + Markdown/MDX (markdownlint + rule fixtures) + prose (misspell) + shell (shellcheck) + workflows (actionlint) + path-classifier fixtures + release-channel fixtures + go-toolchain fixtures + Dockerfile Go-version check + tagged-test selector and integration-parts fixtures + PR build-cache prune fixtures + review-gate hook tests + `make ci-remote` tests + docs type-check (`astro check` — not a full build, so link validation is left to `make build-docs`, which `make ci` runs); a bare `make verify` runs these in parallel | | `make fix` | Auto-fixes across Go (`tidy` + `gofumpt` + `goimports` + `lint --fix`), TS (Biome `--write`), Markdown (markdownlint `--fix`), MDX (`fix-mdx-fences` only — the generic fixers never run over `.mdx`), and docs-prose spelling (misspell, both) | | **Build** | | | `make build` | Compile `wavehouse` → `bin/wavehouse` (debug symbols kept) | @@ -549,6 +565,7 @@ Run `make help` to see all targets. Key ones: | `make test-e2e` | E2E SDK suite against `bin/wavehouse-cov` + coverage gate | | `make test-all` | All four suites sequentially + merged coverage gate | | `make ci` | Full pipeline: parallel `verify` + builds + unit/SDK tests, then integration + E2E + cov | +| `make ci-remote HOST=` | `make ci` for `HEAD` on an ssh host or `docker:`; writes the same marker if it passes there for this exact tree ([details](#running-make-ci-on-another-machine)) | | **Release** (see [Cutting a release](#cutting-a-release)) | | | `make release-server VERSION=X.Y.Z` | Tag a server release — binaries + container image | | `make release-sdk-ts VERSION=X.Y.Z` | Tag a TypeScript SDK release — npm | diff --git a/scripts/ci-marker.sh b/scripts/ci-marker.sh index 8b59e8c0..46b9c81b 100755 --- a/scripts/ci-marker.sh +++ b/scripts/ci-marker.sh @@ -3,7 +3,9 @@ # tmp/ci-passed-tree- — full `make ci` passed # tmp/verify-passed-tree- — just `make verify` passed (subset of ci) # `make ci` writes both (it runs verify); `make verify` writes only the verify -# marker. Pre-commit skips re-running verify when the marker is current; +# marker; `make ci-remote` (scripts/ci/remote-ci.sh) writes both, with a +# provenance file beside the ci marker, after `make ci` passes on another +# machine. Pre-commit skips re-running verify when the marker is current; # pre-push consults the ci marker. Skipped on CI runners. set -euo pipefail diff --git a/scripts/ci/remote-ci.sh b/scripts/ci/remote-ci.sh new file mode 100755 index 00000000..cdb08517 --- /dev/null +++ b/scripts/ci/remote-ci.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Run `make ci` for HEAD on another machine and, when it passes there for +# exactly this tree, write the markers a local `make ci` would +# (tmp/ci-passed-tree- and the verify marker), plus a provenance file +# beside them that .githooks/pre-push prints. `make ci-remote` calls this. +# +# Usage: scripts/ci/remote-ci.sh +# an ssh destination (user@box, a ~/.ssh/config alias, ...), or +# docker: for a running local container +# KEEP=1 leave the run directory on the host +# REMOTE_DIR base directory for per-run checkouts on the host; a relative +# path is under the host's home (default .cache/wavehouse-ci) +# +# The host needs bash, git, GNU make 4+, curl and jq, and what `make ci` itself +# needs: Go, Node and pnpm for `make tools`, and a Docker daemon for the +# integration and e2e suites. Over ssh the commands run in a login shell, so +# PATH comes from the host's profile; in a container it is the container's own. +# +# Nothing is pushed. HEAD travels as a git bundle on the transport's stdin and +# is checked out in a fresh directory under REMOTE_DIR, which is removed after +# the run unless KEEP=1. When this script exits, however it is stopped, the +# host stops the run within about a second. +# The markers are written only if the host exits 0, the tree it reports having +# tested is HEAD^{tree} here, and this worktree is still clean. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 1 + +die() { + printf '🛑 ci-remote: %s\n' "$*" >&2 + exit 1 +} + +# Runs on the host. bash reads a script from a pipe one byte at a time, so +# after it reads the call below, the bundle that follows is left for `head`. +# Report lines carry this run's nonce, which make's output cannot know. +remote_main() { + set -eu + exec 2>&1 + say() { printf 'ci-remote[%s] %s\n' "$nonce" "$*"; } + # shellcheck disable=SC2329 # run by the EXIT trap + finish() { + if [ -n "${watcher:-}" ]; then kill -- "-$watcher" 2> /dev/null || true; fi + if [ "$keep" = 1 ]; then say "kept $dir"; else rm -rf "$dir"; fi + } + case $base in + /*) ;; + *) base=$HOME/${base#"~/"} ;; + esac + mkdir -p "$base" + dir=$(mktemp -d "$base/run.XXXXXX") + trap finish EXIT + say "uname $(uname -sm)" + say "dir $dir" + head -c "$size" > "$dir/head.bundle" + git clone -q --no-checkout "$dir/head.bundle" "$dir/src" + cd "$dir/src" + git -c advice.detachedHead=false checkout -q --detach "$commit" + say "tree $(git rev-parse 'HEAD^{tree}')" + if [ -n "$no_color" ]; then export NO_COLOR="$no_color"; fi + # Stdin stays open until the local side exits. When it closes first, nobody + # is waiting for the run, so stop it: job control gives it a process group + # of its own to signal. + set -m + { make tools && make ci; } < /dev/null & + run=$! + { cat > /dev/null; kill -TERM -- "-$run"; } > /dev/null 2>&1 & + watcher=$! + wait "$run" + # A local `make ci` keys its marker on the tree it leaves behind. + if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then + echo "make ci changed the checkout:" + git status --short --untracked-files=normal + exit 1 + fi +} + +host=${1:-} +case $host in + docker:*) target=${host#docker:} ;; + *) target=$host ;; +esac +case $target in + "") die "usage: make ci-remote HOST=|docker:" ;; + -* | *[[:space:][:cntrl:]]*) die "not a usable host: '$host'" ;; +esac +case $host in + docker:*) transport=(docker exec -i "$target" bash -s) ;; + *) transport=(ssh -T "$target" bash -l -s) ;; +esac + +clean() { + local s + s=$(git status --porcelain --untracked-files=normal) && [ -z "$s" ] +} +clean || die "the worktree has uncommitted or untracked changes, and the host only gets HEAD. Commit them first." + +commit=$(git rev-parse --verify HEAD) || die "no HEAD commit" +tree=$(git rev-parse "$commit^{tree}") || die "can't resolve the tree of ${commit:0:8}" +keep=0 +[ "${KEEP:-}" = 1 ] && keep=1 +base=${REMOTE_DIR:-.cache/wavehouse-ci} +nonce=$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n') +[ -n "$nonce" ] || die "can't read /dev/urandom" + +bundle=$(mktemp) || die "can't create a temporary file" +git bundle create -q "$bundle" HEAD || { rm -f "$bundle"; die "can't bundle HEAD"; } +size=$(($(wc -c < "$bundle"))) +# Read through fd 3 from here on, so the file is gone however the script ends. +exec 3< "$bundle" +rm -f "$bundle" + +mkdir -p tmp +log=tmp/ci-remote-$tree-$nonce.log +printf '==> make ci on %s for %s (tree %s), log in %s\n' "$host" "${commit:0:8}" "${tree:0:8}" "$log" >&2 +started=$(date -u +%Y-%m-%dT%H:%M:%SZ) +{ + printf 'commit=%q base=%q keep=%q nonce=%q no_color=%q size=%q\n' "$commit" "$base" "$keep" "$nonce" "${NO_COLOR:-}" "$size" + declare -f remote_main + echo 'remote_main; exit' + cat <&3 + # Hold the host's stdin open while this script runs ($$ is still its PID in + # this subshell): a signal to the script alone leaves the transport running. + while kill -0 "$$" 2> /dev/null && echo; do sleep 1; done 2> /dev/null +} | "${transport[@]}" 2>&1 | tee "$log" +status=("${PIPESTATUS[@]}") +finished=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +report() { + awk -v p="ci-remote[$nonce] $1 " 'index($0, p) == 1 { print substr($0, length(p) + 1); exit }' "$log" +} +uname_sm=$(report uname) +remote_dir=$(report dir) +tested=$(report tree) + +[ "${status[1]}" = 0 ] || die "make ci failed on $host (exit ${status[1]}); no marker written. Log: $log" +[ "${status[2]}" = 0 ] || die "can't write $log; no marker written." +[ "$tested" = "$tree" ] || die "$host reported testing tree '${tested:0:8}', not ${tree:0:8}; no marker written." +now=$(git rev-parse "HEAD^{tree}") || die "can't resolve HEAD" +[ "$now" = "$tree" ] || die "HEAD moved to tree ${now:0:8} during the run, which tested ${tree:0:8}; no marker written. Rerun." +clean || die "the worktree changed during the run; no marker written. Commit, then rerun." + +marker=$(scripts/ci-marker.sh path-for-commit "$commit") || die "can't resolve the marker path" +verify_marker=$(scripts/ci-marker.sh verify-path-for-commit "$commit") || die "can't resolve the marker path" +prov=$marker.provenance +{ + echo "host=$host" + echo "os=${uname_sm%% *}" + echo "arch=${uname_sm##* }" + echo "commit=$commit" + echo "tree=$tree" + echo "started=$started" + echo "finished=$finished" + echo "log=$log" + echo "remote_dir=$remote_dir" + echo "kept=$keep" +} > "$prov.tmp" || die "can't write $prov" +mv "$prov.tmp" "$prov" || die "can't write $prov" +touch "$marker" "$verify_marker" || die "can't write $marker" +printf '✔ ci-remote: make ci passed on %s (%s) for %s; wrote %s\n' "$host" "$uname_sm" "${commit:0:8}" "$marker" >&2 diff --git a/scripts/ci/remote-ci.test.sh b/scripts/ci/remote-ci.test.sh new file mode 100755 index 00000000..2ed496f4 --- /dev/null +++ b/scripts/ci/remote-ci.test.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +# Behavioral test for scripts/ci/remote-ci.sh (`make ci-remote`) and the +# provenance line .githooks/pre-push prints for its marker. Runs the script in +# a scratch repository with stand-in `ssh` and `docker` commands on PATH: they +# record how they were called, then run the host side for real in a scratch +# home against a stub `make` and `uname`, so no host, network or Docker is +# needed. Markers come only from the script under test. Run by `make verify` +# (target: test-remote-ci). Needs git. + +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1 # repo root (scripts/ci/../..) +fails=0 + +scratch=$(mktemp -d) || exit 1 +trap 'rm -rf "$scratch"' EXIT +scratch=$(cd "$scratch" && pwd -P) + +# Run from a git hook (pre-commit runs make verify), GIT_DIR and friends point +# at this checkout, so a `git init` here would re-initialize it. Clear them, +# refuse to run unless git sees the scratch directory as outside any +# repository, and name every repository with -C. +# shellcheck disable=SC2046 # one variable name per word +unset $(git -C "$scratch" rev-parse --local-env-vars) +# Hiding untracked files from `git status` leaves the scripts' own +# --untracked-files=normal as the only thing that finds them. +printf '[status]\n\tshowUntrackedFiles = no\n' > "$scratch/gitconfig" +export GIT_CONFIG_GLOBAL=$scratch/gitconfig GIT_CONFIG_NOSYSTEM=1 +if git -C "$scratch" rev-parse --git-dir >/dev/null 2>&1; then + echo "remote-ci test: git resolves $scratch to an existing repository; refusing to run." >&2 + exit 1 +fi +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com + +repo=$scratch/repo +git -C "$scratch" init -q -b main "$repo" +if [ "$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null)" != "$repo/.git" ]; then + echo "remote-ci test: git did not create $repo/.git; refusing to go on." >&2 + exit 1 +fi +mkdir -p "$repo/scripts/ci" "$repo/.githooks" +cp scripts/ci-marker.sh scripts/classify-paths.sh "$repo/scripts/" +cp scripts/ci/remote-ci.sh "$repo/scripts/ci/" +cp .githooks/pre-push "$repo/.githooks/" +echo 'tmp/' > "$repo/.gitignore" +git -C "$repo" add -A +git -C "$repo" commit -q -m base +git -C "$repo" update-ref refs/remotes/origin/main HEAD +echo 'package main' > "$repo/main.go" +git -C "$repo" add main.go +git -C "$repo" commit -q -m code + +# ── Stand-ins ── +# bin/ssh, bin/docker record their arguments, then run the host side in +# a process group of its own, as sshd would +# host-bin/make records each call with the tree of the checkout it +# runs in; `make ci` exits with the code in make-exit +# after running the during-ci script, if there is one +# host-bin/uname a Linux amd64 host +# host-bin/git git, except that a checkout lands on the commit +# named in wrong-commit, if there is one +mkdir -p "$scratch/bin" "$scratch/host-bin" +for cmd in ssh docker; do + cat > "$scratch/bin/$cmd" << EOF +#!/usr/bin/env bash +echo "$cmd \$*" >> "$scratch/transport.log" +cd "$scratch/home" || exit 1 +set -m +HOME="$scratch/home" PATH="$scratch/host-bin:\$PATH" bash -s & +wait \$! +EOF +done +cat > "$scratch/host-bin/make" << EOF +#!/usr/bin/env bash +echo "make \$* in \$(git rev-parse 'HEAD^{tree}')" >> "$scratch/make.log" +[ "\$1" = ci ] || exit 0 +echo "stub make ci output" +[ ! -f "$scratch/during-ci" ] || bash "$scratch/during-ci" +exit "\$(cat "$scratch/make-exit")" +EOF +printf '#!/bin/sh\necho "Linux x86_64"\n' > "$scratch/host-bin/uname" +cat > "$scratch/host-bin/git" << EOF +#!/usr/bin/env bash +if [ -f "$scratch/wrong-commit" ] && [[ " \$* " == *" checkout "* ]]; then + set -- "\${@:1:\$#-1}" "\$(cat "$scratch/wrong-commit")" +fi +exec "$(command -v git)" "\$@" +EOF +chmod +x "$scratch/bin/"* "$scratch/host-bin/"* + +ok() { printf ' ok %s\n' "$1"; } +fail() { + printf ' FAIL %s\n' "$1" >&2 + [ -z "${2:-}" ] || printf '%s\n' "$2" | sed 's/^/ /' >&2 + fails=$((fails + 1)) +} + +# reset: a clean slate for the next case. +reset() { + rm -rf "$repo/tmp" "${scratch:?}/home" "$scratch/runs" "$scratch/during-ci" "$scratch/wrong-commit" + mkdir -p "$scratch/home" + : > "$scratch/transport.log" + : > "$scratch/make.log" + echo 0 > "$scratch/make-exit" +} + +# start [VAR=value…]: the script under test, in the scratch repository, +# in the background; $! is the script's own PID. +start() { + local host=$1 + shift + (cd "$repo" && exec env PATH="$scratch/bin:$PATH" "$@" scripts/ci/remote-ci.sh "$host") > "$scratch/out" 2>&1 & +} + +# run [VAR=value…]: start, then wait. A run that hangs is stopped after +# 60s, so a regression fails here instead of stalling `make verify`. +run() { + local pid dog rc + start "$@" + pid=$! + (sleep 60 && kill -TERM "$pid") > /dev/null 2>&1 & + dog=$! + wait "$pid" + rc=$? + kill "$dog" 2> /dev/null + return "$rc" +} + +tree() { git -C "$repo" rev-parse 'HEAD^{tree}'; } +marker() { echo "$repo/tmp/ci-passed-tree-$1"; } +prov() { sed -n "s/^$1=//p" "$(marker "$2").provenance"; } +leftover_runs() { find "$scratch/home" "$scratch/runs" -name 'run.*' -prune 2>/dev/null; } + +expect_marker() { # + if [ -f "$(marker "$2")" ] && [ -f "$repo/tmp/verify-passed-tree-$2" ] && [ -f "$(marker "$2").provenance" ]; then + ok "$1" + else + fail "$1: no marker and provenance for tree ${2:0:8}" "$(cat "$scratch/out")" + fi +} +expect_nothing() { # + local name=$1 want=$2 t + shift 2 + for t in "$@"; do + if [ -e "$(marker "$t")" ] || [ -e "$(marker "$t").provenance" ] || [ -e "$repo/tmp/verify-passed-tree-$t" ]; then + fail "$name: wrote a marker for tree ${t:0:8}" + return + fi + done + if ! grep -qF -- "$want" "$scratch/out"; then + fail "$name: output lacks '$want'" "$(cat "$scratch/out")" + else + ok "$name" + fi +} +expect_eq() { # + if [ "$2" = "$3" ]; then ok "$1"; else fail "$1" "got: $2"$'\n'"want: $3"; fi +} + +# ── A passing run over ssh ── +reset +T=$(tree) +C=$(git -C "$repo" rev-parse HEAD) +if run devbox; then + expect_marker "a passing run writes the markers and provenance" "$T" +else + fail "a passing run exits 0" "$(cat "$scratch/out")" +fi +expect_eq "…over ssh, in a login shell" "$(cat "$scratch/transport.log")" "ssh -T devbox bash -l -s" +expect_eq "…running make tools, then make ci, in a checkout of HEAD's tree" "$(cat "$scratch/make.log")" "make tools in $T"$'\n'"make ci in $T" +expect_eq "…recording the host" "$(prov host "$T")" devbox +expect_eq "…its OS and architecture as the host reports them" "$(prov os "$T")/$(prov arch "$T")" "Linux/x86_64" +expect_eq "…the commit and tree" "$(prov commit "$T") $(prov tree "$T")" "$C $T" +iso='^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' +if [[ $(prov started "$T") =~ $iso ]] && [[ $(prov finished "$T") =~ $iso ]]; then + ok "…the start and end times" +else + fail "…the start and end times" "$(cat "$(marker "$T").provenance")" +fi +log=$repo/$(prov log "$T") +if [[ $(head -n 1 "$log") == "ci-remote["*"] uname Linux x86_64" ]] && grep -qx "stub make ci output" "$log"; then + ok "…and a local copy of the log, which opens with the host's uname" +else + fail "…and a local copy of the log, which opens with the host's uname" "$(cat "$log")" +fi +expect_eq "…and removes the run directory from the host" "$(leftover_runs)" "" + +# ── KEEP and REMOTE_DIR ── +reset +if run devbox KEEP=1 REMOTE_DIR="$scratch/runs"; then + dir=$(prov remote_dir "$T") + if [ "$(prov kept "$T")" = 1 ] && [ "${dir#"$scratch/runs/run."}" != "$dir" ] && [ -d "$dir/src" ]; then + ok "KEEP=1 leaves the run directory under REMOTE_DIR" + else + fail "KEEP=1 leaves the run directory under REMOTE_DIR" "$(cat "$(marker "$T").provenance")" + fi +else + fail "KEEP=1 run exits 0" "$(cat "$scratch/out")" +fi + +# ── Failures write nothing ── +reset +echo 2 > "$scratch/make-exit" +if run devbox; then fail "a failing make ci exits non-zero"; fi +expect_nothing "a failing make ci writes nothing" "make ci failed on devbox (exit 2)" "$T" +expect_eq "…and still cleans up the host" "$(leftover_runs)" "" + +reset +cat > "$scratch/during-ci" << EOF +echo change > "$repo/main.go" +git -C "$repo" commit -qam 'commit during the run' +EOF +if run devbox; then fail "HEAD moving during the run exits non-zero"; fi +expect_nothing "a tree mismatch (HEAD moved during the run) writes nothing" "HEAD moved to tree" "$T" "$(tree)" +git -C "$repo" reset -q --hard "$C" + +reset +git -C "$repo" rev-parse HEAD~1 > "$scratch/wrong-commit" +if run devbox; then fail "a host that tested another tree exits non-zero"; fi +expect_nothing "a tree mismatch (the host tested another tree) writes nothing" "reported testing tree" "$T" + +reset +git -C "$repo" rev-parse HEAD~1 > "$scratch/wrong-commit" +echo "echo 'ci-remote[000000000000] tree $T'" > "$scratch/during-ci" +if run devbox; then fail "a report line forged by make's output exits non-zero"; fi +expect_nothing "a report line forged by make's output doesn't count" "reported testing tree" "$T" + +reset +echo "touch '$repo/new-file'" > "$scratch/during-ci" +if run devbox; then fail "a worktree dirtied during the run exits non-zero"; fi +expect_nothing "a worktree dirtied during the run writes nothing" "the worktree changed during the run" "$T" +rm -f "$repo/new-file" + +reset +echo "touch generated-file" > "$scratch/during-ci" # cwd: the host's checkout +if run devbox; then fail "make ci dirtying the host checkout exits non-zero"; fi +expect_nothing "make ci leaving the host checkout dirty writes nothing" "make ci changed the checkout" "$T" + +# ── Interrupted: the host stops the run and cleans up ── +# The host's make ci records its PID, then waits. +hold_ci() { echo "echo \$\$ > '$scratch/ci.pid'; exec sleep 60" > "$scratch/during-ci"; } +wait_for_ci() { for _ in $(seq 100); do [ -s "$scratch/ci.pid" ] && break; sleep 0.1; done; } +expect_host_stopped() { # + local pid + pid=$(cat "$scratch/ci.pid" 2> /dev/null) + for _ in $(seq 100); do + [ -n "$pid" ] && [ -z "$(leftover_runs)" ] && ! kill -0 "$pid" 2> /dev/null && break + sleep 0.1 + done + if [ -n "$pid" ] && [ -z "$(leftover_runs)" ] && ! kill -0 "$pid" 2> /dev/null; then + ok "$1" + else + fail "$1" "${pid:-make ci never started}; left: $(leftover_runs)" + [ -z "$pid" ] || kill "$pid" 2> /dev/null + fi + rm -f "$scratch/ci.pid" +} + +reset +hold_ci +set -m +run devbox & +local_side=$! +set +m +wait_for_ci +kill -TERM -- "-$local_side" 2> /dev/null # what Ctrl-C does: the whole local process group +wait "$local_side" 2> /dev/null +expect_host_stopped "stopping the local process group stops make ci on the host and removes its run directory" +expect_nothing "…and writes nothing" "make ci on devbox" "$T" + +reset +hold_ci +start devbox +script=$! +wait_for_ci +kill -TERM "$script" # the script alone: its pipeline is left running +wait "$script" 2> /dev/null +expect_host_stopped "signaling only the script stops make ci on the host and removes its run directory" +expect_nothing "…and writes nothing" "make ci on devbox" "$T" + +# ── Refusals: nothing reaches the host ── +reset +echo change > "$repo/main.go" +if run devbox; then fail "a modified file is refused"; fi +expect_nothing "a modified tracked file is refused" "uncommitted or untracked changes" "$T" +git -C "$repo" checkout -q -- main.go +touch "$repo/untracked" +if run devbox; then fail "an untracked file is refused"; fi +expect_nothing "an untracked file is refused" "uncommitted or untracked changes" "$T" +rm -f "$repo/untracked" +for bad in "" "docker:" "-oProxyCommand=sh" "docker:-it" "dev box"; do + if run "$bad"; then fail "HOST '$bad' is refused"; fi +done +expect_eq "…and so are unusable HOST values, none of them contacting the host" "$(cat "$scratch/transport.log")" "" + +# ── A container ── +reset +if run docker:wh-dev; then + expect_marker "docker: writes the markers" "$T" +else + fail "docker: run exits 0" "$(cat "$scratch/out")" +fi +expect_eq "…through docker exec, in the container's own environment" "$(cat "$scratch/transport.log")" "docker exec -i wh-dev bash -s" +expect_eq "…recording the container as the host" "$(prov host "$T")" "docker:wh-dev" + +# ── Two runs of one tree keep their own logs ── +reset +run devbox +log_a=$(prov log "$T") +run docker:wh-dev +log_b=$(prov log "$T") +if [ "$log_a" != "$log_b" ] && [ -f "$repo/$log_a" ] && [ -f "$repo/$log_b" ] \ + && [ "$(head -n 1 "$repo/$log_a")" != "$(head -n 1 "$repo/$log_b")" ]; then + ok "two runs of the same tree write separate logs, and the provenance names this run's" +else + fail "two runs of the same tree write separate logs, and the provenance names this run's" "$log_a"$'\n'"$log_b" +fi + +# ── The pre-push hook names where make ci passed ── +zero=0000000000000000000000000000000000000000 +push() { (cd "$repo" && echo "refs/heads/main $C refs/heads/main $zero" | .githooks/pre-push origin url) > "$scratch/out" 2>&1; } +if push && grep -qF "make ci passed on docker:wh-dev (Linux/x86_64)" "$scratch/out"; then + ok "pre-push accepts the marker and names the host" +else + fail "pre-push accepts the marker and names the host" "$(cat "$scratch/out")" +fi +rm -f "$(marker "$T").provenance" +if push && ! grep -qF "passed on" "$scratch/out"; then + ok "…and says nothing extra for a marker without provenance" +else + fail "…and says nothing extra for a marker without provenance" "$(cat "$scratch/out")" +fi +rm -f "$(marker "$T")" +if push; then fail "pre-push blocks a code push with no marker"; else ok "pre-push blocks a code push with no marker"; fi + +if [ "$fails" -gt 0 ]; then + printf '\n%d case(s) failed\n' "$fails" >&2 + exit 1 +fi +echo "remote-ci: all cases passed" From 89d368225597dd9ef107412bec5745f54d98d505 Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Fri, 9 Oct 2026 11:32:19 -0400 Subject: [PATCH 02/13] test(ci): give the remote-ci test's scratch repository a Makefile The pre-push hook is about to pass a pushed commit that has no Makefile, since it holds no code to validate (#678). This test's scratch repository had none, so its pre-push cases would stop testing the marker check: the push would pass as code-less, and "pre-push blocks a code push with no marker" and "pre-push accepts the marker and names the host" would fail. A code repository has a Makefile, so the scratch one now does too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH --- scripts/ci/remote-ci.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ci/remote-ci.test.sh b/scripts/ci/remote-ci.test.sh index 2ed496f4..aed0f98f 100755 --- a/scripts/ci/remote-ci.test.sh +++ b/scripts/ci/remote-ci.test.sh @@ -42,6 +42,8 @@ cp scripts/ci-marker.sh scripts/classify-paths.sh "$repo/scripts/" cp scripts/ci/remote-ci.sh "$repo/scripts/ci/" cp .githooks/pre-push "$repo/.githooks/" echo 'tmp/' > "$repo/.gitignore" +# A code repository has a Makefile; the pre-push hook passes a commit without one. +printf 'ci:\n' > "$repo/Makefile" git -C "$repo" add -A git -C "$repo" commit -q -m base git -C "$repo" update-ref refs/remotes/origin/main HEAD From 413a4c8db5394788c7d8ccbe2a5362967ec9dafa Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Fri, 9 Oct 2026 10:38:01 -0400 Subject: [PATCH 03/13] fix(hooks): let a worktree with no Makefile commit and push With an absolute core.hooksPath, git runs one checkout's .githooks/ in every worktree, including one on an orphan branch that keeps a tool's state and no code. There pre-commit could not find scripts/ci-marker.sh, hid the error behind 2>/dev/null, and ran `make verify` with no Makefile ("No rule to make target 'verify'"), so nothing could be committed (#678). - pre-commit: a worktree with no Makefile has nothing to verify and exits 0. A missing scripts/ci-marker.sh, or one that fails other than "no marker" (exit 1), is reported and `make verify` runs. - pre-push: a pushed commit with no Makefile holds no code to validate and goes through. One with a Makefile, pushed from a worktree without scripts/ci-marker.sh, is blocked with a message that says so, instead of "can't resolve tree". The decision follows what is pushed, not the worktree it is pushed from, so a code branch can't slip through a code-less worktree. - scripts/githooks.test.sh (make test-githooks, a verify leaf) commits and pushes through the real hooks from scratch worktrees, with core.hooksPath pointing at them by absolute path. - make lint-sh now shellchecks the extensionless .githooks/ files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH --- .githooks/pre-commit | 29 ++++- .githooks/pre-push | 16 ++- AGENTS.md | 6 +- CHANGELOG.md | 1 + Makefile | 26 +++-- docs/src/content/docs/claude-code.md | 6 +- docs/src/content/docs/development.md | 2 +- scripts/githooks.test.sh | 163 +++++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 19 deletions(-) create mode 100755 scripts/githooks.test.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 71736be3..07d55cfe 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,13 +1,38 @@ #!/usr/bin/env bash # Runs `make verify` (or skips if `scripts/ci-marker.sh` says it already passed # for this tree). Bypass: `git commit --no-verify`. +# +# An absolute core.hooksPath runs this hook in every worktree of the +# repository, including ones whose branch holds no code (an orphan branch that +# keeps a tool's state, say). A worktree with no Makefile has nothing to verify. set -uo pipefail cd "$(git rev-parse --show-toplevel)" || exit 1 -if scripts/ci-marker.sh has-verify-marker 2>/dev/null; then - echo "✔ pre-commit: verify already passed for this tree (cached)" >&2 +if [ ! -f Makefile ]; then + echo "pre-commit: no Makefile in $PWD, so nothing to verify." >&2 exit 0 fi +# has-verify-marker exits 0 when the marker is there and 1 when it isn't; any +# other failure, or a missing script, is reported and falls through to the full +# `make verify`. +marker_script=scripts/ci-marker.sh +if [ ! -x "$marker_script" ]; then + echo "⚠️ pre-commit: $marker_script is missing or not executable, so the cached verify result can't be checked; running 'make verify'." >&2 +else + err=$("$marker_script" has-verify-marker 2>&1 >/dev/null) + case $? in + 0) + echo "✔ pre-commit: verify already passed for this tree (cached)" >&2 + exit 0 + ;; + 1) ;; + *) + echo "⚠️ pre-commit: $marker_script has-verify-marker failed, so the cached verify result can't be checked; running 'make verify'." >&2 + [ -n "$err" ] && printf '%s\n' "$err" | sed 's/^/ /' >&2 + ;; + esac +fi + make verify || { echo "🛑 pre-commit: 'make verify' failed." >&2; exit 1; } diff --git a/.githooks/pre-push b/.githooks/pre-push index f762d95c..85eea4a7 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -12,12 +12,16 @@ # `make verify`, so this is usually a zero-extra-work push. # Fail-closed: if the change set can't be classified (no resolvable base, # classifier error), fall back to requiring the full `make ci` marker. +# A pushed commit with no Makefile holds no code to validate (an orphan branch +# that keeps a tool's state, say), so it passes; one with a Makefile, pushed +# from a worktree that lacks scripts/ci-marker.sh, is blocked. # Bypass: `git push --no-verify` (humans only). set -uo pipefail cd "$(git rev-parse --show-toplevel)" || exit 1 zero=0000000000000000000000000000000000000000 +marker_script=scripts/ci-marker.sh # Net changed files for a pushed ref (base..tip), one per line. New branch # (no remote tip) → diff against the merge-base with origin/main. Returns @@ -34,7 +38,7 @@ changed_files() { block_ci() { # — require the full make-ci marker local sha="$1" marker tree - marker=$(scripts/ci-marker.sh path-for-commit "$sha" 2>/dev/null) || { + marker=$("$marker_script" path-for-commit "$sha" 2>/dev/null) || { echo "🛑 pre-push: can't resolve tree for ${sha:0:8}." >&2; exit 1 } if [ ! -f "$marker" ]; then @@ -52,6 +56,14 @@ block_ci() { # — require the full make-ci marker while read -r _ local_sha _ remote_sha; do [ -z "${local_sha:-}" ] && continue [ "$local_sha" = "$zero" ] && continue # branch deletion — nothing to validate + if ! git rev-parse --verify --quiet "$local_sha:Makefile" >/dev/null; then + echo "pre-push: ${local_sha:0:8} has no Makefile, so no code to validate." >&2 + continue + fi + if [ ! -x "$marker_script" ]; then + echo "🛑 pre-push: $marker_script is missing or not executable in $PWD, so the validation marker for ${local_sha:0:8} can't be checked. Push from a worktree that has it." >&2 + exit 1 + fi # Classify the push, failing closed to a code change on any hiccup. code=true @@ -63,7 +75,7 @@ while read -r _ local_sha _ remote_sha; do fi if [ "$code" = false ]; then - marker=$(scripts/ci-marker.sh verify-path-for-commit "$local_sha" 2>/dev/null) || { + marker=$("$marker_script" verify-path-for-commit "$local_sha" 2>/dev/null) || { echo "🛑 pre-push: can't resolve tree for ${local_sha:0:8}." >&2; exit 1 } if [ ! -f "$marker" ]; then diff --git a/AGENTS.md b/AGENTS.md index 8ec029cf..2864c3ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,10 +173,10 @@ On success `make ci` writes the tree-keyed `tmp/ci-passed-tree-` marker (s ### Enforced via git hooks -`make tools` installs team-wide git hooks via `git config core.hooksPath .githooks`. They apply to humans and Claude Code alike: +`make tools` installs team-wide git hooks via `git config core.hooksPath .githooks`. They apply to humans and Claude Code alike. Git resolves that relative path in each worktree, so each worktree runs its own branch's hooks, and a worktree whose branch has no `.githooks/` runs none; an absolute `core.hooksPath` runs one checkout's hooks in every worktree instead. The hooks behave correctly under either: -- **`.githooks/pre-commit`** runs `make verify` (~30s) on every commit; blocks on failure. Skipped if `make ci` or `make verify` already ran for the current tree state (cached marker). See §Documentation Sync / §SDK Sync for what to update by hand. -- **`.githooks/pre-push`** scales the bar to what the push changes, classified by the same allowlist CI uses (`scripts/classify-paths.sh`): a code change requires the full `make ci` marker (`tmp/ci-passed-tree-`), while a docs/prose-only push requires only the `make verify` marker (`tmp/verify-passed-tree-`) — CI skips the Go/SDK suites for those too, so there's no reason to run them locally (and pre-commit's `make verify` usually already wrote it). Fail-closed: an unclassifiable push (no resolvable base, classifier error) falls back to requiring `make ci`. Both markers are tree-keyed (not commit-keyed) so `make {ci,verify} → commit → push` works without a re-run when the tree is unchanged; editing the tree (or staging a different subset than CI saw) requires a re-run. The marker write is skipped entirely when `$CI` is set (CI runners don't push). +- **`.githooks/pre-commit`** runs `make verify` (~30s) on every commit; blocks on failure. Skipped if `make ci` or `make verify` already ran for the current tree state (cached marker). A worktree with no `Makefile` (an orphan branch that keeps a tool's state, say) has nothing to verify and commits. A missing or failing `scripts/ci-marker.sh` is reported, not hidden, and `make verify` runs. See §Documentation Sync / §SDK Sync for what to update by hand. +- **`.githooks/pre-push`** scales the bar to what the push changes, classified by the same allowlist CI uses (`scripts/classify-paths.sh`): a code change requires the full `make ci` marker (`tmp/ci-passed-tree-`), while a docs/prose-only push requires only the `make verify` marker (`tmp/verify-passed-tree-`) — CI skips the Go/SDK suites for those too, so there's no reason to run them locally (and pre-commit's `make verify` usually already wrote it). Fail-closed: an unclassifiable push (no resolvable base, classifier error) falls back to requiring `make ci`. A pushed commit with no `Makefile` has no code to validate and goes through; one with a `Makefile`, pushed from a worktree that lacks `scripts/ci-marker.sh`, is blocked. Both markers are tree-keyed (not commit-keyed) so `make {ci,verify} → commit → push` works without a re-run when the tree is unchanged; editing the tree (or staging a different subset than CI saw) requires a re-run. The marker write is skipped entirely when `$CI` is set (CI runners don't push). Bypass with `git commit --no-verify` / `git push --no-verify` only when explicitly intentional (WIP / draft pushes where you accept the consequences). Don't disable the hooks globally; that defeats the gate. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242e0f2..1471b7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **A worktree whose branch has no Makefile can commit, and the git hooks say when `scripts/ci-marker.sh` is missing** (`.githooks/{pre-commit,pre-push}`, `scripts/githooks.test.sh` (new), `Makefile`, `AGENTS.md`, `docs/src/content/docs/{claude-code,development}.md`), closing [#678](https://github.com/Wave-RF/WaveHouse/issues/678): with an absolute `core.hooksPath`, git runs one checkout's `.githooks/` in every worktree of the repository, including one on an orphan branch that keeps a tool's state and no code. There `pre-commit` couldn't find `scripts/ci-marker.sh`, hid the error behind `2>/dev/null`, and ran `make verify` with no Makefile, which failed with `No rule to make target 'verify'`, so nothing could be committed. A worktree with no Makefile now has nothing to verify and commits; a missing or failing `scripts/ci-marker.sh` is reported instead of hidden, and `make verify` runs as before. `pre-push` blocked the same branches with a misleading "can't resolve tree"; it now lets through a pushed commit that has no Makefile, which holds no code to validate, and blocks one that has a Makefile but is pushed from a worktree without `scripts/ci-marker.sh`, saying so. The relative `.githooks` that `make tools` sets is resolved in each worktree, so there a worktree whose branch has no `.githooks/` runs no hooks at all; the hooks now behave correctly under either setting. `make verify` gains `test-githooks`, which commits and pushes through the real hooks from scratch worktrees with and without code, and `make lint-sh` now shellchecks `.githooks/`. - **`scripts/skip-pre-push-review.sh` no longer refuses, now and then, to skip the first reviewer the manifest lists** (`scripts/skip-pre-push-review.sh`, `.claude/hooks/review-gate.test.sh`), closing [#767](https://github.com/Wave-RF/WaveHouse/issues/767): it checked the name with `bash scripts/pre-push-reviewers.sh | grep -Fxq` under `pipefail`. Bash writes each name in its own `write()`, so `grep -q` could match `pre-push-reviewer` on the first line and exit before the second write, which then died of `SIGPIPE`; `pipefail` reported that as no match, and the script refused with "'pre-push-reviewer' is not a reviewer". Whether it happened was up to the scheduler, so `make test-review-gate` failed intermittently in CI, more often on a busy runner, and its log showed only that no marker was written, because the test discarded the script's stderr. The script now reads the whole list before matching, and does the same for its advisory `git diff --name-only | grep -qv` check; that check also resolves its base the way the push gate does (`origin/main`, else `main`), which its comment already claimed. The test now prints the script's stderr when a skip fails, and adds a case whose manifest pauses after each name, which the old script failed every time. - **The multi-tenant docs say how a tenant folder is added by hand, where `X-Tenant-ID` is resolved, and when a stream can see the tenant `404`** (`docs/src/content/docs/{deployment,api}.md`, `docs/src/content/docs/sdk/{reference,streaming,admin}.md`, `docs/src/content/docs/settings-directory.mdx`): story 11 of [#583](https://github.com/Wave-RF/WaveHouse/issues/583). `wavehouse bootstrap` seeds one folder of a nested directory when given the folder's path, and the new folder is adopted by a whole-directory reload, not a reload by name; the API page's Authentication section now says the tenant is resolved from the header before the token is read. The SDK pages said a stream sees the `400`/`404` of tenant resolution only "when you send `X-Tenant-ID`"; a stream that sends none is tenant `0`'s, which over a nested directory with no `0` folder is the `404` ([#641](https://github.com/Wave-RF/WaveHouse/issues/641)). The DLQ example on the SDK admin page declared `data` twice in one block and showed a `0` count the server never returns, the `?table=` row said the filter narrows `total`, and the dead-letter section called the unreadable-envelope exception "new in this release" ([#676](https://github.com/Wave-RF/WaveHouse/issues/676)). - **`make ci` no longer kills healthy test runs at the `go test` time limits** (`Makefile`, `docs/src/content/docs/development.md`): its parallel phase runs the unit suite beside the lint and build jobs on every core, where `internal/app` and `internal/mq`, about 10s each alone, run past the suite's 15s per-package budget, so that phase now gives the suite 60s (`CI_UNIT_TIMEOUT`). `make test-unit` and the unit job in CI, which run the suite on its own, keep the 15s (`UNIT_TIMEOUT`). The integration suite's limit goes from 480s to 900s (`INTEGRATION_TIMEOUT`): `tests/integration` takes about 6m on a CI runner and up to 8m under Docker Desktop, which 480s left half a minute. Each limit is a Makefile variable, so one can be raised for a run (`make ci CI_UNIT_TIMEOUT=90s`). diff --git a/Makefile b/Makefile index 7e23950f..346821b6 100644 --- a/Makefile +++ b/Makefile @@ -429,10 +429,11 @@ lint-prose: $(MISSPELL) $(call run,misspell (US spelling),$(MISSPELL) -locale US -source text -error $(DOCS_PROSE),run make fix to auto-correct) # lint-sh: shellcheck over every tracked shell script (scripts/, hooks, -# docs tooling). -x follows `source`d files; -P SCRIPTDIR resolves -# `# shellcheck source=` directives relative to the sourcing script, not -# the cwd. Lazily expanded so the ls-files only runs when the target does. -SHELL_SOURCES = $(shell git ls-files '*.sh') +# docs tooling), the extensionless git hooks in .githooks/ included. -x follows +# `source`d files; -P SCRIPTDIR resolves `# shellcheck source=` directives +# relative to the sourcing script, not the cwd. Lazily expanded so the +# ls-files only runs when the target does. +SHELL_SOURCES = $(shell git ls-files '*.sh' '.githooks/*') .PHONY: lint-sh lint-sh: $(SHELLCHECK) $(call run,shellcheck,$(SHELLCHECK) -x -P SCRIPTDIR $(SHELL_SOURCES),) @@ -521,6 +522,14 @@ test-review-gate: test-remote-ci: $(call run,remote-ci test,scripts/ci/remote-ci.test.sh,) +# test-githooks: commit and push through the .githooks/ hooks from scratch +# worktrees with and without code: a worktree whose branch has no Makefile must +# commit, and code must still need its markers. A verify leaf, same as +# test-review-gate. +.PHONY: test-githooks +test-githooks: + $(call run,githooks test,scripts/githooks.test.sh,) + .PHONY: vulncheck vulncheck: go-mod-download ## Run govulncheck -scan package (V=1: symbol-level scan with example traces) ifdef V @@ -611,14 +620,13 @@ fix-prose: $(MISSPELL) # slowest tool, not the slowest *group* (e.g. golangci no longer drags Biome + # markdownlint along behind it). # -# Leaves (21): tidy, fmt-go (gofumpt), lint-go (golangci), vulncheck on the Go +# Leaves (22): tidy, fmt-go (gofumpt), lint-go (golangci), vulncheck on the Go # side; lint-ts (biome check) + lint-md (markdownlint) + lint-prose (misspell, # docs spelling) + test-md-rules (node --test over the WH001/WH002 fixtures) # for JS/TS + Markdown + prose; lint-sh (shellcheck), lint-gha (actionlint), # test-classify-paths, test-release-channel, test-go-toolchain, check-dockerfile-go, test-tagged-tests, -# test-classify-paths, test-release-channel, test-go-toolchain, check-dockerfile-go, test-tagged-tests, -# test-integration-parts, test-prune-pr-build-cache, test-review-gate and -# test-remote-ci for the tooling; +# test-integration-parts, test-prune-pr-build-cache, test-review-gate, +# test-remote-ci and test-githooks for the tooling; # check-docs (astro check — the only leaf that writes, to docs/.astro/, and # nothing else touches it) and typecheck-ts (tsc --noEmit). It runs lint-ts # (`biome check`) but NOT fmt-ts (`biome format`) — check already covers @@ -632,7 +640,7 @@ verify: ## Run all static checks across the repo (Go + TS + docs, parallelized) @printf "$(GREEN)$(BOLD)✔ All static checks passed$(RESET)\n" .PHONY: verify-parallel -verify-parallel: tidy fmt-go lint-go lint-ts lint-md lint-prose lint-sh lint-gha test-classify-paths test-md-rules test-release-channel test-go-toolchain check-dockerfile-go test-tagged-tests test-integration-parts test-prune-pr-build-cache test-review-gate test-remote-ci vulncheck check-docs typecheck-ts +verify-parallel: tidy fmt-go lint-go lint-ts lint-md lint-prose lint-sh lint-gha test-classify-paths test-md-rules test-release-channel test-go-toolchain check-dockerfile-go test-tagged-tests test-integration-parts test-prune-pr-build-cache test-review-gate test-remote-ci test-githooks vulncheck check-docs typecheck-ts # typecheck-ts: tsc --noEmit on the SDK. Its own target (was inline in verify's # recipe) so it can run as a parallel leaf of verify-parallel. diff --git a/docs/src/content/docs/claude-code.md b/docs/src/content/docs/claude-code.md index 555176c0..d17d35bc 100644 --- a/docs/src/content/docs/claude-code.md +++ b/docs/src/content/docs/claude-code.md @@ -36,13 +36,15 @@ Two scripts, both committed to the repo: | Hook | Behavior | | ---- | -------- | -| `pre-commit` | Runs `make verify` (the static checks listed in [Development → Makefile Targets](/development#makefile-targets), ~30s) — **blocks on failure**. Skipped if `make ci` or `make verify` already ran for the current tree state (cached via `scripts/ci-marker.sh`). | -| `pre-push` | Scales the bar to the change set (same classifier CI uses, `scripts/classify-paths.sh`): a **code** change requires the `make ci` marker (`tmp/ci-passed-tree-`, from `make ci` or `make ci-remote`); a **docs/prose-only** push requires only the `make verify` marker (`tmp/verify-passed-tree-`) — CI skips the Go/SDK suites for those too. **Blocks** if the required marker is absent. Fail-closed: an unclassifiable push falls back to requiring `make ci`. | +| `pre-commit` | Runs `make verify` (the static checks listed in [Development → Makefile Targets](/development#makefile-targets), ~30s) — **blocks on failure**. Skipped if `make ci` or `make verify` already ran for the current tree state (cached via `scripts/ci-marker.sh`). A worktree with no `Makefile` has nothing to verify and commits; a missing or failing `scripts/ci-marker.sh` is reported and `make verify` runs. | +| `pre-push` | Scales the bar to the change set (same classifier CI uses, `scripts/classify-paths.sh`): a **code** change requires the `make ci` marker (`tmp/ci-passed-tree-`, from `make ci` or `make ci-remote`); a **docs/prose-only** push requires only the `make verify` marker (`tmp/verify-passed-tree-`) — CI skips the Go/SDK suites for those too. **Blocks** if the required marker is absent. Fail-closed: an unclassifiable push falls back to requiring `make ci`. A pushed commit with no `Makefile` goes through, having no code to validate; one with a `Makefile`, pushed from a worktree without `scripts/ci-marker.sh`, is blocked. | `--no-verify` is for intentional WIP / draft pushes. Agents should not use it — policy in AGENTS.md §"Agent PR Discipline", not regex-enforced. Both markers are tree-keyed so commit-then-push works without a re-run when the tree is unchanged. `make ci` / `make verify` skip the marker write when `$CI` is set (CI runners don't push). Shared logic lives in `scripts/ci-marker.sh`. +`make tools` sets `core.hooksPath` to the relative `.githooks`, which git resolves in each worktree: each worktree runs its own branch's hooks, and one whose branch has no `.githooks/` runs none. An absolute `core.hooksPath` runs one checkout's hooks in every worktree, which is why the hooks handle a worktree with no code. `make test-githooks` (part of `make verify`) commits and pushes through the real hooks from scratch worktrees with and without code. + ## What's in `.claude/` and `.config/` | Path | Purpose | diff --git a/docs/src/content/docs/development.md b/docs/src/content/docs/development.md index 888a5364..77ca79c1 100644 --- a/docs/src/content/docs/development.md +++ b/docs/src/content/docs/development.md @@ -549,7 +549,7 @@ Run `make help` to see all targets. Key ones: | `make tidy` | Verify `go.mod`/`go.sum` are tidy (run `make fix` to apply) | | `make lint` | Run linters across Go (`golangci-lint`) + TS (Biome) + Markdown/MDX (markdownlint) + prose (misspell) | | `make vulncheck` | Run `govulncheck -scan package` (`V=1`: the default symbol-level scan, with example traces) | -| `make verify` | Repo-wide static checks: Go (tidy + fmt + vulncheck + lint) + TS (Biome + `tsc` typecheck) + Markdown/MDX (markdownlint + rule fixtures) + prose (misspell) + shell (shellcheck) + workflows (actionlint) + path-classifier fixtures + release-channel fixtures + go-toolchain fixtures + Dockerfile Go-version check + tagged-test selector and integration-parts fixtures + PR build-cache prune fixtures + review-gate hook tests + `make ci-remote` tests + docs type-check (`astro check` — not a full build, so link validation is left to `make build-docs`, which `make ci` runs); a bare `make verify` runs these in parallel | +| `make verify` | Repo-wide static checks: Go (tidy + fmt + vulncheck + lint) + TS (Biome + `tsc` typecheck) + Markdown/MDX (markdownlint + rule fixtures) + prose (misspell) + shell (shellcheck) + workflows (actionlint) + path-classifier fixtures + release-channel fixtures + go-toolchain fixtures + Dockerfile Go-version check + tagged-test selector and integration-parts fixtures + PR build-cache prune fixtures + review-gate hook tests + git-hook tests (`.githooks/`) + `make ci-remote` tests + docs type-check (`astro check` — not a full build, so link validation is left to `make build-docs`, which `make ci` runs); a bare `make verify` runs these in parallel | | `make fix` | Auto-fixes across Go (`tidy` + `gofumpt` + `goimports` + `lint --fix`), TS (Biome `--write`), Markdown (markdownlint `--fix`), MDX (`fix-mdx-fences` only — the generic fixers never run over `.mdx`), and docs-prose spelling (misspell, both) | | **Build** | | | `make build` | Compile `wavehouse` → `bin/wavehouse` (debug symbols kept) | diff --git a/scripts/githooks.test.sh b/scripts/githooks.test.sh new file mode 100755 index 00000000..e95a5b23 --- /dev/null +++ b/scripts/githooks.test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Behavioral test for the git hooks in .githooks/. Builds a scratch repository +# whose core.hooksPath names this checkout's .githooks/ by absolute path, the +# setup that runs them in every linked worktree, and commits and pushes from +# worktrees with and without code: a worktree whose branch has no Makefile must +# commit and push its own branch (#678), while code still needs its markers and +# a missing scripts/ci-marker.sh is reported, not swallowed. The repository's +# Makefile is a stub whose `verify` only says it ran. Markers come only from +# scripts/ci-marker.sh, never from the test itself. Run by `make verify` +# (target: test-githooks). Needs git and make; no network. + +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 # repo root (scripts/..) +root=$PWD +fails=0 + +scratch=$(mktemp -d) || exit 1 +trap 'rm -rf "$scratch"' EXIT +scratch=$(cd "$scratch" && pwd -P) + +# Run from a git hook (pre-commit runs make verify), GIT_DIR and friends point +# at this checkout: a `git init` here would re-initialize it instead of creating +# the scratch repository. Clear them, then refuse to write anything unless git +# sees the scratch directory as outside any repository and puts the new +# repository exactly where it was asked to. Every git call below names its +# directory with -C. The outer make's MAKEFLAGS would hand the stub make its +# jobserver, so clear those too. +# shellcheck disable=SC2046 # one variable name per word +unset $(git -C "$scratch" rev-parse --local-env-vars) MAKEFLAGS MAKELEVEL MFLAGS +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +if git -C "$scratch" rev-parse --git-dir >/dev/null 2>&1; then + echo "githooks test: git resolves $scratch to an existing repository; refusing to run." >&2 + exit 1 +fi +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com + +repo=$scratch/repo +git -C "$scratch" init -q -b main "$repo" +if [ "$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null)" != "$repo/.git" ]; then + echo "githooks test: git did not create $repo/.git; refusing to go on." >&2 + exit 1 +fi + +ok() { printf ' ok %s\n' "$1"; } +fail() { + printf ' FAIL %s\n' "$1" >&2 + [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ /' >&2 + fails=$((fails + 1)) +} + +# expect ... -- : run git in +# and check its exit code and combined output (a hook's output reaches git's +# stderr). A pattern is a fixed string the output must contain, or, led by +# `!`, one it must not. +expect() { + local name=$1 want=$2 got out p bad="" + shift 2 + local pats=() + while [ "$1" != -- ]; do pats+=("$1"); shift; done + shift + local dir=$1 + shift + out=$(git -C "$dir" "$@" 2>&1) + got=$? + [ "$got" = "$want" ] || bad="exit $got, want $want" + for p in "${pats[@]}"; do + case $p in + !*) case $out in *"${p#!}"*) bad="${bad:+$bad; }output has '${p#!}'" ;; esac ;; + *) case $out in *"$p"*) ;; *) bad="${bad:+$bad; }output lacks '$p'" ;; esac ;; + esac + done + if [ -z "$bad" ]; then ok "$name"; else fail "$name: $bad" "$out"; fi +} + +# in_wt …: run a command from the top of the scratch worktree , +# or stop if git doesn't see as one: scripts/ci-marker.sh writes its +# markers into whichever worktree it runs in. +in_wt() { + local dir=$1 + shift + if [ "$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)" != "$dir" ]; then + echo "githooks test: $dir is not a scratch worktree; refusing to go on." >&2 + exit 1 + fi + (cd "$dir" && "$@") +} + +# ── Scratch repository ── +# repo/ — main worktree on main: a stub Makefile and the real marker and +# classifier scripts +# code/ — linked worktree on feat +# state/ — linked worktree on the orphan branch `state`: three text files, +# no Makefile, no scripts +mkdir -p "$repo/scripts" +cp scripts/ci-marker.sh scripts/classify-paths.sh "$repo/scripts/" +# shellcheck disable=SC2016 # make's $$, not the shell's +printf 'verify:\n\t@echo "stub verify ran"\n\t@test -z "$$STUB_VERIFY_FAIL"\n' > "$repo/Makefile" +printf 'tmp/\n' > "$repo/.gitignore" +git -C "$repo" add -A +git -C "$repo" commit -q -m base +code=$scratch/code +state=$scratch/state +git -C "$repo" worktree add -q -b feat "$code" main +git -C "$repo" worktree add -q --detach "$state" main +git -C "$state" checkout -q --orphan state +in_wt "$state" git rm -rqf . +printf '{}\n' > "$state/state.json" +git -C "$state" add state.json +git -C "$state" commit -q -m 'state: first run' +# The remote has main alone, so every branch pushed below is new to it. +git -C "$scratch" init -q --bare remote.git +git -C "$scratch/remote.git" fetch -q "$repo" main:main +git -C "$repo" remote add origin "$scratch/remote.git" +git -C "$repo" fetch -q origin + +# Hooks from here on: this checkout's, by absolute path, in every worktree. +git -C "$repo" config core.hooksPath "$root/.githooks" + +# ── pre-commit ── +printf 'run 2\n' > "$state/runs.log" +git -C "$state" add runs.log +expect "pre-commit: a worktree with no Makefile commits" 0 "no Makefile" "!make verify" -- "$state" commit -q -m 'state: run 2' + +printf 'package x\n' > "$code/x.go" +git -C "$code" add x.go +expect "pre-commit: code with no verify marker runs make verify" 0 "stub verify ran" "!missing" "!cached" -- "$code" commit -q -m x + +printf 'package y\n' > "$code/y.go" +git -C "$code" add y.go +in_wt "$code" env -u CI scripts/ci-marker.sh write-verify +expect "pre-commit: a verify marker for the tree skips make verify" 0 "(cached)" "!stub verify ran" -- "$code" commit -q -m y + +printf 'package z\n' > "$code/z.go" +git -C "$code" add z.go +export STUB_VERIFY_FAIL=1 +expect "pre-commit: a failing make verify blocks the commit" 1 "stub verify ran" "'make verify' failed" -- "$code" commit -q -m z +unset STUB_VERIFY_FAIL +git -C "$code" commit -q -m z >/dev/null 2>&1 || fail "setup: commit z" + +printf '#!/usr/bin/env bash\necho "marker store unreadable" >&2\nexit 3\n' > "$code/scripts/ci-marker.sh" +printf 'package w\n' > "$code/w.go" +git -C "$code" add -A +expect "pre-commit: a failing marker script is reported, then make verify runs" 0 "has-verify-marker failed" "marker store unreadable" "stub verify ran" -- "$code" commit -q -m w +git -C "$code" rm -q scripts/ci-marker.sh +expect "pre-commit: a missing marker script is reported, then make verify runs" 0 "scripts/ci-marker.sh is missing" "stub verify ran" -- "$code" commit -q -m 'drop the marker script' + +# ── pre-push ── +expect "pre-push: a branch with no Makefile pushes" 0 "has no Makefile" -- "$state" push -q origin state +expect "pre-push: code pushed from a worktree without the marker script is blocked" 1 "scripts/ci-marker.sh is missing" -- "$state" push -q origin main:fresh +expect "pre-push: code pushed from a worktree whose marker script is gone is blocked" 1 "scripts/ci-marker.sh is missing" -- "$code" push -q origin feat +git -C "$repo" switch -q -c feat2 main +printf 'package v\n' > "$repo/v.go" +git -C "$repo" add v.go +git -C "$repo" commit -q -m v >/dev/null 2>&1 || fail "setup: commit v" +expect "pre-push: code with no make ci marker is blocked" 1 "'make ci' has not been run" -- "$repo" push -q origin feat2 +in_wt "$repo" env -u CI scripts/ci-marker.sh write +expect "pre-push: code with a make ci marker for its tree pushes" 0 "!pre-push:" -- "$repo" push -q origin feat2 + +if [ "$fails" -gt 0 ]; then + printf '\n%d case(s) failed\n' "$fails" >&2 + exit 1 +fi +echo "githooks: all cases passed" From 63229ed6f6ebb9af003fa5cca4c920bb49736c3f Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Fri, 9 Oct 2026 10:38:57 -0400 Subject: [PATCH 04/13] fix(agents): fail the Claude Code hooks closed when scripts are missing Each hook in .claude/settings.json ran $CLAUDE_PROJECT_DIR/.claude/hooks/