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+).