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>
43 KiB
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:
- ruff replaces black, isort and flake8 (format-only diff).
- uv: dependency groups,
uv.lock, packaging cleanup, docs. - 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-okrequired 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 inpyproject.tomland the list inscripts/verify_index_release.shmust match it.tests/test_ci_workflow.pyenforces 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 fromvX.Y.Ztags. - Nothing is published to PyPI until CI is green on
mainand the TestPyPI artifact passesscripts/verify_index_release.shon 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 withpr-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.
- 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). - CI checkout without tags (a shallow clone). setuptools_scm would silently version the package
0.0.x. Pinned bysmoke_import.py's0.0check (Task 4) andtest_checkout_fetches_tags(Task 5). - Importing with
APPSKELLINGTON_DEBUG=1. That runs a separate code path in_bootstrap.py. Pinned by the subprocess check insmoke_import.py(Task 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). - 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. Rungit fetch && git log origin/develop --oneline -3, and you should see the docs commits. uvandteaare 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'slintjob runs asuv run ruff checkanduv run ruff format --check. The pinnedruff==0.16.10in the dev deps must match the pre-commitrev: 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.yamland 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 committeduv.lock, and[tool.pytest.ini_options] testpaths = ["tests"]. PR 3's CI runsuv sync --lockedanduv 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 passedon both Pythons -
ruff clean
-
git statuslists only this task's intended changes._version.pymust 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.pynext to the 7 modules, and nothing fromtests/ -
the sdist contains
_version.pyandPKG-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,# Developmentand# Versionsections inREADME.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.mdusage
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 printssmoke ok: <version> <path>. It's used by the CI test lanes (Task 5) and byscripts/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 = 14in 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.8orpython:3.9fails for image reasons (apt, nodejs), apply the policy and don't build workarounds. Report it to Mathew and propose dropping the lane: raiserequires-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_URLdefaults to TestPyPI. Exit 0 means every matrix Python installed and smoke-tested that exact version. -
Step 1: Cut the branch (
release/0.2.3-prepfrom the latestorigin/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→mainPR onceCI / ci-ok (pull_request)is green. Thengit switch main && git pull --ff-only, and confirm the push run onmainis 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 sameverified …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
developwithmainif the promotion created a merge commit (git fetch, compareorigin/developagainstorigin/main, and tell Mathew if amain→developback-merge PR is needed). Then hand over the next plan (characterization tests + hardening, spec PR 4/5+).