From 3b3e23a30e8e46b9f8b8b05057ebd08c11a20615 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 11 Jun 2026 16:28:46 -0700 Subject: [PATCH] Gate commits behind check.sh + document release pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DX improvements mined from recent sessions: - Add a PreToolUse(Bash) hook that blocks `git commit` unless ./scripts/check.sh passed for the current working tree. check.sh now records a tree signature (scripts/gate_marker.py) into .git/aai-gate-pass on success; the hook (.claude/hooks/require-gate-before-commit.sh) re-verifies it. The signature is staging-invariant but edit-sensitive, so any change after the gate re-requires a green run. Escape hatch for deliberate WIP commits: AAI_ALLOW_COMMIT=1. - Document the tag-triggered release/Homebrew-bottle pipeline in AGENTS.md (release.yml, cut_release.sh, bump_minor.sh, update_check.py) — it was committed in #69/#70 but undocumented. gate_marker.py is stdlib-only (runs from the hook and from check.sh without uv); both new shell paths are added to check.sh's shellcheck list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/require-gate-before-commit.sh | 50 +++++++ .claude/settings.json | 9 ++ AGENTS.md | 3 + scripts/check.sh | 8 +- scripts/gate_marker.py | 136 ++++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100755 .claude/hooks/require-gate-before-commit.sh create mode 100644 scripts/gate_marker.py diff --git a/.claude/hooks/require-gate-before-commit.sh b/.claude/hooks/require-gate-before-commit.sh new file mode 100755 index 00000000..2d7d9c4a --- /dev/null +++ b/.claude/hooks/require-gate-before-commit.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) gate: block `git commit` unless ./scripts/check.sh recorded a +# passing run for the *current* working tree. The full gate (mutation + 100% patch +# coverage tail) is what makes a commit safe here; a single-file `pytest` does not. +# +# Escape hatch for deliberate work-in-progress commits (e.g. before a multi-agent +# workflow, per the commit-wip-before-workflows practice): prefix the command with +# AAI_ALLOW_COMMIT=1 git commit ... +set -euo pipefail + +input="$(cat)" +cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" + +# Only police git commits; let everything else through immediately. +if ! printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+commit([[:space:]]|$)'; then + exit 0 +fi + +# Explicit opt-out for intentional WIP commits. +if printf '%s' "$cmd" | grep -q 'AAI_ALLOW_COMMIT=1'; then + exit 0 +fi + +# Fail open outside this repo or if the marker tool is missing, so the hook never +# wedges commits in an unrelated checkout. +root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$root" ] || [ ! -f "$root/scripts/gate_marker.py" ]; then + exit 0 +fi + +if python3 "$root/scripts/gate_marker.py" check >/dev/null 2>&1; then + exit 0 +fi + +cat >&2 <<'MSG' +Blocked: ./scripts/check.sh has not passed for the current working tree. + +The full gate (incl. the mutation + 100% patch-coverage tail) must be green before +committing — a single-file `pytest` does not satisfy it. Run: + + ./scripts/check.sh + +then commit again once it prints "All checks passed." Editing any file after the +gate passes re-requires it (the marker is keyed to the exact tree contents). + +To commit work-in-progress on purpose, prefix the command: + + AAI_ALLOW_COMMIT=1 git commit ... +MSG +exit 2 diff --git a/.claude/settings.json b/.claude/settings.json index d1186d4c..35d06c1d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -94,6 +94,15 @@ "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *uv.lock|*.env|*/.env) echo \"Blocked: $f is a protected lock/secret file. Edit it by hand if you really intend to.\" >&2; exit 2;; esac; exit 0" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/require-gate-before-commit.sh" + } + ] } ], "PostToolUse": [ diff --git a/AGENTS.md b/AGENTS.md index 5132fa5b..533efd62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,8 @@ Dev tooling is a PEP 735 `[dependency-groups]` group with `default-groups = ["de `scripts/check.sh` is the authoritative gate; keep this list in sync with it. It runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` → `pyright` (src strict) → `pyright` (tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (import-linter architecture contracts) → max-file-length (500 lines) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only, skipped elsewhere) → `markdownlint` → `prettier` (init template JS/CSS) → `shellcheck` → `actionlint` + `zizmor` (workflow lint/audit) → `gitleaks` (secret scan) → generated `--show-code` compile gate → init template contract gate → `pytest` (90% branch coverage) → `diff-cover` (100% patch coverage vs `origin/main`) → **mutation gate** (diff-scoped: mutates each changed line and reruns the tests that cover it — a surviving mutant fails the gate, so changed lines need assertions that would *fail* if the line broke, not just coverage; suppress a genuinely unassertable line with `# pragma: no mutate`) → a "no new escape hatches" diff gate (`# type: ignore` / `# noqa` / `pragma: no cover` / net-new `Any` / `cast(`) → `uv build` + `twine check --strict`. The `vulture`/`deptry`/`lint-imports`/`xenon`, patch-coverage, and mutation stages catch the failures that `ruff`+`mypy` alone won't — don't claim the gate is green until the script prints `All checks passed.` +**Commits are gated.** On success `check.sh` records a working-tree signature (`scripts/gate_marker.py record` → `.git/aai-gate-pass`), and a PreToolUse hook (`.claude/hooks/require-gate-before-commit.sh`) blocks `git commit` unless that signature still matches — so run the full gate to completion *before* committing (a single-file `pytest` does not satisfy it), and re-run it after any further edit. Iterate with the fast targeted commands above, gate once at the end. For a deliberate work-in-progress commit, prefix `AAI_ALLOW_COMMIT=1 git commit …`. + Individual tools (all via `uv run`): ```sh @@ -152,6 +154,7 @@ the return value comes from a recorded payload instead of a hand-built mock. - The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `assembly` (`[project.scripts] assembly = "aai_cli.main:run"`). - `assembly init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`). - `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.12–3.13. +- **Releasing is tag-triggered.** `.github/workflows/release.yml` fires on a pushed `vX.Y.Z` tag and builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR — bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. Two committed helpers drive it and are self-documenting (`--help`): `scripts/bump_minor.sh` rewrites the version in lock-step across `pyproject.toml` + `aai_cli/__init__.py` (run on a branch → merge the PR), then `scripts/cut_release.sh` tags + pushes. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree / version mismatch), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`. ## Architecture diff --git a/scripts/check.sh b/scripts/check.sh index e7d19877..85290e8a 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -126,7 +126,7 @@ if command -v shellcheck >/dev/null 2>&1; then # (paths resolve from the repo root, where this script always runs). shellcheck -x --source-path=. scripts/check.sh scripts/docker_build_check.sh \ scripts/cut_release.sh scripts/bump_minor.sh scripts/gate_tool_pins.sh \ - .claude/hooks/session-start.sh + .claude/hooks/session-start.sh .claude/hooks/require-gate-before-commit.sh else echo " shellcheck not found; skipping (CI runs it)" fi @@ -258,4 +258,10 @@ rm -rf dist uv build uvx twine check --strict dist/* +# Record that this exact working tree passed, so the pre-commit gate hook +# (.claude/hooks/require-gate-before-commit.sh) can let a `git commit` through. +# Any later edit changes the signature and re-requires a green gate. Never fail +# the gate over the marker itself. +python3 scripts/gate_marker.py record || true + echo "All checks passed." diff --git a/scripts/gate_marker.py b/scripts/gate_marker.py new file mode 100644 index 00000000..2d6dfc7f --- /dev/null +++ b/scripts/gate_marker.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Record / verify that ``scripts/check.sh`` passed for the *current* working tree. + +``check.sh`` calls ``record`` on success, writing a signature of the working tree +into the repo's git dir. The pre-commit gate hook +(``.claude/hooks/require-gate-before-commit.sh``) calls ``check``: it recomputes the +signature and blocks ``git commit`` unless it matches — so any edit made *after* the +gate passed invalidates the marker and re-requires a green run. + +stdlib-only on purpose (runs from a hook and from check.sh, before/without ``uv``). +""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path +from typing import Protocol + + +class _Digest(Protocol): + def update(self, data: bytes, /) -> None: ... + + +MARKER_NAME = "aai-gate-pass" +# Hash full content for normal source files; for anything larger (committed audio +# fixtures, build artifacts) fall back to size+mtime so a commit check stays fast. +_CONTENT_HASH_MAX_BYTES = 2 * 1024 * 1024 +# `git status --porcelain=v1 -z` records are "XY" — 3-byte prefix. +_STATUS_PREFIX_LEN = 3 +# argv when invoked as `gate_marker.py `. +_ARGC_WITH_ACTION = 2 + + +def _git(*args: str) -> bytes: + # S603/S607 are ignored project-wide (the CLI shells out to controlled tools); + # args here are all literals, never user input. + return subprocess.run(["git", *args], check=True, capture_output=True).stdout + + +def _marker_path() -> Path: + git_dir = _git("rev-parse", "--git-dir").decode().strip() + return Path(git_dir) / MARKER_NAME + + +def _head() -> bytes: + try: + return _git("rev-parse", "HEAD").strip() + except subprocess.CalledProcessError: + return b"NO_HEAD" + + +def _changed_paths() -> list[str]: + """Paths git reports as changed/untracked, independent of staging state. + + ``--no-renames`` keeps one path per record (rename -> delete + add) so each + NUL-terminated record is ``XY``. The path set is identical whether + a change is staged or not, which makes the signature stable across ``git add``. + """ + out = _git("status", "--porcelain=v1", "--no-renames", "-z") + paths: list[str] = [] + for record in out.split(b"\x00"): + if len(record) <= _STATUS_PREFIX_LEN: + continue + paths.append(record[_STATUS_PREFIX_LEN:].decode("utf-8", "surrogateescape")) + return sorted(paths) + + +def _update_file(path: Path, digest: _Digest) -> None: + """Fingerprint one regular file: full content when small, else size+mtime.""" + try: + stat = path.stat() + except OSError: + digest.update(b"\x00UNREADABLE") + return + if stat.st_size <= _CONTENT_HASH_MAX_BYTES: + digest.update(b"\x00C") + try: + digest.update(path.read_bytes()) + except OSError: + digest.update(b"UNREADABLE") + else: + digest.update(f"\x00S{stat.st_size}:{stat.st_mtime_ns}".encode()) + + +def _update_for_path(rel: str, digest: _Digest) -> None: + digest.update(rel.encode("utf-8", "surrogateescape")) + path = Path(rel) + if not path.exists(): + digest.update(b"\x00DELETED") + elif path.is_dir(): + # git collapses a fully-untracked directory into one `dir/` entry. Such dirs + # are scratch (e.g. tmp/), not what the gate validates, so fingerprint their + # files by name+stat only — cheap, deterministic, and enough to notice drift. + for child in sorted(p for p in path.rglob("*") if p.is_file()): + digest.update(str(child).encode("utf-8", "surrogateescape")) + try: + cst = child.stat() + digest.update(f"\x00S{cst.st_size}:{cst.st_mtime_ns}".encode()) + except OSError: + digest.update(b"\x00UNREADABLE") + else: + _update_file(path, digest) + + +def _signature() -> str: + digest = hashlib.sha256() + digest.update(_head()) + digest.update(b"\x00") + for rel in _changed_paths(): + _update_for_path(rel, digest) + return digest.hexdigest() + + +def _cmd_record() -> int: + _marker_path().write_text(_signature(), encoding="utf-8") + return 0 + + +def _cmd_check() -> int: + marker = _marker_path() + if not marker.exists(): + return 1 + return 0 if marker.read_text(encoding="utf-8").strip() == _signature() else 1 + + +def main(argv: list[str]) -> int: + if len(argv) != _ARGC_WITH_ACTION or argv[1] not in {"record", "check"}: + sys.stderr.write("usage: gate_marker.py {record|check}\n") + return 2 + return _cmd_record() if argv[1] == "record" else _cmd_check() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv))