From f15897aa5b7191869358bb547edf987f23d40164 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 21:17:49 +1000 Subject: [PATCH 1/7] Build libraries with uv_build, with the version set once in pyproject.toml hatchling read the version from __init__.py, which `uv version` can't read or bump. A static [project] version is what uv manages, so libraries keep version = "0.1.0" in pyproject.toml, build with uv_build (which takes src// with no further config), and read __version__ back from the installed package metadata. scriv reads pyproject.toml for every kind. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- copier.yml | 2 +- template/pyproject.toml.jinja | 25 ++++++------------- .../src/{{ package_name }}/__init__.py.jinja | 9 ++++++- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4a61e44..7dc8302 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ standard so the repos don't drift: - **uv** for dependency management (`uv sync`, `uv run`), pinned Python via `.python-version` -- **hatchling** build backend for libraries; apps stay `package = false` +- **uv_build** build backend for libraries, with the version set once in + `pyproject.toml` and bumped by `uv version`; apps stay `package = false` - **pre-commit** stack: ruff (lint + format), [zizmor](https://docs.zizmor.sh/) (Actions security), mdformat, optionally typos — plus **[basedpyright](https://docs.basedpyright.com/)** (always) and **pytest** diff --git a/copier.yml b/copier.yml index 0db8af0..1e77339 100644 --- a/copier.yml +++ b/copier.yml @@ -42,7 +42,7 @@ project_kind: help: An installable library, or a standalone app you run via `uv run`? choices: "Application (not packaged; run via `uv run`)": app - "Library (packaged with hatchling, publishable to PyPI)": library + "Library (packaged with uv_build, publishable to PyPI)": library default: app publish_to_pypi: diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index b887ba6..68625fa 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -1,16 +1,15 @@ {% if project_kind == "library" -%} [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +# uv's own backend. It builds src/{{ package_name }}/ with no further config. +# Keep the upper bound at the next uv minor, as `uv init` writes it, and raise +# both bounds when moving to a new uv minor. +requires = ["uv_build>=0.12.22,<0.13.0"] +build-backend = "uv_build" {% endif -%} [project] name = "{{ project_name }}" -{% if project_kind == "library" -%} -dynamic = ["version"] -{% else -%} version = "0.1.0" -{% endif -%} description = "{{ project_description }}" readme = "README.md" license = "{{ 'LicenseRef-Proprietary' if license == 'Proprietary' else license }}" @@ -32,13 +31,7 @@ dev = [ "scriv>=1.8", ] -{% if project_kind == "library" -%} -[tool.hatch.version] -path = "src/{{ package_name }}/__init__.py" - -[tool.hatch.build.targets.wheel] -packages = ["src/{{ package_name }}"] -{% else -%} +{% if project_kind == "app" -%} [tool.uv] package = false {% endif %} @@ -57,11 +50,9 @@ md_html_anchors = false # Without this, two fragments in one category leave a blank line between them, # which makes the list loose and fails mdformat on the release commit. compact_fragments = true -{% if project_kind == "library" %} -version = "literal: src/{{ package_name }}/__init__.py: __version__" -{% else %} +# The version's single source is [project] version above, which +# `uv version --bump` (and the Prepare release workflow) rewrites. version = "literal: pyproject.toml: project.version" -{% endif %} [tool.ruff] line-length = 100 diff --git a/template/src/{{ package_name }}/__init__.py.jinja b/template/src/{{ package_name }}/__init__.py.jinja index ae8dadf..9475101 100644 --- a/template/src/{{ package_name }}/__init__.py.jinja +++ b/template/src/{{ package_name }}/__init__.py.jinja @@ -1,5 +1,12 @@ """{{ project_description }}""" {% if project_kind == "library" %} -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +# The version is set once, in pyproject.toml; this reads it back from the +# installed package metadata. +try: + __version__ = version("{{ project_name }}") +except PackageNotFoundError: # a source tree that was never installed + __version__ = "unknown" {% endif %} From 7d1d060d7d4b717e16cdde85367b606ae849348d Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 21:17:49 +1000 Subject: [PATCH 2/7] Let CI and publish run on dispatch, and publish only a matching tag A pull request or tag created with the default Actions token starts no workflows, so the release workflows start ci.yml and publish.yml explicitly. A dispatched publish still runs as publish.yml, which is what PyPI trusted publishing checks; it can't run as a reusable workflow. publish.yml now refuses any ref but a v* tag and checks the tag against the built wheel's version. Co-Authored-By: Claude Opus 5.5 --- template/.github/workflows/ci.yml.jinja | 4 +++ ...ish_to_pypi %}publish.yml{% endif %}.jinja | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/template/.github/workflows/ci.yml.jinja b/template/.github/workflows/ci.yml.jinja index b8c9cdf..3ad8208 100644 --- a/template/.github/workflows/ci.yml.jinja +++ b/template/.github/workflows/ci.yml.jinja @@ -5,6 +5,10 @@ on: branches: - main pull_request: + # The Prepare release workflow starts CI on its release branch this way: a + # pull request opened with the default Actions token triggers no workflows, + # so its required checks would otherwise never report. + workflow_dispatch: permissions: contents: read diff --git a/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja b/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja index 3cb93b8..8f6342a 100644 --- a/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja @@ -4,6 +4,11 @@ on: push: tags: - "v*" + # Started by release-on-merge.yml on the tag it creates. A tag created with + # the default Actions token triggers no workflows, and PyPI trusted + # publishing can't run from a reusable workflow, so this one is dispatched + # and still runs as publish.yml, the workflow the publisher names. + workflow_dispatch: permissions: contents: read @@ -29,8 +34,32 @@ jobs: python-version: "{{ python_version }}" enable-cache: false # no caching in the privileged publish job (cache-poisoning surface) + - name: Refuse anything but a version tag + run: | + case "$GITHUB_REF" in + refs/tags/v*) ;; + *) + echo "::error::publish runs only on a v* tag, not ${GITHUB_REF}" >&2 + exit 1 + ;; + esac + - name: Build sdist + wheel run: uv build + - name: Check the tag matches the package version + run: | + shopt -s nullglob + set -- dist/*.whl + if [ "$#" -ne 1 ]; then + echo "::error::expected one wheel in dist/, found $#" >&2 + exit 1 + fi + pkg_version=$(basename "$1" | cut -d- -f2) + if [ "$pkg_version" != "${GITHUB_REF_NAME#v}" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match package version ${pkg_version}" >&2 + exit 1 + fi + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 From aab78c5229dd12e3dc712829064801ffb173eee1 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 21:17:49 +1000 Subject: [PATCH 3/7] Add Prepare release and release-on-merge workflows Prepare release (run from the Actions tab) bumps the version with `uv version --bump`, collects changelog.d/ with scriv, opens a "Release vX.Y.Z" pull request, and starts CI on it. Its auto bump picks minor for Added/Changed/Deprecated/Removed and patch for Fixed/Security, never major. Merging the pull request runs release-on-merge, which checks the version, tags the merge commit, creates the GitHub release from the changelog section, and starts publish.yml for PyPI projects. Co-Authored-By: Claude Opus 5.5 --- .../workflows/prepare-release.yml.jinja | 129 ++++++++++++++++++ .../workflows/release-on-merge.yml.jinja | 78 +++++++++++ 2 files changed, 207 insertions(+) create mode 100644 template/.github/workflows/prepare-release.yml.jinja create mode 100644 template/.github/workflows/release-on-merge.yml.jinja diff --git a/template/.github/workflows/prepare-release.yml.jinja b/template/.github/workflows/prepare-release.yml.jinja new file mode 100644 index 0000000..9c90246 --- /dev/null +++ b/template/.github/workflows/prepare-release.yml.jinja @@ -0,0 +1,129 @@ +{% raw %}name: Prepare release + +# Run this from the Actions tab to cut a release. It bumps the version in +# pyproject.toml, collects the changelog.d/ fragments into CHANGELOG.md, and +# opens a "Release vX.Y.Z" pull request. Merging that PR tags the release (see +# release-on-merge.yml). +# +# One-time setup: Settings > Actions > General > "Allow GitHub Actions to +# create and approve pull requests" must be on, or opening the PR fails. + +on: + workflow_dispatch: + inputs: + bump: + description: >- + Version bump. auto picks minor when a fragment adds, changes, + deprecates or removes something, and patch when fragments only fix. + auto never picks major. + type: choice + default: auto + options: + - auto + - patch + - minor + - major + +permissions: + contents: read + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + name: Open the release pull request + # Releases are cut from the default branch only. + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + permissions: + contents: write # push the release branch + pull-requests: write # open the release pull request + actions: write # start CI on the release branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "{% endraw %}{{ python_version }}{% raw %}" + enable-cache: false # no caching in a job that can push (cache-poisoning surface) + + - name: Choose the version bump + id: bump + env: + BUMP: ${{ inputs.bump }} + run: | + python3 - <<'PY' + import os + import re + import sys + from pathlib import Path + + fragments = [ + p for p in sorted(Path("changelog.d").glob("*.md")) + if not p.name[0].isupper() # TEMPLATE.md, README.md + ] + sections = set() + for path in fragments: + text = re.sub(r"", "", path.read_text(encoding="utf-8"), flags=re.S) + sections |= {m.strip() for m in re.findall(r"^### (.+)$", text, flags=re.M)} + if not sections: + sys.exit("::error::changelog.d/ has no fragments with entries, so there is nothing to release.") + + bump = os.environ["BUMP"] + if bump == "auto": + minor = {"Added", "Changed", "Deprecated", "Removed"} + bump = "minor" if sections & minor else "patch" + print(f"Sections: {', '.join(sorted(sections))}. Bump: {bump}.") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + out.write(f"bump={bump}\n") + PY + + - name: Bump the version and collect the changelog + id: version + env: + BUMP: ${{ steps.bump.outputs.bump }} + run: | + set -euo pipefail + uv version --bump "$BUMP" + uv run --group dev scriv collect + # A first mdformat pass may tidy the collected section; the second + # must then pass. + uv run --group dev pre-commit run mdformat --files CHANGELOG.md \ + || uv run --group dev pre-commit run mdformat --files CHANGELOG.md + echo "version=$(uv version --short)" >> "$GITHUB_OUTPUT" + + - name: Push the release branch and open the pull request + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + BASE: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + branch="release/v${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add pyproject.toml uv.lock CHANGELOG.md changelog.d + git commit -m "Release v${VERSION}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/${branch}" + + awk -v v="$VERSION" ' + index($0, "## [" v "]") == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md > notes.md + { + echo "Merging this pull request tags v${VERSION} and creates its GitHub release." + echo + cat notes.md + } > body.md + gh pr create --base "$BASE" --head "$branch" --title "Release v${VERSION}" --body-file body.md + + # A pull request opened with the default token starts no workflows, + # so start CI on the branch; its checks count for the pull request. + gh workflow run ci.yml --ref "$branch" +{% endraw %} diff --git a/template/.github/workflows/release-on-merge.yml.jinja b/template/.github/workflows/release-on-merge.yml.jinja new file mode 100644 index 0000000..498020c --- /dev/null +++ b/template/.github/workflows/release-on-merge.yml.jinja @@ -0,0 +1,78 @@ +{% raw %}name: Release on merge + +# When a "Release vX.Y.Z" pull request from prepare-release.yml merges, tag the +# merge commit vX.Y.Z and create its GitHub release from the CHANGELOG.md +# section{% endraw %}{% if publish_to_pypi %}, then start publish.yml to upload it to PyPI{% endif %}{% raw %}. + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: read + +concurrency: + group: release-on-merge + cancel-in-progress: false + +jobs: + release: + name: Tag and release + if: >- + github.event.pull_request.merged + && startsWith(github.event.pull_request.head.ref, 'release/v') + && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: write # create the tag and the release +{% endraw %}{% if publish_to_pypi %} actions: write # start publish.yml +{% endif %}{% raw %} steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + persist-credentials: false + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: false # no caching in a job that can write (cache-poisoning surface) + + - name: Check the branch and pyproject.toml agree on the version + id: version + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + version="${HEAD_REF#release/v}" + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::release branch ${HEAD_REF} does not name a version X.Y.Z" >&2 + exit 1 + fi + pkg_version=$(uv version --short --frozen) + if [ "$pkg_version" != "$version" ]; then + echo "::error::branch says ${version} but pyproject.toml at the merge commit says ${pkg_version}" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Tag the merge commit and create the release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + awk -v v="$VERSION" ' + index($0, "## [" v "]") == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md > notes.md + gh release create "v${VERSION}" --target "$SHA" --title "v${VERSION}" --notes-file notes.md +{% endraw %}{% if publish_to_pypi %} + + - name: Start publishing to PyPI + env: + GH_TOKEN: ${{ '{{' }} github.token {{ '}}' }} + VERSION: ${{ '{{' }} steps.version.outputs.version {{ '}}' }} + run: gh workflow run publish.yml --ref "v${VERSION}" +{% endif %} From 600567c3e3991652aeebf196e8bb47fd38b17b16 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 21:17:49 +1000 Subject: [PATCH 4/7] Document the release workflows and check them in template CI RELEASING.md and the scaffold README describe the Prepare release flow, its one-time Actions setting, and the manual fallback. The template CHANGELOG records the additions and the upgrade steps. Template CI parses the new workflows in both variants, checks the PyPI step appears only for libraries, and builds the library with uv_build. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/template-ci.yml | 16 ++++++++++++++++ CHANGELOG.md | 16 ++++++++++++++++ template/README.md.jinja | 2 +- ...blish_to_pypi %}RELEASING.md{% endif %}.jinja | 16 +++++++--------- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 619d1a7..eb74259 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -107,6 +107,22 @@ jobs: done echo "scriv collects fragments into CHANGELOG.md in both variants." + for kind in app library; do + out="/tmp/out-$kind" + for wf in prepare-release release-on-merge ci; do + python3 -c "import sys,yaml; yaml.safe_load(open(sys.argv[1]))" "$out/.github/workflows/$wf.yml" + done + grep -q '^ workflow_dispatch:' "$out/.github/workflows/ci.yml" + ! grep -q 'dynamic = \["version"\]' "$out/pyproject.toml" || exit 1 + done + grep -q 'gh workflow run publish.yml' /tmp/out-library/.github/workflows/release-on-merge.yml + ! grep -q 'publish.yml' /tmp/out-app/.github/workflows/release-on-merge.yml || exit 1 + grep -q '^ workflow_dispatch:' /tmp/out-library/.github/workflows/publish.yml + grep -q '^build-backend = "uv_build"' /tmp/out-library/pyproject.toml + # The library builds, and __version__ comes from its metadata. + (cd /tmp/out-library && uv build -q && uv run python -c "import sample_library as m; assert m.__version__ == '0.1.0', m.__version__") + echo "release workflows rendered; library builds with uv_build." + # use_typos defaults on, and answering no drops the hook uvx copier copy --trust --defaults --vcs-ref=HEAD \ --data project_name="sample-no-typos" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 882209e..3680010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,22 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, ## [Unreleased] +### Added + +- A **Prepare release** workflow (`prepare-release.yml`), run from the Actions tab. It bumps `version` in `pyproject.toml` with `uv version --bump`, collects `changelog.d/` into `CHANGELOG.md`, and opens a "Release vX.Y.Z" pull request. Its `auto` bump picks minor when a fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. A pull request opened with the default Actions token starts no workflows, so it starts CI on the release branch itself, and those checks satisfy the required check. `ci.yml` gains a `workflow_dispatch` trigger for this. +- `release-on-merge.yml`: when a `release/vX.Y.Z` pull request merges, it checks that `pyproject.toml` at the merge commit says X.Y.Z, tags the merge commit `vX.Y.Z`, and creates the GitHub release from the changelog section. For a project published to PyPI it then starts `publish.yml`. +- `publish.yml` takes a `workflow_dispatch` trigger, so `release-on-merge.yml` can start it. A tag created with the default token triggers no workflows, and PyPI trusted publishing can't run from a reusable workflow, so the dispatched run is still `publish.yml`, the workflow the trusted publisher names. It refuses any ref but a `v*` tag, and fails if the tag doesn't match the built wheel's version. + +### Changed + +- Libraries build with uv's own backend, `uv_build`, instead of hatchling, and keep a static `version` in `pyproject.toml`, so `uv version --bump` manages it. `__version__` reads it back from the installed package metadata (`"unknown"` in a source tree that was never installed). hatchling was there to read the version from `__init__.py`, which uv can't bump; with the version in `pyproject.toml` it isn't needed. `[tool.scriv]` reads the version from `pyproject.toml` for every project kind. + +### Upgrading + +- Turn on _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**, or Prepare release fails when it opens the pull request. The weekly template-update workflow needs the same setting. +- `copier update` will conflict on the libraries' `[build-system]`, `[project] version` and `__init__.py`. Resolve to the template's side: `uv_build`, a static `version`, and `__version__` from metadata. Check that the wheel's contents don't change: uv_build expects the package under `src//`, and any hatch build options (includes, excludes, force-include) need their `[tool.uv.build-backend]` equivalents. +- No PyPI change is needed. The trusted publisher still names `publish.yml`. A project that publishes from a differently named workflow should rename it to `publish.yml` and update the publisher. + ## [1.9.1] - 2026-10-02 Repairs 1.9.0, which was tagged before `CHANGELOG.md` had a `[1.9.0]` section. `bump-v1.yml` refuses a release it can't find described, so it left `v1` on 1.8.1 and the 1.9.0 tag lightweight. Consumers land on 1.9.1 rather than 1.9.0; the contents are the same bar this changelog. diff --git a/template/README.md.jinja b/template/README.md.jinja index 4ef0670..0d527ad 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -11,7 +11,7 @@ uv run pytest uv run basedpyright src ``` -Each pull request adds a changelog fragment rather than editing `CHANGELOG.md`, so concurrent PRs don't conflict. Run `uv run scriv create`, uncomment the sections that apply in the new file under `changelog.d/`, and commit it with the change, or delete it if the change needs no entry. At release time, bump the version{% if project_kind == 'app' %} in `pyproject.toml`{% endif %} and run `uv run scriv collect`, which writes the fragments into `CHANGELOG.md` under the new version and deletes them. +Each pull request adds a changelog fragment rather than editing `CHANGELOG.md`, so concurrent PRs don't conflict. Run `uv run scriv create`, uncomment the sections that apply in the new file under `changelog.d/`, and commit it with the change, or delete it if the change needs no entry. To release, run the **Prepare release** workflow from the Actions tab. It bumps `version` in `pyproject.toml`, collects the fragments into `CHANGELOG.md`, and opens a release pull request; merging it tags the release. It needs _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**. By hand, the same is `uv version --bump minor` and `uv run scriv collect`. Linting (ruff, [zizmor](https://docs.zizmor.sh/), mdformat) runs via [pre-commit](https://pre-commit.com); CI runs the same stack plus basedpyright and pytest via the shared [`python-ci`](https://github.com/{{ github_owner }}/python-project-template) reusable workflow. {% if publish_to_pypi %} diff --git a/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja b/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja index 9f92b0d..6a2d110 100644 --- a/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja +++ b/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja @@ -14,17 +14,15 @@ Publishing runs from GitHub Actions with no API tokens, via [trusted publishing] The first tagged release creates the PyPI project and converts the pending publisher into a normal one. +One more one-time step: _Settings → Actions → General_ → enable **Allow GitHub Actions to create and approve pull requests**, so the release workflow can open its pull request. + ## Each release -1. Bump `__version__` in `src/{{ package_name }}/__init__.py` (single source of truth — `pyproject.toml` reads it). -2. Run `uv run scriv collect`. It reads the new version, writes the fragments in `changelog.d/` into `CHANGELOG.md` under `## [X.Y.Z] - YYYY-MM-DD`, and deletes them. -3. Commit, then tag and push: - ```bash - git tag vX.Y.Z - git push origin main vX.Y.Z - ``` -4. The `publish.yml` workflow builds the sdist/wheel and uploads to PyPI. -5. Create a GitHub release from the tag, pasting the changelog section. +1. _Actions_ → **Prepare release** → _Run workflow_, choosing the bump. `auto` picks minor when a changelog fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. The workflow bumps `version` in `pyproject.toml`, collects `changelog.d/` into `CHANGELOG.md`, opens a **Release vX.Y.Z** pull request, and starts CI on it. +2. Review the pull request. Add a summary paragraph under the new heading if the release needs one. +3. Merge it. `release-on-merge.yml` tags the merge commit `vX.Y.Z`, creates the GitHub release from the changelog section, and starts `publish.yml`, which checks the tag against the built wheel and uploads to PyPI. + +To do the same by hand: `uv version --bump minor` (or `patch` / `major`), `uv run scriv collect`, open and merge a pull request, then `git tag vX.Y.Z && git push origin vX.Y.Z` on the merge commit. The tag push starts `publish.yml`. ## Sanity checks before tagging From c00283c525817fd43a56f9e340214452b4e51898 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 21:34:58 +1000 Subject: [PATCH 5/7] Ship LICENSE with uv_build, and keep a library's version across the update uv_build ships only the license files [project] license-files names, so MIT projects name LICENSE; hatchling included it by default. Updating a library across 1.10.0 merges the template's version = "0.1.0" into pyproject.toml with no conflict, silently resetting a released project. A copier migration now sets it back to the __version__ the project's __init__.py had at HEAD, which copier leaves untouched since it only updates a clean tree. Tested by updating a v1.9.1 library released at 0.4.2: pyproject.toml ends at 0.4.2. From the review on #6. Co-Authored-By: Claude Opus 5.5 --- copier.yml | 17 +++++++++++++++++ template/pyproject.toml.jinja | 3 +++ 2 files changed, 20 insertions(+) diff --git a/copier.yml b/copier.yml index 1e77339..e5df6ef 100644 --- a/copier.yml +++ b/copier.yml @@ -7,6 +7,23 @@ _min_copier_version: "9.0.0" _skip_if_exists: - CHANGELOG.md +# 1.10.0 moves a library's version from __init__.py to a static +# [project] version, and the template's value is 0.1.0. A plain 3-way merge +# applies that cleanly, silently resetting a released project to 0.1.0. After +# updating across 1.10.0, put back the version the project had: copier only +# updates a clean tree, so HEAD still holds the old __init__.py. +_migrations: + - version: v1.10.0 + when: "{{ _stage == 'after' and project_kind == 'library' }}" + command: >- + python3 -c "import re, subprocess, pathlib; + old = subprocess.run(['git', 'show', 'HEAD:src/{{ package_name }}/__init__.py'], capture_output=True, text=True).stdout; + m = re.search(r'^__version__ = [\x22\x27]([^\x22\x27]+)[\x22\x27]', old, re.M); + p = pathlib.Path('pyproject.toml'); s = p.read_text(); + new = re.sub(r'^version = \x220\.1\.0\x22$', 'version = \x22' + m.group(1) + '\x22', s, count=1, flags=re.M) if m else s; + p.write_text(new); + print('restored version ' + m.group(1) if m and new != s else 'version left as is')" + # Trim block-tag newlines so conditionals don't leave stray blank lines in # rendered text/markdown (keeps mdformat happy on a fresh scaffold). _envops: diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 68625fa..a11d3ed 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -13,6 +13,9 @@ version = "0.1.0" description = "{{ project_description }}" readme = "README.md" license = "{{ 'LicenseRef-Proprietary' if license == 'Proprietary' else license }}" +{% if license == "MIT" %} +license-files = ["LICENSE"] # uv_build ships only the license files named here +{% endif %} authors = [{ name = "{{ author_name }}", email = "{{ author_email }}" }] requires-python = ">={{ python_version }}" dependencies = [] From cfe5daca562dbe457c30eda588306fce589c0122 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 21:34:58 +1000 Subject: [PATCH 6/7] Let the release PR's own CI run, and make both release workflows re-runnable Checks from a dispatched run don't satisfy a required check, but a pull request opened with the default token does start its pull_request runs, in an approval-required state. So Prepare release no longer dispatches ci.yml or needs actions: write, and ci.yml drops its dispatch trigger; the PR body says to approve the runs. Prepare release now fails clearly when run from another branch, on a fragment with entries but no heading, on an existing tag, or with a release PR already open, and force-pushes its own branch so a re-run after a partial failure works. Release on merge checks the base against the default branch, creates the tag at the merge commit itself (refusing one already on another commit), and skips an existing tag or release on re-run. Release notes lose their leading blank line, and publish.yml takes the current checkout and setup-uv pins. From the review on #6. Co-Authored-By: Claude Opus 5.5 --- template/.github/workflows/ci.yml.jinja | 4 -- .../workflows/prepare-release.yml.jinja | 44 ++++++++++++++----- .../workflows/release-on-merge.yml.jinja | 31 ++++++++++--- ...ish_to_pypi %}publish.yml{% endif %}.jinja | 4 +- 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/template/.github/workflows/ci.yml.jinja b/template/.github/workflows/ci.yml.jinja index 3ad8208..b8c9cdf 100644 --- a/template/.github/workflows/ci.yml.jinja +++ b/template/.github/workflows/ci.yml.jinja @@ -5,10 +5,6 @@ on: branches: - main pull_request: - # The Prepare release workflow starts CI on its release branch this way: a - # pull request opened with the default Actions token triggers no workflows, - # so its required checks would otherwise never report. - workflow_dispatch: permissions: contents: read diff --git a/template/.github/workflows/prepare-release.yml.jinja b/template/.github/workflows/prepare-release.yml.jinja index 9c90246..db0a587 100644 --- a/template/.github/workflows/prepare-release.yml.jinja +++ b/template/.github/workflows/prepare-release.yml.jinja @@ -7,6 +7,11 @@ # # One-time setup: Settings > Actions > General > "Allow GitHub Actions to # create and approve pull requests" must be on, or opening the PR fails. +# +# CI on the release pull request waits for approval: a pull request opened +# with the default Actions token starts its workflows in an approval-required +# state. Open the pull request's Checks tab and click "Approve workflows to +# run". A re-run of this workflow after a failure is safe. on: workflow_dispatch: @@ -34,14 +39,21 @@ concurrency: jobs: prepare: name: Open the release pull request - # Releases are cut from the default branch only. - if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest permissions: contents: write # push the release branch pull-requests: write # open the release pull request - actions: write # start CI on the release branch steps: + - name: Refuse a run from another branch + env: + REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "$REF" != "refs/heads/${DEFAULT_BRANCH}" ]; then + echo "::error::run Prepare release from ${DEFAULT_BRANCH}, not ${REF}" >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -69,7 +81,10 @@ jobs: sections = set() for path in fragments: text = re.sub(r"", "", path.read_text(encoding="utf-8"), flags=re.S) - sections |= {m.strip() for m in re.findall(r"^### (.+)$", text, flags=re.M)} + found = {m.strip() for m in re.findall(r"^### (.+)$", text, flags=re.M)} + if text.strip() and not found: + sys.exit(f"::error::{path} has entries but no ### category heading.") + sections |= found if not sections: sys.exit("::error::changelog.d/ has no fragments with entries, so there is nothing to release.") @@ -104,26 +119,35 @@ jobs: run: | set -euo pipefail branch="release/v${VERSION}" + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${VERSION}" >/dev/null 2>&1; then + echo "::error::tag v${VERSION} already exists; bump further, or delete the tag if it is stale" >&2 + exit 1 + fi + open_prs=$(gh pr list --head "$branch" --state open --json number --jq length) + if [ "$open_prs" != "0" ]; then + echo "::error::a release pull request from ${branch} is already open" >&2 + exit 1 + fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -c "$branch" git add pyproject.toml uv.lock CHANGELOG.md changelog.d git commit -m "Release v${VERSION}" - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/${branch}" + # --force: a run that failed after pushing (for example, before the + # Actions setting was on) leaves this branch behind with no PR. + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/${branch}" awk -v v="$VERSION" ' index($0, "## [" v "]") == 1 { found = 1; next } found && /^## / { exit } - found { print } + found && (started || NF) { started = 1; print } ' CHANGELOG.md > notes.md { echo "Merging this pull request tags v${VERSION} and creates its GitHub release." echo + echo "CI waits for approval: open the Checks tab and click **Approve workflows to run**." + echo cat notes.md } > body.md gh pr create --base "$BASE" --head "$branch" --title "Release v${VERSION}" --body-file body.md - - # A pull request opened with the default token starts no workflows, - # so start CI on the branch; its checks count for the pull request. - gh workflow run ci.yml --ref "$branch" {% endraw %} diff --git a/template/.github/workflows/release-on-merge.yml.jinja b/template/.github/workflows/release-on-merge.yml.jinja index 498020c..2eee879 100644 --- a/template/.github/workflows/release-on-merge.yml.jinja +++ b/template/.github/workflows/release-on-merge.yml.jinja @@ -7,7 +7,6 @@ on: pull_request: types: [closed] - branches: [main] permissions: contents: read @@ -21,6 +20,7 @@ jobs: name: Tag and release if: >- github.event.pull_request.merged + && github.event.pull_request.base.ref == github.event.repository.default_branch && startsWith(github.event.pull_request.head.ref, 'release/v') && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest @@ -62,12 +62,29 @@ jobs: SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | set -euo pipefail - awk -v v="$VERSION" ' - index($0, "## [" v "]") == 1 { found = 1; next } - found && /^## / { exit } - found { print } - ' CHANGELOG.md > notes.md - gh release create "v${VERSION}" --target "$SHA" --title "v${VERSION}" --notes-file notes.md + tag="v${VERSION}" + # Safe to re-run: a tag already on this commit, or a release that + # already exists, is left alone. A tag on another commit is refused, + # since the release would silently attach to the old commit. + if tagged=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${tag}" --jq .sha 2>/dev/null); then + if [ "$tagged" != "$SHA" ]; then + echo "::error::tag ${tag} already exists on ${tagged}, not the merge commit ${SHA}" >&2 + exit 1 + fi + echo "tag ${tag} already on ${SHA}" + else + gh api "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/tags/${tag}" -f sha="$SHA" >/dev/null + fi + if gh release view "$tag" >/dev/null 2>&1; then + echo "release ${tag} already exists" + else + awk -v v="$VERSION" ' + index($0, "## [" v "]") == 1 { found = 1; next } + found && /^## / { exit } + found && (started || NF) { started = 1; print } + ' CHANGELOG.md > notes.md + gh release create "$tag" --verify-tag --title "$tag" --notes-file notes.md + fi {% endraw %}{% if publish_to_pypi %} - name: Start publishing to PyPI diff --git a/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja b/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja index 8f6342a..3aadc64 100644 --- a/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if publish_to_pypi %}publish.yml{% endif %}.jinja @@ -25,11 +25,11 @@ jobs: permissions: id-token: write # required for PyPI trusted publishing (OIDC, no API token) steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "{{ python_version }}" enable-cache: false # no caching in the privileged publish job (cache-poisoning surface) From 737cdd42eff553f57b6a577ddd0dbe7d18f89fa8 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 2 Oct 2026 21:34:58 +1000 Subject: [PATCH 7/7] Describe the approval step and the migration in the release docs RELEASING.md, the scaffold README and the template CHANGELOG say to click Approve workflows to run on the release PR, describe the re-run and tag safeguards, and replace the upgrade note's wrong conflict list with what the migration does. Template CI drops the ci.yml dispatch check and checks that LICENSE reaches the library's wheel. From the review on #6. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/template-ci.yml | 5 +++-- CHANGELOG.md | 11 ++++++----- template/README.md.jinja | 2 +- ...if publish_to_pypi %}RELEASING.md{% endif %}.jinja | 4 ++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index eb74259..7ee7494 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -109,10 +109,9 @@ jobs: for kind in app library; do out="/tmp/out-$kind" - for wf in prepare-release release-on-merge ci; do + for wf in prepare-release release-on-merge; do python3 -c "import sys,yaml; yaml.safe_load(open(sys.argv[1]))" "$out/.github/workflows/$wf.yml" done - grep -q '^ workflow_dispatch:' "$out/.github/workflows/ci.yml" ! grep -q 'dynamic = \["version"\]' "$out/pyproject.toml" || exit 1 done grep -q 'gh workflow run publish.yml' /tmp/out-library/.github/workflows/release-on-merge.yml @@ -121,6 +120,8 @@ jobs: grep -q '^build-backend = "uv_build"' /tmp/out-library/pyproject.toml # The library builds, and __version__ comes from its metadata. (cd /tmp/out-library && uv build -q && uv run python -c "import sample_library as m; assert m.__version__ == '0.1.0', m.__version__") + # uv_build ships LICENSE only when license-files names it. + python3 -c "import glob,sys,zipfile; n=zipfile.ZipFile(glob.glob('/tmp/out-library/dist/*.whl')[0]).namelist(); sys.exit(0 if any(x.endswith('licenses/LICENSE') for x in n) else 'LICENSE missing from the wheel')" echo "release workflows rendered; library builds with uv_build." # use_typos defaults on, and answering no drops the hook diff --git a/CHANGELOG.md b/CHANGELOG.md index 3680010..ee197a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,18 +16,19 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, ### Added -- A **Prepare release** workflow (`prepare-release.yml`), run from the Actions tab. It bumps `version` in `pyproject.toml` with `uv version --bump`, collects `changelog.d/` into `CHANGELOG.md`, and opens a "Release vX.Y.Z" pull request. Its `auto` bump picks minor when a fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. A pull request opened with the default Actions token starts no workflows, so it starts CI on the release branch itself, and those checks satisfy the required check. `ci.yml` gains a `workflow_dispatch` trigger for this. -- `release-on-merge.yml`: when a `release/vX.Y.Z` pull request merges, it checks that `pyproject.toml` at the merge commit says X.Y.Z, tags the merge commit `vX.Y.Z`, and creates the GitHub release from the changelog section. For a project published to PyPI it then starts `publish.yml`. -- `publish.yml` takes a `workflow_dispatch` trigger, so `release-on-merge.yml` can start it. A tag created with the default token triggers no workflows, and PyPI trusted publishing can't run from a reusable workflow, so the dispatched run is still `publish.yml`, the workflow the trusted publisher names. It refuses any ref but a `v*` tag, and fails if the tag doesn't match the built wheel's version. +- A **Prepare release** workflow (`prepare-release.yml`), run from the Actions tab. It bumps `version` in `pyproject.toml` with `uv version --bump`, collects `changelog.d/` into `CHANGELOG.md`, and opens a "Release vX.Y.Z" pull request. Its `auto` bump picks minor when a fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. It refuses a run with no fragments, a fragment with entries but no category heading, an existing tag, or an already-open release pull request, and is safe to re-run after a failure. CI on the pull request starts in GitHub's approval-required state for pull requests opened by Actions: click **Approve workflows to run**. +- `release-on-merge.yml`: when a `release/vX.Y.Z` pull request merges into the default branch, it checks that `pyproject.toml` at the merge commit says X.Y.Z, tags the merge commit `vX.Y.Z`, and creates the GitHub release from the changelog section. For a project published to PyPI it then starts `publish.yml`. It refuses a tag that already points at another commit, and skips a tag or release that already exists, so it can be re-run. +- `publish.yml` takes a `workflow_dispatch` trigger, so `release-on-merge.yml` can start it, and moves to checkout 7.0.1 and setup-uv 10.0.1. A tag created with the default token triggers no workflows, and PyPI trusted publishing can't run from a reusable workflow, so the dispatched run is still `publish.yml`, the workflow the trusted publisher names. It refuses any ref but a `v*` tag, and fails if the tag doesn't match the built wheel's version. ### Changed -- Libraries build with uv's own backend, `uv_build`, instead of hatchling, and keep a static `version` in `pyproject.toml`, so `uv version --bump` manages it. `__version__` reads it back from the installed package metadata (`"unknown"` in a source tree that was never installed). hatchling was there to read the version from `__init__.py`, which uv can't bump; with the version in `pyproject.toml` it isn't needed. `[tool.scriv]` reads the version from `pyproject.toml` for every project kind. +- Libraries build with uv's own backend, `uv_build`, instead of hatchling, and keep a static `version` in `pyproject.toml`, so `uv version --bump` manages it. `__version__` reads it back from the installed package metadata (`"unknown"` in a source tree that was never installed). hatchling was there to read the version from `__init__.py`, which uv can't bump; with the version in `pyproject.toml` it isn't needed. `[tool.scriv]` reads the version from `pyproject.toml` for every project kind. MIT projects set `license-files = ["LICENSE"]`, since uv_build ships only the license files it is told about. ### Upgrading - Turn on _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**, or Prepare release fails when it opens the pull request. The weekly template-update workflow needs the same setting. -- `copier update` will conflict on the libraries' `[build-system]`, `[project] version` and `__init__.py`. Resolve to the template's side: `uv_build`, a static `version`, and `__version__` from metadata. Check that the wheel's contents don't change: uv_build expects the package under `src//`, and any hatch build options (includes, excludes, force-include) need their `[tool.uv.build-backend]` equivalents. +- For a library, `copier update` changes `pyproject.toml` to uv_build and a static `version`, and a migration then sets that `version` to the `__version__` the project's `__init__.py` had before the update. Without it the template's `0.1.0` would merge in silently. Check `uv version --short` before merging the update. `__init__.py` itself conflicts: keep the template's metadata lookup, plus any other code the file had. +- Check that the wheel's contents don't change: uv_build expects the package under `src//`, and any hatch build options (includes, excludes, force-include) need their `[tool.uv.build-backend]` equivalents. - No PyPI change is needed. The trusted publisher still names `publish.yml`. A project that publishes from a differently named workflow should rename it to `publish.yml` and update the publisher. ## [1.9.1] - 2026-10-02 diff --git a/template/README.md.jinja b/template/README.md.jinja index 0d527ad..04ada3e 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -11,7 +11,7 @@ uv run pytest uv run basedpyright src ``` -Each pull request adds a changelog fragment rather than editing `CHANGELOG.md`, so concurrent PRs don't conflict. Run `uv run scriv create`, uncomment the sections that apply in the new file under `changelog.d/`, and commit it with the change, or delete it if the change needs no entry. To release, run the **Prepare release** workflow from the Actions tab. It bumps `version` in `pyproject.toml`, collects the fragments into `CHANGELOG.md`, and opens a release pull request; merging it tags the release. It needs _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**. By hand, the same is `uv version --bump minor` and `uv run scriv collect`. +Each pull request adds a changelog fragment rather than editing `CHANGELOG.md`, so concurrent PRs don't conflict. Run `uv run scriv create`, uncomment the sections that apply in the new file under `changelog.d/`, and commit it with the change, or delete it if the change needs no entry. To release, run the **Prepare release** workflow from the Actions tab. It bumps `version` in `pyproject.toml`, collects the fragments into `CHANGELOG.md`, and opens a release pull request, whose CI starts once you click **Approve workflows to run** on it; merging it tags the release. It needs _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**. By hand, the same is `uv version --bump minor` and `uv run scriv collect`. Linting (ruff, [zizmor](https://docs.zizmor.sh/), mdformat) runs via [pre-commit](https://pre-commit.com); CI runs the same stack plus basedpyright and pytest via the shared [`python-ci`](https://github.com/{{ github_owner }}/python-project-template) reusable workflow. {% if publish_to_pypi %} diff --git a/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja b/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja index 6a2d110..d18677f 100644 --- a/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja +++ b/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja @@ -18,8 +18,8 @@ One more one-time step: _Settings → Actions → General_ → enable **Allow Gi ## Each release -1. _Actions_ → **Prepare release** → _Run workflow_, choosing the bump. `auto` picks minor when a changelog fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. The workflow bumps `version` in `pyproject.toml`, collects `changelog.d/` into `CHANGELOG.md`, opens a **Release vX.Y.Z** pull request, and starts CI on it. -2. Review the pull request. Add a summary paragraph under the new heading if the release needs one. +1. _Actions_ → **Prepare release** → _Run workflow_, choosing the bump. `auto` picks minor when a changelog fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. The workflow bumps `version` in `pyproject.toml`, collects `changelog.d/` into `CHANGELOG.md`, and opens a **Release vX.Y.Z** pull request. +2. On the pull request's Checks tab, click **Approve workflows to run**. A pull request opened by Actions starts its CI in an approval-required state. Review the pull request, and add a summary paragraph under the new heading if the release needs one. 3. Merge it. `release-on-merge.yml` tags the merge commit `vX.Y.Z`, creates the GitHub release from the changelog section, and starts `publish.yml`, which checks the tag against the built wheel and uploads to PyPI. To do the same by hand: `uv version --bump minor` (or `patch` / `major`), `uv run scriv collect`, open and merge a pull request, then `git tag vX.Y.Z && git push origin vX.Y.Z` on the merge commit. The tag push starts `publish.yml`.