diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 68199b0c..b7caa510 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -100,7 +100,14 @@ this order: explicit args → env (`AAASM_GATEWAY_URL` / `AAASM_API_KEY`) → only `agent_assembly/**/*.py`, `test/**/*.py`, and specific config files — it **excludes** `docs/**`, `*.md`, and `examples/**`. A docs-only PR with no CI is *review-required*, not a failure. (`pre-commit` also excludes `.github/` and - `docs/`.) + `docs/`.) Exception: `README.md` / `pyproject.toml` edits trigger the + `readme-version-check.yml` gate below. +- **README version literal is SoT-gated.** The `aasm --version` sample output in + `README.md` must equal the `pyproject.toml` version anchor (`[project].version`) — + the single source of truth per [ADR 0013](https://github.com/ai-agent-assembly/agent-assembly/blob/master/docs/src/adr/0013-version-metadata-source-of-truth-and-drift-gate.md). + `scripts/check_readme_version.py --check` (blocking CI: `readme-version-check.yml`) + fails the build on drift. Don't retype the README literal on a bump without + matching the anchor. - **macOS native build:** building the PyO3 `cdylib` via a plain `cargo build` link-fails (unresolved Python symbols). Use **maturin**, or `cargo rustc -- -Clink-arg=-undefined -Clink-arg=dynamic_lookup`. diff --git a/.github/workflows/readme-version-check.yml b/.github/workflows/readme-version-check.yml new file mode 100644 index 00000000..9fdea6cd --- /dev/null +++ b/.github/workflows/readme-version-check.yml @@ -0,0 +1,38 @@ +name: README Version Drift Check + +# AAASM-4918 (ADR 0013): the README `aasm --version` sample output is a +# hand-typed version literal that sits outside the pyproject.toml version anchor +# (the SoT). This gate re-audits it on every PR and fails if the sample drifted +# from the anchor, so a stale sample output can never merge. + +on: + pull_request: + paths: + - "README.md" + - "pyproject.toml" + - "scripts/check_readme_version.py" + - ".github/workflows/readme-version-check.yml" + push: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + version-drift: + name: README version drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + + - name: Audit README sample output against the pyproject.toml anchor + run: python scripts/check_readme_version.py --check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d617918c..0b971f38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -153,6 +153,22 @@ The SDK targets Python ≥ 3.12, so use **[PEP 695](https://peps.python.org/pep- This requires `mypy >= 1.11` (PEP 695 support), pinned in `pyproject.toml`. +### README version drift check + +The `aasm --version` sample output in `README.md` is a hand-typed version literal. +Per [ADR 0013](https://github.com/ai-agent-assembly/agent-assembly/blob/master/docs/src/adr/0013-version-metadata-source-of-truth-and-drift-gate.md) +the package version anchor in `pyproject.toml` (`[project].version`) is the single +source of truth; the README sample must never restate a different value. A blocking +CI job (`.github/workflows/readme-version-check.yml`) audits this on every PR: + +```bash +python scripts/check_readme_version.py --check # exit 0 = in sync, 1 = drift +``` + +If it fails, the sample output drifted from the anchor. Fix it by editing the +**anchor** (the release tooling normally owns this) and updating the README sample +to match — never leave the two out of sync. + ## Branch naming and commit style - **Branch**: `///` — a four-part scheme. `` is the change category (see the table below), and `` is 2–4 words in `snake_case`. Example: `v0.0.1/AAASM-42/feat/add_registry`. @@ -188,6 +204,7 @@ Before requesting review, confirm every item below. - [ ] If adapters or runtime changed: added/updated tests under `test/unit/` and `test/integration/` - [ ] If public API changed: docstrings updated (mkdocstrings will pick them up automatically) - [ ] If user-facing behaviour changed: README.md / docs/ updated +- [ ] If the README `aasm --version` sample or `pyproject.toml` version changed: `python scripts/check_readme_version.py --check` is green - [ ] No `print()` / `breakpoint()` / commented-out dead code left in the diff - [ ] No `.env`, secrets, or large binaries staged diff --git a/scripts/check_readme_version.py b/scripts/check_readme_version.py new file mode 100644 index 00000000..4f618529 --- /dev/null +++ b/scripts/check_readme_version.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Audit the README sample-output version literal against the package SoT. + +WHY THIS EXISTS +--------------- +Per ADR 0013 (version-metadata source-of-truth & drift gate, AAASM-4907), every +version-bearing value has exactly one source of truth and nothing outside it may +carry a drifting version literal. This repo's SoT is the package version anchor in +``pyproject.toml`` (``[project].version``); ``release-tag-cut`` is its only +sanctioned writer. + +``README.md`` shows a ``aasm --version`` sample whose expected output (``# e.g. +aasm ``) is a hand-typed literal that sits outside that anchor — tag [B] +in the ADR's audit (Appendix A / Appendix B item 4). It stays correct only by a +maintainer remembering to retype it on every bump, which is exactly the drift the +ADR eliminates. + +Rather than stand up a full generator/metadata pipeline for a single line, this is +the ADR's alternative: a scoped **orphan-literal audit**. It reads the anchor and +fails if any ``aasm `` sample literal in the README does not equal it, so a +stale sample output is a red build at PR time instead of a finding in a later sweep. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +# Matches a sample-output CLI version token: the literal ``aasm`` followed by a +# version starting with a digit (so ``aasm --version`` and prose like ``aasm CLI`` +# are not captured — only the emitted ``aasm 0.0.1rc6`` form). +_SAMPLE_VERSION = re.compile(r"\baasm\s+(\d[\w.+-]*)") + + +def _repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def read_anchor(pyproject: Path) -> str: + """Return the package version anchor (the SoT) from ``pyproject.toml``.""" + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + version = data["project"]["version"] + if not isinstance(version, str): # pragma: no cover - malformed manifest + raise TypeError(f"[project].version is not a string: {version!r}") + return version + + +def find_sample_versions(readme_text: str) -> list[str]: + """Return every ``aasm `` sample-output literal found in the README.""" + return _SAMPLE_VERSION.findall(readme_text) + + +def audit(pyproject: Path, readme: Path) -> list[str]: + """Return a list of drift messages; empty means the README is in sync.""" + anchor = read_anchor(pyproject) + found = find_sample_versions(readme.read_text(encoding="utf-8")) + return [ + f"{readme.name}: sample output 'aasm {literal}' drifted from the pyproject.toml version anchor 'aasm {anchor}'" + for literal in found + if literal != anchor + ] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Audit only and exit non-zero on drift (the CI-gate mode).", + ) + parser.parse_args(argv) + + root = _repo_root() + drift = audit(root / "pyproject.toml", root / "README.md") + if drift: + for message in drift: + print(f"error: {message}", file=sys.stderr) + print( + "\nThe README sample output must match the pyproject.toml version " + "anchor (SoT). Edit the anchor, not the README literal, then update " + "the README sample to match. See ADR 0013.", + file=sys.stderr, + ) + return 1 + print("README sample-output version is in sync with the pyproject.toml anchor.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())