Files
app_skellington/docs/superpowers/plans/2026-10-04-modernization-hotfix-track.md
T
golemandClaude Opus 5.5 bca4c38f98 style: adopt ruff (format + lint) at 88, replacing black/isort/flake8
Format-only: Python files are AST-identical apart from import order.
pre-commit end-of-file/trailing-whitespace fixes applied repo-wide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:39:30 -06:00

43 KiB
Raw Blame History

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

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:

[project.optional-dependencies]
dev = [
    "ruff==0.16.10",
    "pre-commit",
]

Append:

[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
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
git rm .flake8
  • Step 5: Apply the formatter and the safe fixes
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.

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:

- **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
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

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:

[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
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
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:

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

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

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:

# 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 --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:

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 --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:

- **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)
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
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

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
"""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)
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
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:

"""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
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:

    "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:

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
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:

**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:

## 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
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:

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
#!/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[*]}"
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:

## [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
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.

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