From d02d38872ce6e41b673362485104d8f9ae349b97 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 4 Oct 2026 18:53:04 -0600 Subject: [PATCH 1/6] doc: add CLAUDE.md for Claude Code sessions Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be89afa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`app_skellington` is a small Python library (published to PyPI) for building CLI applications. It has nothing runnable on its own. It glues together argparse-based multi-level command menus, name-based dependency injection, ConfigObj INI config with spec validation, and colorlog logging. It targets Python >=3.8 on Linux, Windows and Mac. Runtime deps are `appdirs`, `configobj` and `colorlog`. + +## Commands + +```bash +pip install -e '.[dev]' pytest # dev extras = black, isort, flake8, pre-commit; pytest is NOT in the extras +pytest tests # full suite from repo root (or `pytest .` from inside tests/) +pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults # single test +pytest tests -k "" + +black app_skellington +isort --profile black app_skellington +flake8 app_skellington # .flake8: max-line-length 120, tests/ excluded + +python -m build # sdist + wheel into dist/ +python -m setuptools_scm # print the version the build will get +``` + +- **Versioning is tag-driven.** setuptools_scm uses `release-branch-semver` and derives the version from the latest `vX.Y.Z` git tag. It writes `app_skellington/_version.py` at build time. That file is tracked, but don't hand-edit it. A release means tagging, then `python -m build`, then `twine upload dist/*`. The README covers the full publish steps. +- **Formatter config is inconsistent.** `pyproject.toml` has no `[tool.black]`, so black runs at its default width of 88. Only flake8 allows 120. The code on `main` is formatted at 88. The pre-commit black hook pins `language_version: python3.8`. It fails unless a 3.8 interpreter is on PATH (`.python-version` names 3.8.x for pyenv). +- **Lint is not currently clean on `main`.** black wants to reformat 2 files, isort flags 3, and flake8 reports W391 and W503. Keep unrelated reformatting out of feature diffs. +- Tests cover only `cfg` (ConfigObj fixtures live in `tests/cfg/`) plus a CommandTree constructor smoke test. `tests/test_log.py` is empty. + +## Architecture + +Package layout: `_bootstrap.py`, `_util.py`, `cfg.py`, `log.py`, `cli.py` and `app_container.py`. `__init__.py` re-exports the public names explicitly. + +**`ApplicationContainer` (app_container.py) is the entry point applications subclass.** Its `__init__` does the following, in order: +1. builds `cfg.Config(configspec_filepath, configini_filepath)` +2. builds `log.LoggingLayer` and configures it from the config's `[logging]` section, or from `DEFAULT_LOG_SETTINGS` if there is none +3. creates `ApplicationContext` (`self.ctx`, which holds config, log and parsed argv) +4. registers `ctx` as a service and creates `self.cli = CommandTree()` +5. calls the optional subclass hooks **`_cli_options()`, `_services()`, `_command_menu()`**, in that order, but only if they are defined + +Applications then call `invoke_from_cli()`, which parses argv and dispatches. + +**Dependency injection works by parameter name.** +- Services are registered as *factories*: `app["db"] = lambda: Db(...)`. +- `app["db"]` calls the factory each time, so factories decide whether services are singletons. +- An unknown name raises `ServiceNotFound`. +- `_inject_service_dependencies(cls)` reads `cls.__init__`'s parameter names (skipping `self`) and returns a partial. When that partial is called, it resolves each name from the container and constructs the class. + +**Classes become commands.** `_util.register_class_as_commands(app, submenu, Cls)` registers every public function of `Cls` as a command. Each invocation constructs a *fresh* `Cls` instance with its dependencies injected, then calls the method. The method's signature (minus `self`) and its docstring are what argparse sees. You pass the class, not an instance. + +**`CommandTree` (cli.py) maps function signatures onto argparse.** +- Parameters with defaults become optional positionals (`nargs="?"`). Parameters without defaults become required positionals. Options can be added with `add_argument`. +- Help text is the first sentence of the docstring (`HelpGenerator`). +- It runs in one of two **mutually exclusive modes**. In submenu mode, `init_submenu()` → `SubMenu.create_submenu()` / `register_command()` fills nested subparsers, tracked in `entries`. In single-command mode, `CommandTree.register_command()` binds one function directly to the root parser. The two modes are guarded by asserts in `_lookup_command`. +- Dispatch walks the parsed namespace one submenu `var_name` at a time until it hits a `CommandEntry`. It then calls the callback with the args whose names match the signature. +- argparse placement matters: `prog --opt sub cmd` applies `--opt` at the root, while `prog sub cmd --opt` applies it to the command. + +**`ServiceNotFound` and `NoCommandSpecified` live in `_util.py`.** That placement avoids a circular import between `cli` and `app_container`. Don't move them back into `app_container`. + +**Config (cfg.py)** wraps `configobj.ConfigObj`. +- Setting `configspec_filepath` or `configini_filepath` reloads and revalidates immediately. +- Validation runs with `copy=True`, so spec defaults populate the config. +- Interpolation is `"template"` (`$var`, not `%(var)s`). +- Keys beyond the spec are allowed. +- A validation failure logs and returns `False`; it doesn't raise. The strict-validation flag is hard-coded off, and the `capabilities` constructor argument is ignored. +- `EnvironmentVariables` is an unimplemented stub. + +**Logging (log.py, _bootstrap.py).** +- The framework's own logs go to a private logger named `skell` (`_bootstrap_logger`). It is silenced to CRITICAL with `propagate=False`, unless the env var **`APPSKELLINGTON_DEBUG`** is set. The README's `APPSKELLINGTON_ENABLE_LOGGING` is stale; the code only reads `APPSKELLINGTON_DEBUG`. +- That env var is checked twice: once at import in `_bootstrap.py`, and again in `LoggingLayer._add_own_logconfig` when the logging config is (re)applied. +- `LoggingLayer.transform_config` adapts a ConfigObj-friendly dict for `logging.config.dictConfig`. It forces `version=1` and turns level strings (`"debug"`, `"all"`, ...) into ints. It renames the logger key `root` to `""`, because ConfigObj can't hold an empty key. It also resolves a relative `handlers.file.filename` into `appdirs.user_log_dir(appname, appauthor)`. +- `_bootstrap.py` also checks at import time that the third-party deps are installed, and raises `ImportError` if they're missing. + +## Branches + +`main` is the active branch. `develop` exists on the remote but is stale: it stopped in 2024-08 and still uses the old Poetry build. From da9568b4f95c05506d324d2a319e729c213b3045 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 4 Oct 2026 18:53:41 -0600 Subject: [PATCH 2/6] doc: note editable install rewrites _version.py Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index be89afa..73ccd6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ python -m build # sdist + wheel into dist/ python -m setuptools_scm # print the version the build will get ``` -- **Versioning is tag-driven.** setuptools_scm uses `release-branch-semver` and derives the version from the latest `vX.Y.Z` git tag. It writes `app_skellington/_version.py` at build time. That file is tracked, but don't hand-edit it. A release means tagging, then `python -m build`, then `twine upload dist/*`. The README covers the full publish steps. +- **Versioning is tag-driven.** setuptools_scm uses `release-branch-semver` and derives the version from the latest `vX.Y.Z` git tag. It writes `app_skellington/_version.py` at build time. That file is tracked, but don't hand-edit it. `pip install -e .` also rewrites it, so `git restore` it rather than committing the churn. A release means tagging, then `python -m build`, then `twine upload dist/*`. The README covers the full publish steps. - **Formatter config is inconsistent.** `pyproject.toml` has no `[tool.black]`, so black runs at its default width of 88. Only flake8 allows 120. The code on `main` is formatted at 88. The pre-commit black hook pins `language_version: python3.8`. It fails unless a 3.8 interpreter is on PATH (`.python-version` names 3.8.x for pyenv). - **Lint is not currently clean on `main`.** black wants to reformat 2 files, isort flags 3, and flake8 reports W391 and W503. Keep unrelated reformatting out of feature diffs. - Tests cover only `cfg` (ConfigObj fixtures live in `tests/cfg/`) plus a CommandTree constructor smoke test. `tests/test_log.py` is empty. From 713c49b4c41ad2ffcf6d7bb3d9396e34368de628 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 4 Oct 2026 19:09:02 -0600 Subject: [PATCH 3/6] =?UTF-8?q?doc:=20Python=20support=20policy=20?= =?UTF-8?q?=E2=80=94=20drop=20EOL=20versions=20over=20contorting=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 ++++ app_skellington/_version.py | 32 ++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73ccd6a..2ec8a91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,10 @@ python -m setuptools_scm # print the version the build will get - **Lint is not currently clean on `main`.** black wants to reformat 2 files, isort flags 3, and flake8 reports W391 and W503. Keep unrelated reformatting out of feature diffs. - Tests cover only `cfg` (ConfigObj fixtures live in `tests/cfg/`) plus a CommandTree constructor smoke test. `tests/test_log.py` is empty. +## Python support policy + +`requires-python` is `>=3.8`, and the claim should match what CI actually tests. When an end-of-life Python version starts failing, drop it: raise `requires-python` and remove its CI lane. Don't add compat shims, version branches or contorted code just to keep it working. Clean code matters more than supporting EOL runtimes. Users on old Pythons keep the older releases on PyPI. + ## Architecture Package layout: `_bootstrap.py`, `_util.py`, `cfg.py`, `log.py`, `cli.py` and `app_container.py`. `__init__.py` re-exports the public names explicitly. diff --git a/app_skellington/_version.py b/app_skellington/_version.py index 8ec8685..8b8b410 100644 --- a/app_skellington/_version.py +++ b/app_skellington/_version.py @@ -1,20 +1,24 @@ -# file generated by setuptools-scm +# file generated by vcs-versioning # don't change, don't track in version control +from __future__ import annotations -__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"] - -TYPE_CHECKING = False -if TYPE_CHECKING: - from typing import Tuple, Union - - VERSION_TUPLE = Tuple[Union[int, str], ...] -else: - VERSION_TUPLE = object +__all__ = [ + "__version__", + "__version_tuple__", + "version", + "version_tuple", + "__commit_id__", + "commit_id", +] version: str __version__: str -__version_tuple__: VERSION_TUPLE -version_tuple: VERSION_TUPLE +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None -__version__ = version = "0.3.0.dev7+gd155176.d20250223" -__version_tuple__ = version_tuple = (0, 3, 0, "dev7", "gd155176.d20250223") +__version__ = version = '0.3.0.dev10+gda9568b4f.d20261005' +__version_tuple__ = version_tuple = (0, 3, 0, 'dev10', 'gda9568b4f.d20261005') + +__commit_id__ = commit_id = 'gda9568b4f' From 2ce8d7532682f22ebd36cd8be484975f00854d0f Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 4 Oct 2026 20:04:34 -0600 Subject: [PATCH 4/6] doc: modernization design spec (ruff, uv, Gitea CI matrix, 0.2.3 hotfix) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../specs/2026-10-04-modernization-design.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-modernization-design.md diff --git a/docs/superpowers/specs/2026-10-04-modernization-design.md b/docs/superpowers/specs/2026-10-04-modernization-design.md new file mode 100644 index 0000000..e664fe7 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-modernization-design.md @@ -0,0 +1,112 @@ +# app_skellington modernization — design + +Date: 2026-10-04 · Status: draft for review + +## Goal + +Keep the library's existing behaviour while modernizing the tooling and backing every compatibility claim with CI. After that, harden the code. The first user-visible result is a **0.2.3 hotfix release**. Every install of the current PyPI release (0.2.2) is broken: `import app_skellington` raises `ImportError ... partially initialized module ... (most likely due to a circular import)`. The fix (`d155176`) is already on `main` but was never released. + +## Decisions (agreed in brainstorming) + +| Topic | Decision | +|---|---| +| Python floor | Keep `requires-python >=3.8`. When an EOL version starts failing, drop it rather than contort the code (policy recorded in CLAUDE.md). | +| Platforms | Claim only what's tested: **Linux**, CPython 3.8–3.14. Windows and macOS are a stated long-term goal for this repo, but out of scope until a runner exists. | +| Formatter/linter | **ruff** replaces black, isort and flake8. | +| Line length | **88**: the black and ruff default and the de facto modern standard. `main` is already formatted at 88. Measured churn: 4 files, cosmetic only (assert-message wrapping). | +| Reformatting | Goes in its **own style-only PR**. | +| Build system | Keep pyproject + setuptools + setuptools_scm (tag-driven versions). Add **uv** for envs, the lockfile, builds and publishing. Poetry is already gone from `main`. | +| PyPI | Nothing gets published until CI is green across the matrix. Each release goes to TestPyPI first. Publishing stays manual (Mathew's credentials); there's no publish job in CI for now. | +| Branches | Mathew recreates `develop` from `main`. All PRs below target `develop`, `develop` goes to `main` by PR, and release tags go on `main`. Stale PR #2 (develop → main, 2024) gets closed as superseded. | + +## Current state (verified 2026-10-04) + +- The suite passes on 3.8, 3.9, 3.10, 3.12, 3.13 and 3.14 from `main`, but it covers only `cfg` plus one CommandTree constructor smoke test. Passing it says little about CLI, DI or logging. +- `app_skellington/_version.py` is tracked, yet setuptools_scm rewrites it on every install or build. Newer setuptools_scm writes `tuple[int | str, ...]` annotations, which crash when imported on Python 3.8 and 3.9. +- Lint isn't clean: black would reformat 2 files, isort flags 3, flake8 reports W391 and W503. +- The README names the wrong debug env var (`APPSKELLINGTON_ENABLE_LOGGING`; the code reads `APPSKELLINGTON_DEBUG`). It also claims Windows and Mac support that nothing tests. + +## Delivery: five PRs in order, then the release + +### PR 1: style (ruff, format only) + +- `pyproject.toml`: `[tool.ruff]` with `line-length = 88` and `target-version = "py38"`, excluding `app_skellington/_version.py`. Lint rules are `select = ["E", "F", "W", "I"]`, matching the old flake8 + isort coverage. +- Any existing violation that `ruff check --fix` can't fix safely gets a per-rule `ignore` with a comment. Each one is removed during hardening (PR 5). This PR changes no logic. +- Apply `ruff format` and the safe `ruff check --fix` (import order). +- Delete `.flake8`. In `.pre-commit-config.yaml`, replace the black, isort and flake8 hooks with `ruff-pre-commit` (`ruff-check --fix`, `ruff-format`). That also drops the `python3.8` pin, which breaks on machines without 3.8. +- Done when `ruff check` and `ruff format --check` are clean, tests pass, and the diff is whitespace and import order only. + +### PR 2: tooling (uv), packaging hygiene, docs + +- `[dependency-groups] dev = ["ruff", "pytest", "pre-commit", "pyyaml"]` (PEP 735) replaces `[project.optional-dependencies] dev`, so dev tools are no longer extras on the published package. Commit `uv.lock`. uv resolves one lock across all Pythons in range, so 3.8 lanes get compatible older pytest and other tools with no hand pinning. +- `.python-version`: set the dev default to one current version (3.14). CI covers the rest. +- **Untrack `_version.py`** and add it to `.gitignore`. setuptools_scm still generates it at build time, so the sdist and wheel ship it. Nothing in the package imports it. +- Commands become `uv sync`, `uv run pytest`, `uv run ruff ...`, `uv build` and `uv publish`. +- README: + - fix the debug env var + - replace the pyenv and pip dev setup with uv + - drop the untested Windows/Mac claim; PR 3 adds the tested-platform line once CI backs it + - update the test and publish instructions + - leave the claim "config.ini is created if missing" until PR 4 confirms it; correct it there if the test disagrees +- Update CLAUDE.md to match: commands, the branch section after `develop` is recreated, and the lint and `_version.py` notes. + +### PR 3: Gitea CI (checks only) + +`.gitea/workflows/ci.yaml`, following `~/.claude/docs/gitea-ci-pattern.md`: + +- **Triggers:** `pull_request` (types `opened, synchronize, reopened, edited`), `push: [develop, main]`, `workflow_dispatch`. Concurrency cancels superseded PR runs only. +- **WIP gate** on every job that runs on `pull_request`: skip when the title starts with `WIP:` or `[WIP]`, and nothing else. +- **`lint`:** one Python. Runs `uv run ruff check` and `uv run ruff format --check`. +- **`test`:** matrix over `python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]`, `fail-fast: false`. Container `python:`. The first step installs `nodejs` (Gitea actions are node programs), then uv. Checkout uses `fetch-depth: 0`, because setuptools_scm needs tags. Then each lane: + 1. `uv sync --locked`, then `uv run pytest`. + 2. **Packaging smoke test:** `uv build`, install the built wheel into a fresh venv, then `cd /` (away from the source tree) and run `python -c "import app_skellington; ..."`. This is the check that would have caught 0.2.2. +- **`ci-ok`** aggregate job, `needs: [lint, test]`, with `if: always()` plus the WIP gate. It fails unless every needed job's result is `success`. + - Why it runs `always()`: Gitea reports a *skipped* job as a `success` status. A plain `needs:` aggregate would go green when a needed job failed. The `always()` + explicit check closes that hole. + - Branch protection then requires only `CI / ci-ok (pull_request)`, so one required check covers every matrix lane. +- **`tests/test_ci_workflow.py`** pins the invariants: triggers, WIP gate on each PR job, matrix versions equal to the `requires-python` floor through the newest, and the `ci-ok` logic. CI drift then shows up as a red test. +- README: add "Tested on Linux, CPython 3.8–3.14 (CI). Windows/macOS: goal, untested." +- **Rollout per the playbook:** run every gate locally first, then canary one gate at a time (a lint failure, then a test failure), watch it go red, revert, watch it go green. Mathew then adds the branch-protection status check. + +### Release 0.2.3 (hotfix, after PR 3 is green on `main`) + +1. Promote `develop` → `main` by PR, with CI green. +2. Tag `v0.2.3` on `main`, then `uv build`. +3. `uv publish` to **TestPyPI**. Install it from TestPyPI on every matrix Python (a script under `scripts/` running `uv run --isolated --python X --index testpypi`) and import it. +4. `uv publish` to PyPI. Mathew does this step; whether to yank 0.2.2 afterwards is his call. +5. CHANGELOG entry. + +### PR 4: characterization tests + +These pin *current* behaviour before any hardening changes it. Known bugs get tests marked `xfail(strict=True)`, each with the bug named, so a fix flips the test and forces it to be updated. + +- **CLI:** single-command and submenu dispatch, nested submenus, defaults versus required positionals, help text from the first docstring sentence, `NoCommandSpecified`, option placement (root versus command). +- **Container and DI:** factory registration, `ServiceNotFound`, constructor-parameter-name injection, `register_class_as_commands` (fresh instance per call, public methods only), the hook order `_cli_options` → `_services` → `_command_menu`. +- **Logging:** `transform_config` (`root` → `""`, level strings to ints, `version=1`, relative file path → user log dir, using a tmp dir), `APPSKELLINGTON_DEBUG` on and off. +- **Config:** whether config.ini is created when missing (then correct the README to match), `$var` interpolation, spec defaults, validation failure returning `False`. +- **Public API:** everything `__init__.py` exports can be imported. + +### PR 5+: hardening, one fix per PR or small groups + +Each fix flips its `xfail` from PR 4. Known so far: +- `Config.__delitem__` recurses forever (it calls `del self[key]`). +- `_util.get_asset` has a broken `isinstance(module, module)` check. +- `cli._lookup_command` can stop at `input("")`. +- `logger.warn` is deprecated; use `warning`. +- `_load_config_files` swallows broad exceptions. +- `Config.__contains__` returns `None` on error. +- Remove the per-rule ruff ignores added in PR 1. +- Issue #3: appdirs → platformdirs, with a path-parity check. + +The public API (the names exported from `__init__.py`) stays stable. + +## Out of scope (recorded, not planned) + +- Windows and macOS CI runners. This is the long-term multi-platform goal and needs infrastructure. +- A PyPI publish job in CI (would need the deferred credential model in the CI pattern doc). +- A PyPy lane and type checking. Both can be revisited once the matrix is stable. + +## Risks + +- **Old Python images:** `python:3.8` images are frozen, since 3.8 is EOL. If apt or nodejs installs inside them break, the policy applies: drop the lane and raise the floor rather than build workarounds. +- **Lockfile resolution on 3.8:** some dev tools may have no 3.8-compatible release. uv's fork resolution should handle this; if it can't, that's the trigger to raise the floor. +- **setuptools_scm in CI** needs full history and tags (`fetch-depth: 0`, `safe.directory`). Without them the version falls back to `0.0.0`, and the smoke test should assert that it doesn't. From ae40f57e6babc9de683969c6888e78a539d3f9d2 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 4 Oct 2026 20:06:40 -0600 Subject: [PATCH 5/6] doc: spec review fixes; CLAUDE.md branch model is develop-integrates Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/superpowers/specs/2026-10-04-modernization-design.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2ec8a91..bd276c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,4 +77,4 @@ Applications then call `invoke_from_cli()`, which parses argv and dispatches. ## Branches -`main` is the active branch. `develop` exists on the remote but is stale: it stopped in 2024-08 and still uses the old Poetry build. +`develop` is the integration branch: feature branches come off it, and PRs target it. Changes reach `main` through a `develop` → `main` PR. Release tags (`vX.Y.Z`) go on `main`. In 2026-10, `develop` was recreated from `main`; the 2024 `develop` and its PR #2 were superseded. Modernization roadmap: `docs/superpowers/specs/2026-10-04-modernization-design.md`. diff --git a/docs/superpowers/specs/2026-10-04-modernization-design.md b/docs/superpowers/specs/2026-10-04-modernization-design.md index e664fe7..aae7a32 100644 --- a/docs/superpowers/specs/2026-10-04-modernization-design.md +++ b/docs/superpowers/specs/2026-10-04-modernization-design.md @@ -22,7 +22,7 @@ Keep the library's existing behaviour while modernizing the tooling and backing ## Current state (verified 2026-10-04) - The suite passes on 3.8, 3.9, 3.10, 3.12, 3.13 and 3.14 from `main`, but it covers only `cfg` plus one CommandTree constructor smoke test. Passing it says little about CLI, DI or logging. -- `app_skellington/_version.py` is tracked, yet setuptools_scm rewrites it on every install or build. Newer setuptools_scm writes `tuple[int | str, ...]` annotations, which crash when imported on Python 3.8 and 3.9. +- `app_skellington/_version.py` is tracked, yet setuptools_scm rewrites it on every install or build, so every dev run leaves a dirty tree. (The generated file uses `from __future__ import annotations`, and running it on 3.8 was verified to work.) - Lint isn't clean: black would reformat 2 files, isort flags 3, flake8 reports W391 and W503. - The README names the wrong debug env var (`APPSKELLINGTON_ENABLE_LOGGING`; the code reads `APPSKELLINGTON_DEBUG`). It also claims Windows and Mac support that nothing tests. @@ -39,6 +39,7 @@ Keep the library's existing behaviour while modernizing the tooling and backing ### PR 2: tooling (uv), packaging hygiene, docs - `[dependency-groups] dev = ["ruff", "pytest", "pre-commit", "pyyaml"]` (PEP 735) replaces `[project.optional-dependencies] dev`, so dev tools are no longer extras on the published package. Commit `uv.lock`. uv resolves one lock across all Pythons in range, so 3.8 lanes get compatible older pytest and other tools with no hand pinning. +- Move `tests/pytest.ini` into `[tool.pytest.ini_options]` (with `testpaths = ["tests"]`), so plain `uv run pytest` from the repo root picks up the config. - `.python-version`: set the dev default to one current version (3.14). CI covers the rest. - **Untrack `_version.py`** and add it to `.gitignore`. setuptools_scm still generates it at build time, so the sdist and wheel ship it. Nothing in the package imports it. - Commands become `uv sync`, `uv run pytest`, `uv run ruff ...`, `uv build` and `uv publish`. @@ -65,7 +66,7 @@ Keep the library's existing behaviour while modernizing the tooling and backing - Branch protection then requires only `CI / ci-ok (pull_request)`, so one required check covers every matrix lane. - **`tests/test_ci_workflow.py`** pins the invariants: triggers, WIP gate on each PR job, matrix versions equal to the `requires-python` floor through the newest, and the `ci-ok` logic. CI drift then shows up as a red test. - README: add "Tested on Linux, CPython 3.8–3.14 (CI). Windows/macOS: goal, untested." -- **Rollout per the playbook:** run every gate locally first, then canary one gate at a time (a lint failure, then a test failure), watch it go red, revert, watch it go green. Mathew then adds the branch-protection status check. +- **Rollout per the playbook:** run every gate locally first, then canary one gate at a time (a lint failure, then a test failure), watch it go red, revert, watch it go green. For each canary, confirm that **`CI / ci-ok (pull_request)` itself** reports failure, not just the broken lane. This proves `if: always()` and `needs..result` evaluate as expected on this act_runner, since the pattern doc never canaried them. Mathew then adds the branch-protection status check. ### Release 0.2.3 (hotfix, after PR 3 is green on `main`) From 1634ed154eb948559619b4f6f55c0d6104ab330b Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 4 Oct 2026 20:17:24 -0600 Subject: [PATCH 6/6] doc: implementation plan for the modernization hotfix track (PRs 1-3 + 0.2.3) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-10-04-modernization-hotfix-track.md | 1105 +++++++++++++++++ 1 file changed, 1105 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md diff --git a/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md b/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md new file mode 100644 index 0000000..785d99f --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md @@ -0,0 +1,1105 @@ +# Modernization: hotfix track (PRs 1–3 and the 0.2.3 release) — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship app_skellington 0.2.3. It fixes the 0.2.2 crash on `import app_skellington`, and every compatibility claim it makes is backed by a green CI matrix on Linux, CPython 3.8–3.14. + +**Architecture:** Three sequential PRs into `develop`. Each one is cut fresh from the latest `develop` after the previous one merges: +1. ruff replaces black, isort and flake8 (format-only diff). +2. uv: dependency groups, `uv.lock`, packaging cleanup, docs. +3. A Gitea Actions workflow: lint, a matrix of test lanes (each also builds the wheel and sdist and smoke-tests the installed artifact), and one aggregate `ci-ok` required check. + +Then `develop` → `main`, a tag, TestPyPI, verification across the matrix, and PyPI. + +**Tech Stack:** Python ≥3.8, setuptools + setuptools_scm (unchanged build backend), uv 0.12, ruff 0.16.10, pytest, pre-commit, Gitea Actions (act_runner, Linux Docker host). + +**Spec:** `docs/superpowers/specs/2026-10-04-modernization-design.md`. Out of scope here: the spec's PR 4 (characterization tests) and PR 5+ (hardening). They get their own plan after this track ships, because what they test depends on what the characterization tests uncover. + +## Global Constraints + +- `requires-python = ">=3.8"`. If an EOL version can't be made to work without contorting the code, drop it (raise the floor and remove its lane) instead. That's the CLAUDE.md policy. +- Line length is **88**. Reformatting happens only in PR 1, and PR 1 carries no logic changes. +- The CI matrix is exactly `3.8, 3.9, 3.10, 3.11, 3.12, 3.13, 3.14`. The Python classifiers in `pyproject.toml` and the list in `scripts/verify_index_release.sh` must match it. `tests/test_ci_workflow.py` enforces both. +- Platform claim: Linux only. Windows and macOS are "goal, untested". +- Build backend stays setuptools + setuptools_scm (`release-branch-semver`, `node-and-date`). Versions come from `vX.Y.Z` tags. +- Nothing is published to PyPI until CI is green on `main` and the TestPyPI artifact passes `scripts/verify_index_release.sh` on every matrix Python. +- No publish job in CI. Mathew publishes by hand with his own tokens. +- Branches: cut from the latest `develop` (`git fetch && git worktree add .worktrees/ -b origin/develop`), push after the first commit, open a WIP PR with `pr-flow open`, and finish each with `/branch-wrapup`. Mathew merges. Never rewrite history he has seen. +- Every subagent prompt must restate this: **never route around a guard, hook or permission denial. Report it.** + +## Review Focus + +These are the five conditions most likely to bite a user that no task's unit tests cover naturally. Each is pinned by a check in the task named. + +1. **Installing from the sdist** (pip falls back to it, or a distro packager uses it), outside a git checkout. The expected version must be the real one, not `0.0.0`. Pinned by the sdist loop in the CI smoke step (Task 5). +2. **CI checkout without tags** (a shallow clone). setuptools_scm would silently version the package `0.0.x`. Pinned by `smoke_import.py`'s `0.0` check (Task 4) and `test_checkout_fetches_tags` (Task 5). +3. **Importing with `APPSKELLINGTON_DEBUG=1`.** That runs a separate code path in `_bootstrap.py`. Pinned by the subprocess check in `smoke_import.py` (Task 4). +4. **A dev or dirty version uploaded to PyPI by accident** (`0.3.0.devN+g…`). Pinned by the exact-filename check before upload (Task 8, step 4). +5. **A new contributor following the README literally on a fresh clone.** Pinned by the fresh-clone check (Task 3, step 6). + +--- + +## Preconditions (check before Task 1) + +- PR #4 (CLAUDE.md + spec + this plan) has merged into `develop`. Every PR below edits CLAUDE.md. Run `git fetch && git log origin/develop --oneline -3`, and you should see the docs commits. +- `uv` and `tea` are on PATH (`/usr/bin/uv`, `/usr/bin/tea`). + +--- + +## PR 1 — `style/ruff` (format-only) + +### Task 1: Adopt ruff at 88, format-only + +**Files:** +- Modify: `pyproject.toml` (dev extras; add `[tool.ruff*]`) +- Modify: `.pre-commit-config.yaml` (full replacement) +- Delete: `.flake8` +- Modify: the formatted files `app_skellington/app_container.py`, `app_skellington/cfg.py`, `app_skellington/cli.py`, `tests/cfg/test_cfg.py` +- Modify: `CLAUDE.md` (Commands section and the two lint bullets) + +**Interfaces:** +- Produces: `[tool.ruff]` config that PR 3's `lint` job runs as `uv run ruff check` and `uv run ruff format --check`. The pinned `ruff==0.16.10` in the dev deps must match the pre-commit `rev: v0.16.10`. + +- [ ] **Step 1: Cut the branch** + +```bash +cd /home/golem/dev/zavage-software/app_skellington +git fetch origin +git worktree add .worktrees/style-ruff -b style/ruff origin/develop +``` +Then hand the session over with `EnterWorktree(path: "/home/golem/dev/zavage-software/app_skellington/.worktrees/style-ruff")`. + +- [ ] **Step 2: Record the baseline (the "failing test")** + +Run: `uvx ruff@0.16.10 check --line-length 88 --target-version py38 --select E,F,W,I --exclude app_skellington/_version.py app_skellington tests` +Expected: `Found 13 errors.` That's 2× I001 (app_container.py, cli.py), 9× E501, plus F841 and E712 in `tests/test_cli.py`. + +- [ ] **Step 3: Add the ruff config to `pyproject.toml`** + +Replace the `[project.optional-dependencies]` block with: + +```toml +[project.optional-dependencies] +dev = [ + "ruff==0.16.10", + "pre-commit", +] +``` + +Append: + +```toml +[tool.ruff] +line-length = 88 +target-version = "py38" +extend-exclude = ["app_skellington/_version.py"] + +[tool.ruff.lint] +# E/W = pycodestyle, F = pyflakes, I = isort: the old flake8 + isort coverage. +select = ["E", "F", "W", "I"] +# The formatter owns line length. Long strings and comments it can't wrap are allowed. +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +# Placeholder smoke test. The characterization-tests plan rewrites it. +"tests/test_cli.py" = ["F841", "E712"] +``` + +(PR 2 moves the dev deps into `[dependency-groups]`. They stay as extras here so this PR doesn't touch packaging.) + +- [ ] **Step 4: Replace `.pre-commit-config.yaml` and delete `.flake8`** + +```yaml +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=10240'] + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + +# ruff replaces black, isort and flake8. Keep rev equal to the ruff pin in pyproject.toml. +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.10 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format +``` + +```bash +git rm .flake8 +``` + +- [ ] **Step 5: Apply the formatter and the safe fixes** + +```bash +uvx ruff@0.16.10 check --fix app_skellington tests +uvx ruff@0.16.10 format app_skellington tests +``` + +- [ ] **Step 6: Verify lint is clean** + +Run: `uvx ruff@0.16.10 check app_skellington tests && uvx ruff@0.16.10 format --check app_skellington tests` +Expected: `All checks passed!` and `... files already formatted`. + +- [ ] **Step 7: Prove the diff changes no logic** + +Save this as a scratch file **outside the repo**, at `$CLAUDE_JOB_DIR/tmp/ast_same.py` (or `/tmp/ast_same.py`). It compares each changed `.py` file's AST against `origin/develop`. Import statements are normalized for order, since isort reorders them. + +```python +import ast +import subprocess +import sys + + +def norm(src): + tree = ast.parse(src) + imports, rest = [], [] + for node in tree.body: + (imports if isinstance(node, (ast.Import, ast.ImportFrom)) else rest).append(node) + for node in imports: + node.names = sorted(node.names, key=lambda a: (a.name, a.asname or "")) + return sorted(ast.dump(n) for n in imports), [ast.dump(n) for n in rest] + + +files = subprocess.check_output( + ["git", "diff", "--name-only", "origin/develop", "--", "*.py"], text=True +).split() +changed = [ + f + for f in files + if norm(subprocess.check_output(["git", "show", "origin/develop:" + f], text=True)) + != norm(open(f).read()) +] +print("files:", files) +print("logic changed:", changed) +sys.exit(1 if changed else 0) +``` + +Run: `python3 $CLAUDE_JOB_DIR/tmp/ast_same.py` +Expected: `logic changed: []`, exit 0. If any file is listed, revert that hunk; it doesn't belong in a style PR. + +- [ ] **Step 8: Run the tests** + +Run: `uv run --no-project --isolated --python 3.8 --with . --with pytest -- python -m pytest -q tests`, then the same with `--python 3.14`. +Expected: `7 passed` on both. If the run regenerates `app_skellington/_version.py`, `git restore` it. + +- [ ] **Step 9: Update CLAUDE.md** + +In the `## Commands` block, replace these three lines + +``` +black app_skellington +isort --profile black app_skellington +flake8 app_skellington # .flake8: max-line-length 120, tests/ excluded +``` +with +``` +ruff check --fix # lint + import sorting (config in pyproject.toml) +ruff format # formatter, line length 88 +``` +and change the first line's comment to `# dev extras = ruff, pre-commit; pytest is NOT in the extras`. + +Delete the two bullets that start **"Formatter config is inconsistent."** and **"Lint is not currently clean on `main`."**. Add this bullet in their place: + +```markdown +- **ruff** handles formatting (88 columns) and lint (`E`, `F`, `W`, `I`). E501 is ignored on purpose, since the formatter owns line length. Keep the `ruff==` pin in `pyproject.toml` equal to the `rev:` in `.pre-commit-config.yaml`. +``` + +- [ ] **Step 10: Commit, push, open a WIP PR** + +```bash +git add -A +git status --short # expect: pyproject.toml, .pre-commit-config.yaml, .flake8 (D), 4 formatted .py files, CLAUDE.md; NOT _version.py +git commit -m "style: adopt ruff (format + lint) at 88, replacing black/isort/flake8 + +Format-only: AST-identical apart from import order. + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin style/ruff +pr-flow open --title='style: adopt ruff at 88, replacing black/isort/flake8' --body-file= +``` +Body: link the spec, state "format-only, AST-verified", and say that `tests/test_cli.py`'s ignores go away with the characterization tests. Then run `/branch-wrapup`, hand over the PR URL, and **wait for Mathew to merge** before PR 2. + +--- + +## PR 2 — `build/uv` + +### Task 2: uv, dependency groups, lockfile, packaging cleanup + +**Files:** +- Modify: `pyproject.toml` +- Create: `uv.lock` (generated) +- Modify: `.python-version`, `.gitignore` +- Delete: `setup.cfg`, `tests/pytest.ini` +- Untrack: `app_skellington/_version.py` + +**Interfaces:** +- Consumes: `[tool.ruff*]` from Task 1. +- Produces: `[dependency-groups] dev`, a committed `uv.lock`, and `[tool.pytest.ini_options] testpaths = ["tests"]`. PR 3's CI runs `uv sync --locked` and `uv run pytest`, and relies on all of these. + +- [ ] **Step 1: Cut the branch from the freshly merged develop** + +```bash +cd /home/golem/dev/zavage-software/app_skellington +git fetch origin +git worktree add .worktrees/build-uv -b build/uv origin/develop +``` +Then `EnterWorktree(path: ".../.worktrees/build-uv")`. Check that `pyproject.toml` contains `[tool.ruff]`. If it doesn't, PR 1 hasn't merged; stop. + +- [ ] **Step 2: Write the failing check** + +Run: `uv sync --locked` +Expected: FAIL. There's no `uv.lock` yet (`Unable to find lockfile`). + +- [ ] **Step 3: Edit `pyproject.toml`** + +Delete the whole `[project.optional-dependencies]` block. Change the license classifier from `"License :: OSI Approved :: MIT License"` to `"License :: OSI Approved :: MIT No Attribution License (MIT-0)"` (LICENSE.txt is MIT-0). Keep `license = {file = "LICENSE.txt"}`. The SPDX string form would need setuptools ≥77, which can't build on 3.8. + +Append: + +```toml +[dependency-groups] +dev = [ + "ruff==0.16.10", # keep equal to rev in .pre-commit-config.yaml + "pytest", + "pre-commit", + "pyyaml", # tests/test_ci_workflow.py +] + +[tool.setuptools] +packages = ["app_skellington"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] +``` + +(`packages` stops setuptools' flat-layout autodiscovery from ever picking up `tests/`, `scripts/` or `docs/`.) + +- [ ] **Step 4: Remove obsolete files and untrack the generated version file** + +```bash +git rm setup.cfg tests/pytest.ini +git rm --cached app_skellington/_version.py +printf '3.14\n' > .python-version +printf '.venv/\napp_skellington/_version.py\n' >> .gitignore +``` + +- [ ] **Step 5: Lock and sync** + +```bash +uv lock +uv sync --locked +``` +Expected: `Resolved N packages`, and the sync installs into `.venv/`. The lock forks per Python. Confirm the old lanes got compatible tools: `grep -A2 '^name = "pytest"' uv.lock` should show several versions, including an 8.3.x for 3.8. + +- [ ] **Step 6: Verify the gates and a clean tree** + +Run: +```bash +uv run pytest -q +uv run --python 3.8 pytest -q +uv run ruff check && uv run ruff format --check +git status --short +``` +Expected: +- `7 passed` on both Pythons +- ruff clean +- `git status` lists only this task's intended changes. `_version.py` must **not** appear: it's regenerated but ignored now. + +- [ ] **Step 7: Verify the built artifacts still carry the version module** + +```bash +rm -rf dist && uv build +python3 -c "import zipfile,glob; z=zipfile.ZipFile(glob.glob('dist/*.whl')[0]); print(sorted(n for n in z.namelist() if n.startswith('app_skellington/')))" +tar tzf dist/*.tar.gz | grep -E '_version.py|PKG-INFO$' +``` +Expected: +- the wheel lists `app_skellington/_version.py` next to the 7 modules, and nothing from `tests/` +- the sdist contains `_version.py` and `PKG-INFO` + +- [ ] **Step 8: Commit and push** + +```bash +git add -A +git commit -m "build: uv dependency groups + lockfile; untrack generated _version.py + +- dev tools move from a published extra to [dependency-groups] +- uv.lock resolves one lock across CPython 3.8-3.14 +- pytest config moves into pyproject (testpaths) +- explicit packages list; license classifier corrected to MIT-0 +- drop obsolete setup.cfg and tests/pytest.ini + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin build/uv +pr-flow open --title='build: uv dependency groups, lockfile, packaging cleanup' --body-file= +``` + +### Task 3: README and CLAUDE.md for the uv workflow (same PR) + +**Files:** +- Modify: `README.md` (Debug, Tests, Development, Version sections) +- Modify: `tests/README.md` +- Modify: `CLAUDE.md` (Commands section and the versioning bullet) + +**Interfaces:** +- Consumes: the commands Task 2 made work. + +- [ ] **Step 1: Fix the Debug section in `README.md`** + +Replace its first paragraph + +``` +Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns +on AppSkellington-level logging. For example, +``` +with +``` +Set the 'APPSKELLINGTON_DEBUG' environment variable to any value to turn +on AppSkellington-level logging. For example, +``` + +- [ ] **Step 2: Fix the platform claims in `README.md`** + +In the feature list, replace ` - Works on Linux, Windows, and Mac.` with ` - Linux supported. Windows and macOS are goals, but untested.` + +In Principles, replace ` - Compatible with Linux, Windows, and Mac. Try to be compatible as possible otherwise.` with ` - Aim for Linux, Windows and macOS. Only Linux is tested today; full multi-platform CI is a long-term goal.` + +- [ ] **Step 3: Replace the `# Tests`, `# Development` and `# Version` sections in `README.md`** + +Replace everything from `# Tests` up to (not including) `# License` with: + +````markdown +# Tests + +``` +uv run pytest # full suite +uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults +uv run pytest -k "" +uv run --python 3.8 pytest # against another supported Python +``` + +# Development + +Install [uv](https://docs.astral.sh/uv/). It manages Python versions and the +virtual environment, so you don't need pyenv or a hand-made venv. + +``` +git clone https://git-repos.zavage.net/zavage-software/app_skellington.git +cd app_skellington +uv sync # creates .venv with the package (editable) + dev tools +uv run pre-commit install # ruff lint + format on every commit +``` + +Lint and format (ruff, line length 88): +``` +uv run ruff check --fix +uv run ruff format +``` + +Build the sdist and wheel into `dist/`: +``` +uv build +``` + +Dependencies are locked in `uv.lock`. After editing dependencies in +`pyproject.toml`, run `uv lock` and commit the result. + +# Version + +setuptools_scm derives the version from the latest `vX.Y.Z` git tag. Between +tags you get development versions like `0.3.0.dev4+g1a2b3c4`. The generated +`app_skellington/_version.py` is git-ignored. + + uv build # the version appears in the dist/ file names + +# Publish + +Publish only from a tagged commit on `main`, after CI is green, and only after +TestPyPI has been checked. + +``` +git switch main && git pull --ff-only +git tag -a v0.2.3 -m "0.2.3: " +git push origin v0.2.3 +rm -rf dist && uv build +ls dist # must be exactly app_skellington-0.2.3.tar.gz and app_skellington-0.2.3-py3-none-any.whl + +uv publish --publish-url https://test.pypi.org/legacy/ dist/* # TestPyPI first +scripts/verify_index_release.sh 0.2.3 # every supported Python +uv publish dist/* # then PyPI +scripts/verify_index_release.sh 0.2.3 https://pypi.org/simple/ +``` + * Reference https://packaging.python.org/en/latest/overview/ +```` + +(`scripts/verify_index_release.sh` lands in Task 7. This README section merges before that script exists, which is acceptable because publishing can't happen before Task 7 anyway.) + +- [ ] **Step 4: Update `tests/README.md` usage** + +Replace its `Usage` section body with: + +``` +Run all tests (from the repo root): + + uv run pytest + +Run test by keyword: + + uv run pytest -k "" + +Run test by directory: + + uv run pytest tests/ +``` + +- [ ] **Step 5: Update CLAUDE.md** + +Replace the whole `## Commands` code block with: + +```bash +uv sync # .venv with the package (editable) + dev group: ruff, pytest, pre-commit, pyyaml +uv run pytest # full suite (testpaths = tests) +uv run pytest tests/cfg/test_cfg.py::TestConfig_e2e::test_uses_spec_as_defaults # single test +uv run pytest -k "" +uv run --python 3.8 pytest # any supported Python; uv.lock forks per version + +uv run ruff check --fix # lint + import sorting (config in pyproject.toml) +uv run ruff format # formatter, line length 88 + +uv build # sdist + wheel into dist/; the version shows in the file names +uv lock # after any dependency change; commit uv.lock +``` + +Replace the **"Versioning is tag-driven."** bullet with: + +```markdown +- **Versioning is tag-driven.** setuptools_scm uses `release-branch-semver` and derives the version from the latest `vX.Y.Z` git tag. It generates `app_skellington/_version.py`, which is git-ignored, so never commit it. The release steps (tag on `main`, `uv build`, TestPyPI, verify, PyPI) are in the README's Publish section. +``` + +In `## What this is`, change `It targets Python >=3.8 on Linux, Windows and Mac.` to `It targets Python >=3.8. Only Linux is tested; Windows and macOS are goals.` + +- [ ] **Step 6: Fresh-clone check (Review Focus 5)** + +```bash +rm -rf $CLAUDE_JOB_DIR/tmp/fresh && git clone -q --branch build/uv . $CLAUDE_JOB_DIR/tmp/fresh +cd $CLAUDE_JOB_DIR/tmp/fresh && uv sync && uv run pytest -q && uv run ruff check && uv build && git status --short +``` +Expected: `7 passed`, ruff clean, a successful build, and empty `git status`. Return to the worktree afterwards. + +- [ ] **Step 7: Commit, push, wrap up** + +```bash +git add -A +git commit -m "doc: README and CLAUDE.md for the uv workflow; fix debug env var and platform claims + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push +``` +Run `/branch-wrapup` and hand over. **Wait for the merge** before PR 3. + +--- + +## PR 3 — `ci/gitea` + +### Task 4: `scripts/smoke_import.py`, a smoke test for an installed artifact + +**Files:** +- Create: `scripts/smoke_import.py` + +**Interfaces:** +- Produces: `python scripts/smoke_import.py [EXPECTED_VERSION]`. It must run from outside the source tree (`cd /`). It exits non-zero with a message on any failure, and on success prints `smoke ok: `. It's used by the CI test lanes (Task 5) and by `scripts/verify_index_release.sh` (Task 7). + +- [ ] **Step 1: Cut the branch** + +```bash +cd /home/golem/dev/zavage-software/app_skellington +git fetch origin +git worktree add .worktrees/ci-gitea -b ci/gitea origin/develop +``` +Then `EnterWorktree`. Check that `uv.lock` exists. If it doesn't, PR 2 hasn't merged; stop. + +- [ ] **Step 2: Write the script** + +```python +"""Smoke-test an *installed* app_skellington (wheel, sdist or index release). + +Run it from outside the source tree, so the installed copy is the one imported: + + cd / && python /path/to/scripts/smoke_import.py [EXPECTED_VERSION] +""" + +import importlib.metadata +import os +import pathlib +import subprocess +import sys + + +def fail(msg): + sys.exit("smoke FAILED: " + msg) + + +def main(argv): + expected = argv[1] if len(argv) > 1 else None + + import app_skellington + + location = pathlib.Path(app_skellington.__file__).resolve() + if "site-packages" not in location.parts: + fail("imported the source tree, not an installed copy: %s" % location) + + from app_skellington import ( # noqa: F401 (the public API must import) + ApplicationContainer, + CommandTree, + Config, + LoggingLayer, + NoCommandSpecified, + ServiceNotFound, + SubMenu, + ) + + version = importlib.metadata.version("app_skellington") + if version.startswith("0.0"): + fail("setuptools_scm found no tags (shallow clone?): %s" % version) + if expected is not None and version != expected: + fail("expected version %s, got %s" % (expected, version)) + + # _bootstrap.py takes a different code path when APPSKELLINGTON_DEBUG is set. + env = dict(os.environ, APPSKELLINGTON_DEBUG="1") + proc = subprocess.run([sys.executable, "-c", "import app_skellington"], env=env) + if proc.returncode != 0: + fail("import failed with APPSKELLINGTON_DEBUG=1") + + print("smoke ok:", version, location) + + +if __name__ == "__main__": + main(sys.argv) +``` + +- [ ] **Step 3: Prove it catches the real 0.2.2 bug (expected FAIL)** + +Run: `cd / && uv run --no-project --isolated --python 3.12 --with app_skellington==0.2.2 -- python /home/golem/dev/zavage-software/app_skellington/.worktrees/ci-gitea/scripts/smoke_import.py` +Expected: non-zero exit with `ImportError: cannot import name 'NoCommandSpecified' from partially initialized module 'app_skellington'`. + +- [ ] **Step 4: Prove it rejects the source tree (expected FAIL)** + +Run from the worktree root: `uv run python scripts/smoke_import.py` +Expected: `smoke FAILED: imported the source tree, not an installed copy`. The editable install resolves to the worktree, not site-packages. + +- [ ] **Step 5: Prove it passes on a built wheel and sdist (expected PASS)** + +```bash +rm -rf dist && uv build +for d in dist/*.whl dist/*.tar.gz; do + rm -rf /tmp/smoke-venv && uv venv -q --python 3.8 /tmp/smoke-venv + uv pip install -q --python /tmp/smoke-venv "$d" + (cd / && /tmp/smoke-venv/bin/python "$OLDPWD/scripts/smoke_import.py") +done +``` +Expected: two `smoke ok: 0.2.3.devN+g… /tmp/smoke-venv/lib/python3.8/site-packages/app_skellington/__init__.py` lines. (If the worktree isolation check refuses the loop, run the four commands once per artifact.) + +- [ ] **Step 6: Commit** + +```bash +git add scripts/smoke_import.py +git commit -m "test: smoke-test script for installed artifacts (catches the 0.2.2 import crash) + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin ci/gitea +pr-flow open --title='ci: Gitea checks — lint + CPython 3.8–3.14 matrix + artifact smoke tests' --body-file= +``` +The PR opens as `WIP:`, so CI stays skipped until Task 6 removes the prefix. + +### Task 5: Workflow, pinned by `tests/test_ci_workflow.py` + +**Files:** +- Create: `tests/test_ci_workflow.py` +- Create: `.gitea/workflows/ci.yaml` +- Modify: `pyproject.toml` (Python version classifiers) +- Modify: `README.md` (tested-platform line), `CLAUDE.md` (CI section) + +**Interfaces:** +- Consumes: `scripts/smoke_import.py` (Task 4); `uv sync --locked`, `uv run pytest`, `uv run ruff …` (PR 2). +- Produces: the required status context `CI / ci-ok (pull_request)` (branch protection, Task 6). `NEWEST_MINOR = 14` in the test is the single constant bumped when a new Python is added. Task 7's verify script is pinned against the same list. + +- [ ] **Step 1: Write the failing invariant tests** + +`tests/test_ci_workflow.py`: + +```python +"""Pins the CI workflow's invariants (spec: docs/superpowers/specs/ +2026-10-04-modernization-design.md). CI drift shows up as a red test here.""" + +import pathlib +import re + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".gitea" / "workflows" / "ci.yaml" +VERIFY_SCRIPT = ROOT / "scripts" / "verify_index_release.sh" + +# Newest CPython the project claims. To add a version, bump this, the +# matrix, the classifiers and the verify script's list together. +NEWEST_MINOR = 14 + +WIP_CLAUSES = ( + "!startsWith(github.event.pull_request.title, 'WIP:')", + "!startsWith(github.event.pull_request.title, '[WIP]')", +) + + +def load(): + return yaml.safe_load(WORKFLOW.read_text()) + + +def triggers(wf): + # PyYAML follows YAML 1.1, where the bare key `on` parses as boolean True. + return wf["on"] if "on" in wf else wf[True] + + +def expected_versions(): + text = (ROOT / "pyproject.toml").read_text() + floor = int(re.search(r'requires-python\s*=\s*">=3\.(\d+)"', text).group(1)) + return ["3.%d" % minor for minor in range(floor, NEWEST_MINOR + 1)] + + +def flat(text): + return " ".join(text.split()) + + +def test_triggers(): + on = triggers(load()) + assert set(on["pull_request"]["types"]) == { + "opened", + "synchronize", + "reopened", + "edited", + } + assert on["push"]["branches"] == ["develop", "main"] + assert "workflow_dispatch" in on + + +def test_superseded_pr_runs_cancel_but_push_runs_never_do(): + assert ( + load()["concurrency"]["cancel-in-progress"] + == "${{ github.event_name == 'pull_request' }}" + ) + + +def test_every_job_is_wip_gated(): + for name, job in load()["jobs"].items(): + cond = flat(job.get("if", "")) + for clause in WIP_CLAUSES: + assert clause in cond, "%s lacks WIP gate clause %s" % (name, clause) + + +def test_matrix_matches_requires_python(): + test = load()["jobs"]["test"] + assert test["strategy"]["matrix"]["python"] == expected_versions() + assert test["strategy"]["fail-fast"] is False + + +def test_classifiers_match_matrix(): + text = (ROOT / "pyproject.toml").read_text() + claimed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text) + assert claimed == expected_versions() + + +def test_verify_script_covers_matrix(): + match = re.search(r"^pythons=\(([^)]*)\)", VERIFY_SCRIPT.read_text(), re.M) + assert match.group(1).split() == expected_versions() + + +def test_checkout_fetches_full_history_for_setuptools_scm(): + for name, job in load()["jobs"].items(): + for step in job.get("steps", []): + if step.get("uses", "").startswith("actions/checkout"): + assert step.get("with", {}).get("fetch-depth") == 0, name + + +def test_lint_job_runs_ruff(): + runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["lint"]["steps"])) + assert "uv run ruff check" in runs + assert "uv run ruff format --check" in runs + + +def test_test_lanes_run_locked_suite_and_smoke_test_both_artifacts(): + runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["test"]["steps"])) + assert "uv sync --locked" in runs + assert "uv run pytest" in runs + assert "uv build" in runs + assert "dist/*.whl dist/*.tar.gz" in runs + assert "scripts/smoke_import.py" in runs + + +def test_ci_ok_fails_unless_every_gate_succeeded(): + jobs = load()["jobs"] + ci_ok = jobs["ci-ok"] + # Gitea reports a skipped job as success, so ci-ok must run always() + # and check each result explicitly. + assert set(ci_ok["needs"]) == set(jobs) - {"ci-ok"} + assert flat(ci_ok["if"]).startswith("always() &&") + runs = " ".join(s.get("run", "") for s in ci_ok["steps"]) + for needed in ci_ok["needs"]: + assert 'test "${{ needs.%s.result }}" = success' % needed in runs +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run pytest tests/test_ci_workflow.py -q` +Expected: FAIL, with `FileNotFoundError` for `.gitea/workflows/ci.yaml`. + +- [ ] **Step 3: Write `.gitea/workflows/ci.yaml`** + +```yaml +name: CI +on: + pull_request: + types: [opened, synchronize, reopened, edited] # edited: un-WIP starts CI + push: + branches: [develop, main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Cancel superseded PR runs. Never cancel a push run. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + runs-on: ubuntu-latest + container: python:3.14 + env: + UV_PYTHON_DOWNLOADS: never + steps: + - name: Install node (Gitea actions are node programs) + run: apt-get update && apt-get install -y --no-install-recommends nodejs + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: git config --global --add safe.directory "$PWD" + - run: pip install uv + - run: uv sync --locked + - run: uv run ruff check + - run: uv run ruff format --check + + test: + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + runs-on: ubuntu-latest + container: python:${{ matrix.python }} + strategy: + fail-fast: false + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + env: + UV_PYTHON: ${{ matrix.python }} + UV_PYTHON_DOWNLOADS: never + steps: + - name: Install node (Gitea actions are node programs) + run: apt-get update && apt-get install -y --no-install-recommends nodejs + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # setuptools_scm needs tags + - run: git config --global --add safe.directory "$PWD" + - run: pip install uv + - run: uv sync --locked + - run: uv run pytest + - name: Build, install and smoke-test the wheel and the sdist + run: | + rm -rf dist && uv build + for dist in dist/*.whl dist/*.tar.gz; do + rm -rf /tmp/smoke && uv venv /tmp/smoke + uv pip install --python /tmp/smoke "$dist" + (cd / && /tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/smoke_import.py") + done + + ci-ok: + needs: [lint, test] + # always(): a plain needs: aggregate would be *skipped* when a gate fails, + # and Gitea reports skipped as success. The WIP gate stays. + if: >- + always() && ( + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) + ) + runs-on: ubuntu-latest + steps: + - name: Require every gate to have succeeded + run: | + echo "lint=${{ needs.lint.result }} test=${{ needs.test.result }}" + test "${{ needs.lint.result }}" = success + test "${{ needs.test.result }}" = success +``` + +- [ ] **Step 4: Add the Python classifiers to `pyproject.toml`** + +In `classifiers`, after `"Programming Language :: Python :: 3",`, insert: + +```toml + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: POSIX :: Linux", +``` +and remove `"Operating System :: OS Independent"`. That classifier claims Windows and macOS, which nothing tests. + +- [ ] **Step 5: Run the invariant tests** + +Run: `uv run pytest tests/test_ci_workflow.py -q` +Expected: every test passes **except** `test_verify_script_covers_matrix`, which fails because the script lands in Task 7. Mark it so this PR is green on its own: + +```python +import pytest # add to the imports + + +@pytest.mark.skipif(not VERIFY_SCRIPT.exists(), reason="added with the release task") +def test_verify_script_covers_matrix(): + ... +``` +Run again. Expected: all pass, 1 skipped. + +- [ ] **Step 6: Run every gate locally on every lane before CI sees it** + +```bash +uv run ruff check && uv run ruff format --check +uv run --python 3.8 pytest -q +uv run --python 3.9 pytest -q +uv run --python 3.10 pytest -q +uv run --python 3.11 pytest -q +uv run --python 3.12 pytest -q +uv run --python 3.13 pytest -q +uv run --python 3.14 pytest -q +``` +Expected: lint clean, and every lane `N passed, 1 skipped`. Also run Task 4 step 5 with `--python 3.14`, which repeats the smoke step on the newest lane. + +- [ ] **Step 7: Docs** + +`README.md`: right under the feature list, add: + +```markdown +**Tested:** Linux, CPython 3.8–3.14 (Gitea CI on every PR). Windows and macOS: goals, untested. +``` + +`CLAUDE.md`: add a section after `## Python support policy`: + +```markdown +## CI + +`.gitea/workflows/ci.yaml` runs on PRs and on pushes to `develop`/`main`. PRs titled `WIP:` are skipped; removing the prefix starts the run. +- `lint`: ruff. +- `test`: a matrix over CPython 3.8–3.14 in `python:` containers. Each lane runs the locked suite, then builds the wheel and sdist, installs each into a clean venv, and runs `scripts/smoke_import.py` from outside the source tree. +- `ci-ok`: the only required check (`CI / ci-ok (pull_request)`). It uses `if: always()` and fails unless every gate succeeded, because Gitea reports a skipped job as success. + +`tests/test_ci_workflow.py` pins these invariants. To add or drop a Python version, change all four together: the matrix, the classifiers, `scripts/verify_index_release.sh`'s list, and `NEWEST_MINOR` (or `requires-python`). +``` + +- [ ] **Step 8: Commit and push** + +```bash +git add tests/test_ci_workflow.py .gitea/workflows/ci.yaml pyproject.toml README.md CLAUDE.md +git commit -m "ci: Gitea checks — ruff, CPython 3.8–3.14 matrix, wheel+sdist smoke tests, ci-ok aggregate + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push +``` + +### Task 6: Roll out and prove each gate bites + +**Files:** none permanent. Canary commits are reverted with additive revert commits; no force-push. + +- [ ] **Step 1: Start the real run** + +Remove the `WIP: ` prefix from the PR title (Mathew, or `tea pulls edit` if golem's grant allows it; it got a 403 on closing PRs before, so expect to hand this to Mathew). Watch the run: `tea actions runs --login zavage --repo Zavage-Software/app_skellington`, then `tea actions runs view …`. +Expected: `lint` and all 7 `test` lanes succeed, and `ci-ok` succeeds. +- If `python:3.8` or `python:3.9` fails for image reasons (apt, nodejs), apply the policy and **don't build workarounds**. Report it to Mathew and propose dropping the lane: raise `requires-python`, update the matrix, the classifiers and the verify list. + +- [ ] **Step 2: Lint canary** + +Add `import json` as an unused import at the top of `app_skellington/_util.py` (F401). Don't use `os`: the file already imports it, so the canary would raise a different error or none. Run `uv run ruff check` locally first and confirm it reports F401. Commit with `--no-verify` (the pre-commit hook would catch it first) and push. +Expected: `lint` fails, every `test` lane passes, and **`CI / ci-ok (pull_request)` reports failure**. Then `git revert HEAD --no-edit && git push`, and expect everything green. + +- [ ] **Step 3: Test canary** + +Append to `tests/cfg/test_cfg.py`: +```python +def test_canary_must_fail(): + assert False +``` +Commit and push. +Expected: `lint` passes, all 7 `test` lanes fail, and **`ci-ok` reports failure**. Then `git revert HEAD --no-edit && git push`, and expect green. + +- [ ] **Step 4: Record the results in a PR comment** + +Post the run IDs for green → lint-red → green → test-red → green, and confirm that `ci-ok` went red in both canaries. Note whether `if: always()` and `needs..result` evaluated as expected on this act_runner; the pattern doc had never proven that. + +- [ ] **Step 5: Branch protection (Mathew, Gitea UI)** + +For `develop` and `main`: branch rule → **Enable Status Check** → type exactly `CI / ci-ok (pull_request)`. Require only that context. + +- [ ] **Step 6: Wrap up** + +Run `/branch-wrapup` and hand over. **Wait for the merge** before Task 7. + +--- + +## Release 0.2.3 + +### Task 7: Release tooling and changelog (PR `release/0.2.3-prep`) + +**Files:** +- Create: `scripts/verify_index_release.sh` +- Modify: `tests/test_ci_workflow.py` (remove the skipif) +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: `scripts/smoke_import.py [EXPECTED_VERSION]` (Task 4). +- Produces: `scripts/verify_index_release.sh VERSION [INDEX_URL]`. `INDEX_URL` defaults to TestPyPI. Exit 0 means every matrix Python installed and smoke-tested that exact version. + +- [ ] **Step 1: Cut the branch** (`release/0.2.3-prep` from the latest `origin/develop`, same worktree recipe) + +- [ ] **Step 2: Make the pinning test strict (failing test)** + +Delete the `@pytest.mark.skipif(...)` decorator from `test_verify_script_covers_matrix`, and delete the `import pytest` line Task 5 added. Nothing else uses it, and ruff's F401 would fail lint. +Run: `uv run pytest tests/test_ci_workflow.py::test_verify_script_covers_matrix -q` +Expected: FAIL (`FileNotFoundError` for `scripts/verify_index_release.sh`). + +- [ ] **Step 3: Write `scripts/verify_index_release.sh`** + +```bash +#!/usr/bin/env bash +# Install a published app_skellington from a package index into a clean venv +# for every supported Python, then run scripts/smoke_import.py against it. +# +# Usage: scripts/verify_index_release.sh VERSION [INDEX_URL] +# INDEX_URL defaults to TestPyPI. Dependencies always come from PyPI, +# because TestPyPI hosts junk uploads under real dependency names. +set -euo pipefail + +version="${1:?usage: $0 VERSION [INDEX_URL]}" +index="${2:-https://test.pypi.org/simple/}" +here="$(cd "$(dirname "$0")" && pwd)" +pythons=(3.8 3.9 3.10 3.11 3.12 3.13 3.14) + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +for py in "${pythons[@]}"; do + venv="$work/$py" + uv venv -q --python "$py" "$venv" + uv pip install -q --python "$venv" --no-cache --no-deps \ + --index-url "$index" "app_skellington==$version" + "$venv/bin/python" -c 'import importlib.metadata as m; print("\n".join(m.requires("app_skellington") or []))' \ + > "$work/requirements.txt" + uv pip install -q --python "$venv" -r "$work/requirements.txt" + (cd / && "$venv/bin/python" "$here/smoke_import.py" "$version") +done + +echo "verified app_skellington==$version from $index on: ${pythons[*]}" +``` + +```bash +chmod +x scripts/verify_index_release.sh +``` + +- [ ] **Step 4: Run the pin test (PASS), then prove the script catches 0.2.2 (expected FAIL)** + +Run: `uv run pytest -q` +Expected: everything passes, and nothing is skipped. + +Run: `scripts/verify_index_release.sh 0.2.2 https://pypi.org/simple/` +Expected: non-zero exit on the first lane (3.8) with the `NoCommandSpecified ... circular import` ImportError. That shows the script would have blocked 0.2.2. + +- [ ] **Step 5: Changelog** + +At the top of `CHANGELOG.md`, under `# Changelog`, insert: + +```markdown +## [0.2.3] + +Hotfix. In 0.2.2, `import app_skellington` raised +`ImportError: cannot import name 'NoCommandSpecified' from partially initialized module` +(a circular import) for every install. + + * Fix the circular import between `cli` and `app_container`. The framework + exceptions now live in `_util`, and the public names are unchanged. + * Tested on Linux, CPython 3.8–3.14 (Gitea CI). Each version builds the wheel + and sdist, installs them and smoke-tests the import. + * Tooling: ruff replaces black/isort/flake8; uv with a committed lockfile. + * Metadata: license classifier corrected to MIT-0; Python version classifiers + added; "OS Independent" replaced by Linux (Windows and macOS untested). + * README: the debug variable is `APPSKELLINGTON_DEBUG`. +``` + +Also correct the wrong date on the existing entry: `## [0.2.2] (2020-07-19)` becomes `## [0.2.2] (2024-11-17)` (the PyPI upload date). + +- [ ] **Step 6: Commit, push, PR, wrap up** + +```bash +git add scripts/verify_index_release.sh tests/test_ci_workflow.py CHANGELOG.md +git commit -m "release: verify_index_release.sh and 0.2.3 changelog + +Co-Authored-By: Claude Opus 5.5 (1M context) " +git push -u origin release/0.2.3-prep +pr-flow open --title='release: 0.2.3 prep — index verification script, changelog' --body-file= +``` +Run `/branch-wrapup`. **Wait for the merge.** + +### Task 8: Promote, tag, TestPyPI, verify, PyPI (Mathew runs the gated steps) + +The publish steps use Mathew's tokens, and only he merges. Claude prepares and verifies; each step is marked with who runs it. + +- [ ] **Step 1 (Mathew):** Open and merge a `develop` → `main` PR once `CI / ci-ok (pull_request)` is green. Then `git switch main && git pull --ff-only`, and confirm the push run on `main` is green. + +- [ ] **Step 2 (Mathew):** Tag and push. +```bash +git tag -a v0.2.3 -m "0.2.3: fix import-time circular import in 0.2.2; CI-tested on CPython 3.8-3.14" +git push origin v0.2.3 +``` + +- [ ] **Step 3 (either):** Build from a clean checkout of the tag. +```bash +rm -rf $CLAUDE_JOB_DIR/tmp/rel && git clone -q --branch v0.2.3 https://git-repos.zavage.net/zavage-software/app_skellington.git $CLAUDE_JOB_DIR/tmp/rel +cd $CLAUDE_JOB_DIR/tmp/rel && uv build +``` + +- [ ] **Step 4 (either): Exact-version gate (Review Focus 4)** + +Run: `ls dist` +Expected: **exactly** `app_skellington-0.2.3-py3-none-any.whl` and `app_skellington-0.2.3.tar.gz`. Any `.dev`, `+g…` or `+d…` suffix means the tag or the tree is wrong. Stop and don't upload. + +- [ ] **Step 5 (Mathew):** `uv publish --publish-url https://test.pypi.org/legacy/ dist/*` (TestPyPI token). + +- [ ] **Step 6 (either):** `scripts/verify_index_release.sh 0.2.3` (run from the repo). +Expected: `verified app_skellington==0.2.3 from https://test.pypi.org/simple/ on: 3.8 3.9 3.10 3.11 3.12 3.13 3.14`. On any failure, stop: fix forward as 0.2.4, and never re-upload a version. + +- [ ] **Step 7 (Mathew):** `uv publish dist/*` (PyPI token). + +- [ ] **Step 8 (either):** `scripts/verify_index_release.sh 0.2.3 https://pypi.org/simple/` +Expected: the same `verified …` line against PyPI. + +- [ ] **Step 9 (Mathew, optional):** Yank 0.2.2 on PyPI (its project page → Manage → Releases → 0.2.2 → Yank), with the reason "import fails; use 0.2.3". + +- [ ] **Step 10 (Claude):** Sync `develop` with `main` if the promotion created a merge commit (`git fetch`, compare `origin/develop` against `origin/main`, and tell Mathew if a `main` → `develop` back-merge PR is needed). Then hand over the next plan (characterization tests + hardening, spec PR 4/5+).