Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/template-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package>/`, 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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
19 changes: 18 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
153 changes: 153 additions & 0 deletions template/.github/workflows/prepare-release.yml.jinja
Original file line number Diff line number Diff line change
@@ -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 %}
95 changes: 95 additions & 0 deletions template/.github/workflows/release-on-merge.yml.jinja
Original file line number Diff line number Diff line change
@@ -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 %}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading
Loading