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 ty to the official astral-sh/ty-pre-commit hook (kept in the type-check group too, per my call to single-source the version) and added astral-sh/uv-pre-commit’s uv-lock hook.
  • Python 3.14 + single-sourcing. Added 3.14, but the real fix was single-sourcing the supported-version list in extensions/context.py (a copier ContextHook) 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 failed uv sync against requires-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 docs sphinx-build -W job, ruff ruff-check), an expanded test-template CI (multi-combo build matrix + a copier update migration test), and .editorconfig.
  • Docs + release automation. README updates, a root CHANGELOG.md, a root CLAUDE.md (repo-level agent guidance, distinct from the generated-project one under template/), and an automated release pipeline.

What was striking

  • The testing-gaps work uncovered a genuine bug. Building the copier update migration test exposed that the bootstrap _tasks (git init/commit, uv sync) were re-running on every copier update, and the failing re-run of git commit aborted the run before the pyproject-preservation after-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@v8 doesn’t resolve - upstream ships full v8.x tags but no moving v8 major tag (highest moving major is v7); pinned v8.2.0. (2) The migration test recorded _src_path: . from a relative copier copy ., so copier update resolved the template against the wrong dir; needed $GITHUB_WORKSPACE. prek’s check-github-workflows validates 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_TOKEN do 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 (or ctt, 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 the github-actions[bot] identity and adding a workflow_dispatch recovery trigger; dispatched it to finish cutting v1.4.1.

Top 3 next

  1. 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.
  2. Optional config-polish bucket I deferred (user kept the deps): ruff rule additions (DTZ/LOG/TC/…), exclude_also coverage, GPL-3.0 GPL-3.0-or-later SPDX, dropping dead check-dependabot/check-readthedocs hooks.
  3. 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).

Experience using UV