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