diff --git a/.github/workflows/bump-v1.yml b/.github/workflows/bump-v1.yml index 2943e7b..ebd018a 100644 --- a/.github/workflows/bump-v1.yml +++ b/.github/workflows/bump-v1.yml @@ -11,6 +11,10 @@ name: Bump v1 on: release: types: [published] + # Started by template-release-on-merge.yml with --ref vX.Y.Z: a release it + # creates with the default Actions token raises no release event. On a + # dispatch, github.ref_name is the tag and github.sha its commit. + workflow_dispatch: permissions: contents: read @@ -27,8 +31,12 @@ jobs: # forward onto a breaking change, and a prerelease is by definition not what # consumers pinned to `@v1` should receive. if: >- - ${{ !github.event.release.prerelease - && startsWith(github.event.release.tag_name, 'v1.') }} + ${{ (github.event_name == 'release' + && !github.event.release.prerelease + && startsWith(github.event.release.tag_name, 'v1.')) + || (github.event_name == 'workflow_dispatch' + && github.ref_type == 'tag' + && startsWith(github.ref_name, 'v1.')) }} permissions: # Scoped to this job so the rest of the workflow stays read-only. contents: write # move the v1 tag @@ -36,6 +44,28 @@ jobs: # No checkout on purpose: moving a ref needs the API, not a working copy, # and skipping it avoids persisting credentials on disk for a job that # holds the one token able to rewrite what every consumer runs. + - name: Refuse a dispatch on a prerelease or an older tag + # The release event path gets these checks from the release itself + # (prerelease flag) and from being the release just published. A + # dispatch can name any tag, and an older one would move v1 back for + # every consumer. + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if ! [[ "$TAG" =~ ^v1\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::${TAG} is not a final v1.X.Y release tag" >&2 + exit 1 + fi + newest=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/v1." --jq '.[].ref' \ + | sed 's#^refs/tags/##' | grep -E '^v1\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1) + if [ "$TAG" != "$newest" ]; then + echo "::error::${TAG} is not the newest v1 release tag (${newest}); v1 only moves forward" >&2 + exit 1 + fi + - name: Refuse to move v1 off main env: GH_TOKEN: ${{ github.token }} @@ -61,7 +91,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_SHA: ${{ github.sha }} - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || github.ref_name }} run: | set -euo pipefail # Fetched rather than checked out, to keep this job free of a working @@ -112,7 +142,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_SHA: ${{ github.sha }} - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || github.ref_name }} run: | set -euo pipefail # The Move step below puts v1 on this same commit, and copier reads a diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..390d5c8 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,222 @@ +name: Prepare release + +# Reusable workflow: bump the version, collect the changelog.d/ fragments into +# CHANGELOG.md, and open a "Release vX.Y.Z" pull request. Merging that pull +# request runs release-on-merge.yml, which tags and releases it. +# +# Generated projects call it from their own prepare-release.yml with +# version-source: pyproject. This repo calls it with version-source: tags, +# since a Copier template has no package version, only tags. +# +# The caller must grant contents: write (push the release branch) and +# pull-requests: write (open the pull request); the job asks for exactly +# those. The caller's repo needs Settings > Actions > General > "Allow GitHub Actions to +# create and approve pull requests", or opening the pull request 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 its Checks tab and click "Approve workflows to run". A re-run +# after a failure is safe. + +on: + workflow_call: + inputs: + bump: + description: >- + auto, patch, minor or major. auto picks minor when a fragment adds, + changes, deprecates or removes something, and patch when fragments + only fix. auto never picks major. + type: string + default: auto + version-source: + description: >- + pyproject: the version is [project] version, bumped with + `uv version --bump`, and scriv and mdformat run from the project's + dev group. tags: the version is the latest vX.Y.Z tag, bumped here, + and scriv runs with uvx. + type: string + default: pyproject + python-version: + description: Python for uv. Empty uses the project's .python-version. + type: string + default: "" + +# Nothing at workflow level; the job below asks for what it needs. +permissions: {} + +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: Check the inputs and the branch + env: + BUMP: ${{ inputs.bump }} + VERSION_SOURCE: ${{ inputs.version-source }} + REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + case "$BUMP" in auto | patch | minor | major) ;; *) + echo "::error::bump must be auto, patch, minor or major, not ${BUMP}" >&2; exit 1 ;; + esac + case "$VERSION_SOURCE" in pyproject | tags) ;; *) + echo "::error::version-source must be pyproject or tags, not ${VERSION_SOURCE}" >&2; exit 1 ;; + esac + 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: + fetch-depth: 0 # tags, for version-source: tags + persist-credentials: false + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: ${{ inputs.python-version }} + 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: + raw = path.read_text(encoding="utf-8") + # scriv starts a fragment after any line containing its insert + # marker, even quoted, and silently drops the lines above it. + if "scriv-insert-here" in raw or "scriv-end-here" in raw: + sys.exit(f"::error::{path} quotes a scriv marker, which would make scriv drop part of it. Describe the marker instead of quoting it.") + text = re.sub(r"", "", raw, 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 }} + VERSION_SOURCE: ${{ inputs.version-source }} + run: | + set -euo pipefail + if [ "$VERSION_SOURCE" = pyproject ]; then + uv version --bump "$BUMP" + version=$(uv version --short) + uv run --group dev scriv collect + else + latest=$(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true) + if [ -z "$latest" ]; then + echo "::error::no vX.Y.Z tag to bump from" >&2 + exit 1 + fi + IFS=. read -r major minor patch <<< "${latest#v}" + case "$BUMP" in + major) version="$((major + 1)).0.0" ;; + minor) version="${major}.$((minor + 1)).0" ;; + patch) version="${major}.${minor}.$((patch + 1))" ;; + esac + echo "Latest tag ${latest}; releasing ${version}." + uvx --from 'scriv==1.8.0' scriv collect --version "$version" + fi + + # Keep Keep-a-Changelog compare links current, where CHANGELOG.md has + # them: "[unreleased]: /compare/vPREV...HEAD" gains a line for + # the new version and moves on to it. A file without one is unchanged. + VERSION="$version" python3 - <<'PY' + import os + import re + from pathlib import Path + + v = os.environ["VERSION"] + path = Path("CHANGELOG.md") + text = path.read_text(encoding="utf-8") + pattern = re.compile(r"^\[(unreleased)\]: (\S+/compare/)(\S+)\.\.\.HEAD$", re.I | re.M) + text, n = pattern.subn( + lambda m: f"[{v}]: {m[2]}{m[3]}...v{v}\n[{m[1]}]: {m[2]}v{v}...HEAD", text, count=1 + ) + if n: + path.write_text(text, encoding="utf-8") + print(f"Added the [{v}] compare link.") + PY + + # A first mdformat pass may tidy the collected section; the second + # must then pass. + if [ -f .pre-commit-config.yaml ] && grep -q 'id: mdformat' .pre-commit-config.yaml; then + if [ "$VERSION_SOURCE" = pyproject ]; then + run=(uv run --group dev pre-commit) + else + run=(uvx --from 'pre-commit==4.6.2' pre-commit) + fi + "${run[@]}" run mdformat --files CHANGELOG.md || "${run[@]}" run mdformat --files CHANGELOG.md + fi + echo "version=${version}" >> "$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 CHANGELOG.md changelog.d + for f in pyproject.toml uv.lock; do + if [ -f "$f" ]; then git add "$f"; fi + done + 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 diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml new file mode 100644 index 0000000..6ec933d --- /dev/null +++ b/.github/workflows/release-on-merge.yml @@ -0,0 +1,126 @@ +name: Release on merge + +# Reusable workflow: when a "Release vX.Y.Z" pull request from +# prepare-release.yml merges into the default branch, tag the merge commit +# vX.Y.Z and create its GitHub release from the CHANGELOG.md section, then +# optionally start another workflow on the tag. +# +# Callers run it on `pull_request: types: [closed]`; it does nothing for other +# pull requests. Permissions come from the caller: contents: write (create the +# tag and the release), plus actions: write when dispatch-workflow is set. +# +# A tag created with the default Actions token triggers no workflows, so a +# PyPI project passes dispatch-workflow: publish.yml rather than relying on +# its tag trigger. PyPI trusted publishing can't run from a reusable workflow, +# so publishing stays in the project's own publish.yml. + +on: + workflow_call: + inputs: + version-source: + description: >- + pyproject: check that [project] version at the merge commit matches + the release branch. tags: the branch name is the only version. + type: string + default: pyproject + dispatch-workflow: + description: >- + A workflow file in the caller's repo to start on the new tag, such as + publish.yml. Empty starts nothing. + type: string + default: "" + +jobs: + # No permissions block: the job takes what the caller grants. actions: write + # is needed only with dispatch-workflow, and a called job that asks for more + # than its caller grants fails at startup, so an app's caller (which grants + # contents: write alone) could not use a job that declared it. + release: # zizmor: ignore[excessive-permissions] -- inherits the caller's grant; see above + 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 + 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 + if: inputs.version-source == 'pyproject' + with: + enable-cache: false # no caching in a job that can write (cache-poisoning surface) + + - name: Check the version + id: version + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + VERSION_SOURCE: ${{ inputs.version-source }} + 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 + case "$VERSION_SOURCE" in + pyproject) + 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 + ;; + tags) ;; + *) + echo "::error::version-source must be pyproject or tags, not ${VERSION_SOURCE}" >&2 + exit 1 + ;; + esac + if ! grep -q -F "## [${version}]" CHANGELOG.md; then + echo "::error::CHANGELOG.md at the merge commit has no ## [${version}] section" >&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 + + - name: Start ${{ inputs.dispatch-workflow }} on the tag + if: inputs.dispatch-workflow != '' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + WORKFLOW: ${{ inputs.dispatch-workflow }} + run: gh workflow run "$WORKFLOW" --ref "v${VERSION}" diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 7ee7494..67b1495 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -114,8 +114,15 @@ jobs: 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 + # The scaffolded release workflows are thin callers of this repo's + # reusable ones; only a PyPI library starts publish.yml. + for kind in app library; do + grep -q 'python-project-template/.github/workflows/prepare-release.yml@v1' "/tmp/out-$kind/.github/workflows/prepare-release.yml" + grep -q 'python-project-template/.github/workflows/release-on-merge.yml@v1' "/tmp/out-$kind/.github/workflows/release-on-merge.yml" + done + grep -q 'dispatch-workflow: 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 'actions: write' /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. diff --git a/.github/workflows/template-prepare-release.yml b/.github/workflows/template-prepare-release.yml new file mode 100644 index 0000000..7d41d7e --- /dev/null +++ b/.github/workflows/template-prepare-release.yml @@ -0,0 +1,38 @@ +name: Prepare template release + +# Cuts a release of this template with the same reusable workflow generated +# projects call, so each template release exercises it first. A Copier +# template has no package version: the next version comes from the latest +# vX.Y.Z tag. Merging the pull request runs template-release-on-merge.yml. + +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, which would leave the moving v1 tag behind. + type: choice + default: auto + options: + - auto + - patch + - minor + +permissions: + contents: read + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + uses: ./.github/workflows/prepare-release.yml + permissions: + contents: write # push the release branch + pull-requests: write # open the release pull request + with: + bump: ${{ inputs.bump }} + version-source: tags diff --git a/.github/workflows/template-release-on-merge.yml b/.github/workflows/template-release-on-merge.yml new file mode 100644 index 0000000..2aa17c2 --- /dev/null +++ b/.github/workflows/template-release-on-merge.yml @@ -0,0 +1,30 @@ +name: Template release on merge + +# When a template "Release vX.Y.Z" pull request merges, tag it and create the +# GitHub release, then start bump-v1.yml on the tag. bump-v1 checks the +# changelog, annotates the tag and moves v1. A release created with the +# default Actions token doesn't trigger bump-v1's release event, hence the +# explicit start. + +on: + pull_request: + types: [closed] + +permissions: + contents: read + +# Keyed on the branch: every closed pull request starts this workflow, and a +# shared group would let an unrelated one cancel a pending release run. +concurrency: + group: release-on-merge-${{ github.event.pull_request.head.ref }} + cancel-in-progress: false + +jobs: + release: + uses: ./.github/workflows/release-on-merge.yml + permissions: + contents: write # create the tag and the release + actions: write # start bump-v1.yml + with: + version-source: tags + dispatch-workflow: bump-v1.yml diff --git a/.github/workflows/template-update.yml b/.github/workflows/template-update.yml index dcb6a29..3a20929 100644 --- a/.github/workflows/template-update.yml +++ b/.github/workflows/template-update.yml @@ -146,9 +146,9 @@ jobs: fi BODY="$BODY - Note: opened with the default \`GITHUB_TOKEN\`, so GitHub will not run - this repository's own workflows on it. Close and reopen the PR, or push - a commit, to get CI to run." + Note: opened with the default \`GITHUB_TOKEN\`, so GitHub starts this + repository's CI on it in an approval-required state. Click + **Approve workflows to run** on the Checks tab to start it." gh pr create --head "$BRANCH" --title "chore: apply template updates from $REF" --body "$BODY" \ || gh pr edit "$BRANCH" --body "$BODY" diff --git a/CHANGELOG.md b/CHANGELOG.md index ee197a5..fad9fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,24 +12,7 @@ Two things are worth knowing about how versions work here, because this repo shi Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, so they describe what each tag contained rather than having been written alongside it. -## [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 diff --git a/README.md b/README.md index 7dc8302..0db05c1 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,11 @@ From inside a project that was generated from this template (it has a `.copier-answers.yml`): ```bash -uvx copier update +uvx copier update --trust ``` +`--trust` lets the template run its migrations, such as the one in 1.10.0 that keeps a library's version when its version moves into `pyproject.toml`. Without it, an update that crosses a migration stops and changes nothing. + Copier does a 3-way merge between the old template output, the new output, and your local edits — so you get template improvements without losing your customizations. @@ -140,6 +142,15 @@ change between releases. Turn them on per-project when you want them, and keep `tsc --noEmit` under `strict` as the backstop either way — Biome's inference is newer and less complete than a full type-checker's. +## The reusable release workflows + +Generated projects release through two reusable workflows here, called from thin scaffolded callers pinned to `@v1`: + +- [`prepare-release.yml`](.github/workflows/prepare-release.yml), run from the project's Actions tab, bumps the version, collects `changelog.d/` into `CHANGELOG.md`, and opens a "Release vX.Y.Z" pull request. +- [`release-on-merge.yml`](.github/workflows/release-on-merge.yml), on that pull request's merge, tags the merge commit, creates the GitHub release, and can start another workflow on the tag. Generated PyPI libraries pass `dispatch-workflow: publish.yml`. + +Both take `version-source`: `pyproject` for generated projects, where the version is `[project] version`, or `tags`, where it comes from the latest `vX.Y.Z` tag. Publishing stays in each project's own `publish.yml`, because PyPI trusted publishing can't run from a reusable workflow. The callers grant the permissions; the reusable workflows declare none of their own. + ## Repo settings as code GitHub keeps repository settings and rulesets in the UI and API rather than in @@ -225,9 +236,21 @@ It's off by default: copier's merge is deterministic and usually clean, and a conflict is often exactly the thing a human should look at. One GitHub quirk the PR body also mentions: it's opened with the default -`GITHUB_TOKEN`, and GitHub deliberately does not run workflows on PRs created -that way. Close and reopen the PR, or push a commit to it, to get CI to run — -or swap in a PAT or GitHub App token if you want that automatic. +`GITHUB_TOKEN`, so GitHub starts its CI in an approval-required state. Click +**Approve workflows to run** on the PR, or swap in a PAT or GitHub App token +if you want that automatic. The same applies to release pull requests. + +## Releasing this template + +The template releases itself with the same reusable workflows, so each release exercises them before `v1` moves to it: + +One-time setup: _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**, or step 1 fails when it opens the pull request. + +1. _Actions_ → **Prepare template release** → _Run workflow_. It takes the next version from the latest `vX.Y.Z` tag, 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**, then review it. Add a summary paragraph under the new heading if the release needs one. +3. Merge it. **Template release on merge** tags the merge commit and creates the GitHub release, then starts `bump-v1.yml`, which checks the changelog, annotates the tag, and moves `v1`. + +Each pull request to this repo adds a fragment under `changelog.d/` (`uvx --from scriv scriv create`), configured by `changelog.d/scriv.ini`. Publishing a release by hand from the GitHub UI still works: `bump-v1.yml` runs on the release event as before. ## Versioning diff --git a/changelog.d/20261002_000000_release_workflows.md b/changelog.d/20261002_000000_release_workflows.md new file mode 100644 index 0000000..a830d8b --- /dev/null +++ b/changelog.d/20261002_000000_release_workflows.md @@ -0,0 +1,19 @@ +### Added + +- Automated releases. Run **Prepare release** 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. Approve its CI and merge it, and **Release on merge** tags the merge commit, creates the GitHub release from the changelog section, and, for a library published to PyPI, starts `publish.yml`. The `auto` bump picks minor when a fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. +- The steps live in two reusable workflows in this repo, `prepare-release.yml` and `release-on-merge.yml`, and generated projects get thin callers pinned to `@v1`, as with `python-ci.yml`. Release fixes reach every project when `v1` moves, without a `copier update`. +- The safeguards: Prepare release refuses a run from another branch, with no fragments, with a fragment that has entries but no category heading or that quotes a scriv marker (scriv would silently drop the lines above it), with an existing tag, or with a release pull request already open, and is safe to re-run after a failure. Release on merge checks that `pyproject.toml` at the merge commit agrees with the release branch, refuses a tag already on another commit, and skips a tag or release that already exists, so it can be re-run. Its concurrency group is keyed on the branch, so an unrelated pull request closing can't cancel a pending release run. +- `publish.yml` takes a `workflow_dispatch` trigger, so Release on merge can start it. A tag created with the default Actions 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, fails if the tag doesn't match the built wheel's version, and moves to checkout 7.0.1 and setup-uv 10.0.1. +- This template releases itself with the same reusable workflows, in a mode that takes the version from the latest `vX.Y.Z` tag, so every template release exercises them before `v1` moves. Its own changelog is now scriv fragments too, and `bump-v1.yml` can be started on a tag as well as by a published release. Started that way, it refuses anything but the newest final `v1.X.Y` tag, so `v1` only moves forward. + +### 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 + +- A manual `copier update` across 1.10.0 needs `--trust`, because the template now runs a migration. Without it copier stops with `Template uses potentially unsafe feature: migrations.` and changes nothing, for apps as well as libraries. The weekly template-update workflow already passes it. +- 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. CI on each release pull request then starts once you click **Approve workflows to run** on it. +- 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. +- For a library, 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. diff --git a/changelog.d/TEMPLATE.md b/changelog.d/TEMPLATE.md new file mode 100644 index 0000000..298c4b7 --- /dev/null +++ b/changelog.d/TEMPLATE.md @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + diff --git a/changelog.d/scriv.ini b/changelog.d/scriv.ini new file mode 100644 index 0000000..209b502 --- /dev/null +++ b/changelog.d/scriv.ini @@ -0,0 +1,12 @@ +# scriv settings for this template's own changelog (it has no pyproject.toml). +# Generated projects get theirs in pyproject.toml. +[scriv] +format = md +fragment_directory = changelog.d +categories = Added, Changed, Deprecated, Removed, Fixed, Security, Upgrading +skip_fragments = [A-Z]* +new_fragment_template = file: TEMPLATE.md +entry_title_template = [{{ version }}] - {{ date.strftime('%%Y-%%m-%%d') }} +md_header_level = 2 +md_html_anchors = false +compact_fragments = true diff --git a/template/.github/workflows/prepare-release.yml.jinja b/template/.github/workflows/prepare-release.yml.jinja index db0a587..f7ac65a 100644 --- a/template/.github/workflows/prepare-release.yml.jinja +++ b/template/.github/workflows/prepare-release.yml.jinja @@ -1,17 +1,14 @@ -{% raw %}name: Prepare release +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). +# pyproject.toml, collects changelog.d/ into CHANGELOG.md, and opens a +# "Release vX.Y.Z" pull request; merging it tags the release (see +# release-on-merge.yml). The steps live in the template's reusable workflow, +# so fixes arrive when the template's v1 tag moves. # # 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. +# create and approve pull requests". On the release pull request, click +# "Approve workflows to run" to start its CI. on: workflow_dispatch: @@ -38,116 +35,10 @@ concurrency: jobs: prepare: - name: Open the release pull request - runs-on: ubuntu-latest + # Moving major tag on purpose, as in ci.yml (see .github/zizmor.yml). + uses: {{ github_owner }}/python-project-template/.github/workflows/prepare-release.yml@v1 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 %} + with: + bump: ${{ '{{' }} inputs.bump {{ '}}' }} diff --git a/template/.github/workflows/release-on-merge.yml.jinja b/template/.github/workflows/release-on-merge.yml.jinja index 2eee879..4474dc8 100644 --- a/template/.github/workflows/release-on-merge.yml.jinja +++ b/template/.github/workflows/release-on-merge.yml.jinja @@ -1,8 +1,10 @@ -{% raw %}name: Release on merge +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 %}. +# When a "Release vX.Y.Z" pull request from prepare-release.yml merges, tag +# the merge commit and create its GitHub release from the CHANGELOG.md +# section{% if publish_to_pypi %}, then start publish.yml to upload it to PyPI{% endif %}. +# Other pull requests are ignored. The steps live in the template's reusable +# workflow. on: pull_request: @@ -11,85 +13,20 @@ on: permissions: contents: read +# Keyed on the branch: every closed pull request starts this workflow, and a +# shared group would let an unrelated one cancel a pending release run. concurrency: - group: release-on-merge + group: release-on-merge-${{ '{{' }} github.event.pull_request.head.ref {{ '}}' }} 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 + # Moving major tag on purpose, as in ci.yml (see .github/zizmor.yml). + uses: {{ github_owner }}/python-project-template/.github/workflows/release-on-merge.yml@v1 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}" +{% if publish_to_pypi %} + actions: write # start publish.yml + with: + dispatch-workflow: publish.yml {% endif %}