2026-06-29 Python Project Template Modernization and Release Automation
Context: Working on the python-project-template copier template (github.com/achhina/python-project-template). Started as the routine “it’s been ~2 months, check for updates” sweep and turned into a full modernization + release-automation build. Shipped v1.4.0 and v1.4.1 across PRs 1-4.
What I set out to do
Bimonthly maintenance: check the template’s pinned tooling for updates and apply them.
What I actually did
Far more than a pin bump. The arc:
- Tooling refresh. Bumped every pre-commit hook + GitHub Action to latest (pre-commit-hooks v6, ruff v0.15.20, mdformat 1.0, yamlfmt v0.21, codespell, commitizen, check-jsonschema; checkout v7, codecov v7). Switched
tyto the officialastral-sh/ty-pre-commithook (kept in thetype-checkgroup too, per my call to single-source the version) and addedastral-sh/uv-pre-commit’suv-lockhook. - Python 3.14 + single-sourcing. Added 3.14, but the real fix was single-sourcing the supported-version list in
extensions/context.py(a copierContextHook) so the classifiers and the CI matrix derive from one place. This fixed a real latent defect: the CI matrix was hardcoded[3.12, 3.13], so a project whose minimum was 3.13 spawned a 3.12 job that faileduv syncagainstrequires-python >=3.13- red CI on the first push for every such generated project. - Audit via parallel subagents. Fanned out three read-only agents (packaging config / hooks+CI / structure+gaps), then synthesized. Drove CI hardening (least-privilege
permissions,concurrency,codecov.yml, a docssphinx-build -Wjob,ruff→ruff-check), an expandedtest-templateCI (multi-combo build matrix + acopier updatemigration test), and.editorconfig. - Docs + release automation. README updates, a root
CHANGELOG.md, a rootCLAUDE.md(repo-level agent guidance, distinct from the generated-project one undertemplate/), and an automated release pipeline.
What was striking
- The testing-gaps work uncovered a genuine bug. Building the
copier updatemigration test exposed that the bootstrap_tasks(git init/commit,uv sync) were re-running on everycopier update, and the failing re-run ofgit commitaborted the run before the pyproject-preservationafter-migration could restore user-owned deps - silently clobbering[project.dependencies]. Fixed by gating those tasks to_copier_operation == 'copy'. A test I added to be thorough found a real data-loss bug. - CI caught two things local testing structurally couldn’t. (1)
astral-sh/setup-uv@v8doesn’t resolve - upstream ships fullv8.xtags but no movingv8major tag (highest moving major isv7); pinnedv8.2.0. (2) The migration test recorded_src_path: .from a relativecopier copy ., socopier updateresolved the template against the wrong dir; needed$GITHUB_WORKSPACE. prek’scheck-github-workflowsvalidates schema, not whether an action ref exists on GitHub - so only the real CI round-trip surfaced these. Reinforced watching CI after every push, not just trusting local green. - The release-blocking design is clean because of one fact: tags/releases created with
GITHUB_TOKENdo not trigger workflows. So guard workflows (block-manual-tags,block-manual-releases) only ever fire on human actions - they delete-and-fail on manual tags/releases while the automation’s own tag/release sails through untouched. No ruleset needed for the reactive layer. - Native tag rulesets need GitHub Pro or a public repo - this repo is private free-tier, and GitHub also removed the old tag-protection API. So the hard push-time block was deferred; the guards cover it reactively. Worth remembering for any private-repo enforcement ask.
copier copy .uses the latest git tag, not the working tree. Bit me repeatedly when “verifying” changes;--vcs-ref HEAD(orctt, which renders the working tree) is required to test uncommitted template changes.- The release pipeline’s first live run failed at
git tag -a(empty ident name not allowed- runners have no git identity). Fixed by configuring thegithub-actions[bot]identity and adding aworkflow_dispatchrecovery trigger; dispatched it to finish cutting v1.4.1.
Top 3 next
- If the repo ever goes public or Pro, add the native tag-creation ruleset (bypass = GitHub Actions app id 15368) on top of the guards for hard push-time blocking.
- Optional config-polish bucket I deferred (user kept the deps): ruff rule additions (DTZ/LOG/TC/…),
exclude_alsocoverage, GPL-3.0 → GPL-3.0-or-later SPDX, dropping deadcheck-dependabot/check-readthedocshooks. - Watch the next real version-bump PR merge auto-release end-to-end (the identity bug is fixed, so it should no longer need the manual dispatch).