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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/readme-version-check.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: `<release-or-phase>/<ticket>/<type>/<short_summary>` — a four-part scheme. `<type>` is the change category (see the table below), and `<short_summary>` is 2–4 words in `snake_case`. Example: `v0.0.1/AAASM-42/feat/add_registry`.
Expand Down Expand Up @@ -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

Expand Down
93 changes: 93 additions & 0 deletions scripts/check_readme_version.py
Original file line number Diff line number Diff line change
@@ -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 <version>``) 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 <version>`` 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 <version>`` 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())