Files
app_skellington/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md
golemandClaude Opus 5.5 bee5a691d9 doc: use uv run --isolated for other-Python test runs
A bare `uv run --python 3.8` rebuilds the project .venv on 3.8, after
which the pre-commit hook (pre-commit-hooks needs >=3.9) fails on commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:27:50 -06:00

1107 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<name> -b <branch> 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) <noreply@anthropic.com>"
git push -u origin style/ruff
pr-flow open --title='style: adopt ruff at 88, replacing black/isort/flake8' --body-file=<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 --isolated --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) <noreply@anthropic.com>"
git push -u origin build/uv
pr-flow open --title='build: uv dependency groups, lockfile, packaging cleanup' --body-file=<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 "<keyword>"
uv run --isolated --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: <summary>"
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 "<test keyword>"
Run test by directory:
uv run pytest tests/<dirname>
```
- [ ] **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 "<keyword>"
uv run --isolated --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) <noreply@anthropic.com>"
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: <version> <path>`. 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) <noreply@anthropic.com>"
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=<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 --isolated --python 3.8 pytest -q
uv run --isolated --python 3.9 pytest -q
uv run --isolated --python 3.10 pytest -q
uv run --isolated --python 3.11 pytest -q
uv run --isolated --python 3.12 pytest -q
uv run --isolated --python 3.13 pytest -q
uv run --isolated --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:<v>` 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) <noreply@anthropic.com>"
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 <id> …`.
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.<job>.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) <noreply@anthropic.com>"
git push -u origin release/0.2.3-prep
pr-flow open --title='release: 0.2.3 prep — index verification script, changelog' --body-file=<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+).