Skip to content

Commit ccf451b

Browse files
alexkromanclaude
andauthored
Document cross-platform portability and release gotchas in AGENTS.md (#174)
## Summary Add guidance to `tests/AGENTS.md` and `AGENTS.md` documenting hard-won lessons about cross-platform testing and release automation that have each cost prior sessions a follow-up PR. ## Changes - **tests/AGENTS.md**: New "Cross-platform portability" section documenting four categories of OS-specific failures that don't surface on Linux but break on Windows or macOS: - POSIX-only imports (termios, fcntl, os.openpty) must use `pytest.importorskip()` at module scope, not skip markers - Permission-bit assertions (0o600/0o700) must be gated on `os.name == "posix"` while testing cross-platform behavior unconditionally - Case-insensitive filesystems on macOS require assertions on case-stable properties instead of path casing - GNU vs BSD tooling differences in `check.sh` (grep -E vs -P, brew audit by name not path) - **AGENTS.md**: New "Release-run operational gotchas" subsection documenting two issues specific to the `release.yml` workflow: - Bot-opened formula PRs use `GITHUB_TOKEN` which doesn't trigger CI, requiring admin override to merge - Manual `workflow_dispatch` `tag` job must set git identity before calling `cut_release.sh` (which creates annotated tags requiring committer info) ## Rationale These sections capture patterns that repeatedly catch maintainers and agents: a green Linux gate doesn't guarantee green macOS/Windows runs, and release automation has subtle credential/identity requirements. Documenting them upfront prevents future follow-up PRs. https://claude.ai/code/session_0188anNrEdHn2SHHDPCkoKVR Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2b0958 commit ccf451b

2 files changed

Lines changed: 39 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ structured so independent changes stay in disjoint files. Keep it that way:
7676
- `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`).
7777
- `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.12–3.13.
7878
- **Releasing is tag-triggered.** The version is **derived from the git tag** by hatch-vcs and written to a gitignored `aai_cli/_version.py` at build time — there is no version string to keep in sync across `pyproject.toml` or `aai_cli/__init__.py`, and `bump_patch.sh` no longer exists. To cut a release, run `scripts/cut_release.sh` from a clean `main` in sync with `origin/main`: no argument → next patch above the latest `vX.Y.Z` tag; `cut_release.sh X.Y.Z` → explicit version. It tags + pushes, which fires `.github/workflows/release.yml` — that builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR. **You don't need a local checkout to release:** `release.yml` also has a manual `workflow_dispatch` (GitHub's "Run workflow" button, or `actions_run_trigger` from a Claude web session) taking an optional `version` input — its `tag` job resolves the version and creates+pushes the tag (reusing `cut_release.sh --no-push`), and the rest of the pipeline then runs in that same workflow run. Tag creation lives *inside* the release run on purpose: a `GITHUB_TOKEN` tag push wouldn't re-trigger the `on: push` half, so a separate "push the tag" workflow would silently never build. (`dry_run: true` builds the bottle for an existing tag without publishing.) Bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. The Homebrew formula builds from a git-less GitHub source tarball, so `Formula/assembly.rb`'s `def install` sets the generic `SETUPTOOLS_SCM_PRETEND_VERSION` env var (installing resources first under a clean env, then setting the var for our package only) to feed the tag version to the build. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`.
79+
- **Release-run operational gotchas (cost prior sessions a follow-up PR each).** Two
80+
things bite the `release.yml` path specifically: (1) the bot-opened formula PR (`Bottle
81+
vX.Y.Z`) is authored with `GITHUB_TOKEN`, which **does not trigger CI**, so its required
82+
check never reports — merge it with the admin override; the diff is formula-only by
83+
construction. (2) The manual `workflow_dispatch` `tag` job checks out with
84+
`persist-credentials: false` and must **set a git identity** before invoking
85+
`cut_release.sh`, because the script cuts an *annotated* tag (`git tag -a`) which needs a
86+
committer — without it the run dies with `empty ident name` and the bottle/publish jobs
87+
skip silently (this path only ever "worked" locally, where maintainers have a global
88+
identity). Mirror the `publish` job's `git config user.{name,email}` step.
7989

8090
## Manual QA / running the CLI in sandboxed sessions
8191

tests/AGENTS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,35 @@ uv run pytest -q -n auto --timeout=60 --cov=aai_cli --cov-branch --cov-context=t
8787
uv run python scripts/mutation_sweep.py aai_cli/config.py # or omit paths for the whole package
8888
```
8989

90+
## Cross-platform portability (a green Linux gate isn't a green macOS/Windows run)
91+
92+
`scripts/check.sh` runs **Linux-only** (it's bash plus Go/Homebrew/shell tooling),
93+
and that's the only gate a web session can run. But CI also runs the pytest suite
94+
on `windows-latest` (the `tests (windows)` job), and maintainers run the full gate
95+
on macOS — so OS-specific failures you never see on Linux still land on `main`.
96+
These have each cost a session a follow-up PR; bake the fix in up front:
97+
98+
- **POSIX-only imports at module scope crash collection on Windows.** A top-level
99+
`import termios` / `fcntl` / `os.openpty` (e.g. `tests/test_hotkey.py`'s pty driver)
100+
aborts collection before any skip can apply. Guard it with
101+
`pytest.importorskip("termios")` at the top of the module — that skips the whole file
102+
on Windows and, unlike a skip/xfail marker, is **not** counted by the Linux
103+
escape-hatch gate (which greps for the marker/call forms — so don't paste those literal
104+
tokens into a test file or even this guide; that itself trips the count).
105+
- **Permission-bit asserts are POSIX-only.** `0o600`/`0o700` mode checks (e.g.
106+
`tests/test_init_scaffold.py`) don't hold on Windows. Gate the mode assertion on
107+
`os.name == "posix"` and assert the cross-platform behavior (file contents, the `.env`
108+
rewrite) unconditionally so the test still covers Windows.
109+
- **macOS filesystems are case-insensitive by default.** A test that distinguishes two
110+
paths differing only in case (hard-link / same-file detection) passes on Linux and fails
111+
on macOS — assert on a case-stable property instead of the casing.
112+
- **When you touch `check.sh` itself, don't assume GNU tooling.** macOS ships BSD
113+
utilities: BSD/ERE `grep -E` silently *ignores* `\b`, so a baseline-vs-working count that
114+
used `git grep -E` on one side and `rg` on the other disagreed and failed the escape-hatch
115+
gate on macOS only — use one matcher consistently (`git grep -P`, PCRE). Homebrew 6+ also
116+
dropped `brew audit [path]`; a formula must be audited **by name** (copy it into an
117+
ephemeral local tap first). Both bit a "green on Linux" branch on the maintainer's Mac.
118+
90119
## Replay fixtures (offline end-to-end coverage)
91120

92121
`tests/test_replay_e2e.py` drives whole commands (`transcribe`/`transcripts`/`llm`/

0 commit comments

Comments
 (0)