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>
1107 lines
43 KiB
Markdown
1107 lines
43 KiB
Markdown
# 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 --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 --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 --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 --python 3.8 pytest -q
|
||
uv run --python 3.9 pytest -q
|
||
uv run --python 3.10 pytest -q
|
||
uv run --python 3.11 pytest -q
|
||
uv run --python 3.12 pytest -q
|
||
uv run --python 3.13 pytest -q
|
||
uv run --python 3.14 pytest -q
|
||
```
|
||
Expected: lint clean, and every lane `N passed, 1 skipped`. Also run Task 4 step 5 with `--python 3.14`, which repeats the smoke step on the newest lane.
|
||
|
||
- [ ] **Step 7: Docs**
|
||
|
||
`README.md`: right under the feature list, add:
|
||
|
||
```markdown
|
||
**Tested:** Linux, CPython 3.8–3.14 (Gitea CI on every PR). Windows and macOS: goals, untested.
|
||
```
|
||
|
||
`CLAUDE.md`: add a section after `## Python support policy`:
|
||
|
||
```markdown
|
||
## CI
|
||
|
||
`.gitea/workflows/ci.yaml` runs on PRs and on pushes to `develop`/`main`. PRs titled `WIP:` are skipped; removing the prefix starts the run.
|
||
- `lint`: ruff.
|
||
- `test`: a matrix over CPython 3.8–3.14 in `python:<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+).
|