diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 619d1a7..7ee7494 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -107,6 +107,23 @@ 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; do + python3 -c "import sys,yaml; yaml.safe_load(open(sys.argv[1]))" "$out/.github/workflows/$wf.yml" + done + ! 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__") + # 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 uvx copier copy --trust --defaults --vcs-ref=HEAD \ --data project_name="sample-no-typos" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 882209e..ee197a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,23 @@ 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. 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. 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. +- 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 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/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..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: @@ -42,7 +59,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/.github/workflows/prepare-release.yml.jinja b/template/.github/workflows/prepare-release.yml.jinja new file mode 100644 index 0000000..db0a587 --- /dev/null +++ b/template/.github/workflows/prepare-release.yml.jinja @@ -0,0 +1,153 @@ +{% 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. +# +# 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: + 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 + runs-on: ubuntu-latest + permissions: + contents: write # push the release branch + pull-requests: write # open the release pull request + 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 + + - 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) + 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.") + + 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}" + 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}" + # --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 && (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 +{% 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..2eee879 --- /dev/null +++ b/template/.github/workflows/release-on-merge.yml.jinja @@ -0,0 +1,95 @@ +{% 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] + +permissions: + contents: read + +concurrency: + group: release-on-merge + cancel-in-progress: false + +jobs: + release: + 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 + 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 + 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 + env: + GH_TOKEN: ${{ '{{' }} github.token {{ '}}' }} + VERSION: ${{ '{{' }} steps.version.outputs.version {{ '}}' }} + run: gh workflow run publish.yml --ref "v${VERSION}" +{% endif %} 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..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 @@ -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 @@ -20,17 +25,41 @@ 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) + - 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 diff --git a/template/README.md.jinja b/template/README.md.jinja index 4ef0670..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. 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, 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/pyproject.toml.jinja b/template/pyproject.toml.jinja index b887ba6..a11d3ed 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -1,19 +1,21 @@ {% 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 }}" +{% 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 = [] @@ -32,13 +34,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 +53,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 %} diff --git a/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja b/template/{% if publish_to_pypi %}RELEASING.md{% endif %}.jinja index 9f92b0d..d18677f 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`, 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`. ## Sanity checks before tagging