From 18cfc59de600682d24c3e549975bad3628575e31 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Sat, 3 Oct 2026 11:23:38 +1000 Subject: [PATCH 1/5] Move the release steps into reusable workflows, with thin scaffolded callers prepare-release.yml and release-on-merge.yml now live in this repo as workflow_call workflows, and generated projects get callers pinned to @v1, as with python-ci.yml. Release fixes then reach every project when v1 moves, instead of needing a copier update and a merge in each repo. Both take version-source: pyproject (generated projects; uv version --bump) or tags (the next vX.Y.Z from the latest tag, for a repo with no package version). release-on-merge takes dispatch-workflow, which a PyPI library sets to publish.yml; publishing stays in the project's own workflow because PyPI trusted publishing can't run from a reusable one. The reusable workflows declare no permissions, so a caller that grants less (an app, without actions: write) doesn't fail at startup. prepare-release also keeps Keep-a-Changelog compare links current where a CHANGELOG.md has them, and runs mdformat only where a project's pre-commit config has it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/prepare-release.yml | 211 ++++++++++++++++++ .github/workflows/release-on-merge.yml | 122 ++++++++++ .github/workflows/template-ci.yml | 9 +- .../workflows/prepare-release.yml.jinja | 131 +---------- .../workflows/release-on-merge.yml.jinja | 88 +------- 5 files changed, 363 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/release-on-merge.yml diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..0c9b0f8 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,211 @@ +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. +# +# Permissions come from the caller, which must grant contents: write (push the +# release branch) and pull-requests: write (open the pull request). 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: "" + +jobs: + prepare: + name: Open the release pull request + runs-on: ubuntu-latest + 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: + 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 }} + 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' 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 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..9482c20 --- /dev/null +++ b/.github/workflows/release-on-merge.yml @@ -0,0 +1,122 @@ +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: + 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 + 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/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..22995fa 100644 --- a/template/.github/workflows/release-on-merge.yml.jinja +++ b/template/.github/workflows/release-on-merge.yml.jinja @@ -1,8 +1,9 @@ -{% 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: @@ -17,79 +18,12 @@ concurrency: 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 %} From 68abddafb2e895c1d020e85ee678cb5364631633 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Sat, 3 Oct 2026 11:23:38 +1000 Subject: [PATCH 2/5] Release this template with its own reusable workflows Prepare template release and Template release on merge call the reusable workflows from this checkout in tags mode, so each template release runs them before v1 moves. release-on-merge then starts bump-v1.yml on the tag: a release created with the default token raises no release event, so bump-v1 now also accepts a dispatch on a v1.* tag ref, and still runs on a release published by hand. The template's own changelog moves to scriv: changelog.d/scriv.ini (this repo has no pyproject.toml), a TEMPLATE.md with an Upgrading category, the insert marker, and the [Unreleased] entries in one fragment, rewritten for the shared workflows and with the --trust upgrade step from the review of #7. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bump-v1.yml | 16 ++++-- .../workflows/template-prepare-release.yml | 38 +++++++++++++ .../workflows/template-release-on-merge.yml | 28 ++++++++++ CHANGELOG.md | 19 +------ .../20261002_000000_release_workflows.md | 19 +++++++ changelog.d/TEMPLATE.md | 56 +++++++++++++++++++ changelog.d/scriv.ini | 12 ++++ 7 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/template-prepare-release.yml create mode 100644 .github/workflows/template-release-on-merge.yml create mode 100644 changelog.d/20261002_000000_release_workflows.md create mode 100644 changelog.d/TEMPLATE.md create mode 100644 changelog.d/scriv.ini diff --git a/.github/workflows/bump-v1.yml b/.github/workflows/bump-v1.yml index 2943e7b..a96b66c 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 @@ -61,7 +69,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 +120,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/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..39cc84f --- /dev/null +++ b/.github/workflows/template-release-on-merge.yml @@ -0,0 +1,28 @@ +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 + +concurrency: + group: release-on-merge + 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/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/changelog.d/20261002_000000_release_workflows.md b/changelog.d/20261002_000000_release_workflows.md new file mode 100644 index 0000000..fd706c8 --- /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, 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. +- `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. + +### 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 From b0f995a9c5ea9ac40389ae799575fa0e711f8631 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Sat, 3 Oct 2026 11:23:38 +1000 Subject: [PATCH 3/5] Document the shared release workflows and how the template releases The README describes the reusable release workflows, the template's own release steps, and `copier update --trust`, which a migration now needs. It and the template-update PR body no longer say GitHub skips CI on PRs opened with the default token: it holds them for "Approve workflows to run". Co-Authored-By: Claude Opus 5.5 --- .github/workflows/template-update.yml | 6 +++--- README.md | 29 +++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) 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/README.md b/README.md index 7dc8302..47ec7a5 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,19 @@ 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: + +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 From bf0218bd62ebc19cc155d2cb62571c7536663f1a Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Sat, 3 Oct 2026 11:39:50 +1000 Subject: [PATCH 4/5] Harden the release workflows after review - Key the release-on-merge concurrency group on the branch. Every closed PR runs it, and a shared group lets a newer pending run cancel a pending release run, leaving no tag, release or v1 move. - Refuse a fragment that quotes scriv's insert or end marker: scriv 1.8.0 starts a fragment after any line containing it, even in backticks, and silently drops the lines above. - bump-v1's dispatch path refuses anything but the newest final v1.X.Y tag, so a dispatch on an older tag can't move v1 backwards. - prepare-release declares its job permissions, with none at workflow level; release-on-merge keeps inheriting the caller's grant (actions: write is conditional) and says so with a zizmor ignore. zizmor's auditor persona is clean apart from that. - Pin scriv and pre-commit in tags mode, and fix the caller comment's wrap. From the review on #8. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bump-v1.yml | 22 ++++++++++++++++++ .github/workflows/prepare-release.yml | 23 ++++++++++++++----- .github/workflows/release-on-merge.yml | 6 ++++- .../workflows/template-release-on-merge.yml | 4 +++- .../workflows/release-on-merge.yml.jinja | 9 +++++--- 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bump-v1.yml b/.github/workflows/bump-v1.yml index a96b66c..ebd018a 100644 --- a/.github/workflows/bump-v1.yml +++ b/.github/workflows/bump-v1.yml @@ -44,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 }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 0c9b0f8..390d5c8 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -8,9 +8,9 @@ name: Prepare release # version-source: pyproject. This repo calls it with version-source: tags, # since a Copier template has no package version, only tags. # -# Permissions come from the caller, which must grant contents: write (push the -# release branch) and pull-requests: write (open the pull request). The -# caller's repo needs Settings > Actions > General > "Allow GitHub Actions to +# 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 @@ -41,10 +41,16 @@ on: 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: @@ -91,7 +97,12 @@ jobs: ] sections = set() for path in fragments: - text = re.sub(r"", "", path.read_text(encoding="utf-8"), flags=re.S) + 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.") @@ -132,7 +143,7 @@ jobs: patch) version="${major}.${minor}.$((patch + 1))" ;; esac echo "Latest tag ${latest}; releasing ${version}." - uvx --from 'scriv>=1.8' scriv collect --version "$version" + uvx --from 'scriv==1.8.0' scriv collect --version "$version" fi # Keep Keep-a-Changelog compare links current, where CHANGELOG.md has @@ -161,7 +172,7 @@ jobs: if [ "$VERSION_SOURCE" = pyproject ]; then run=(uv run --group dev pre-commit) else - run=(uvx pre-commit) + run=(uvx --from 'pre-commit==4.6.2' pre-commit) fi "${run[@]}" run mdformat --files CHANGELOG.md || "${run[@]}" run mdformat --files CHANGELOG.md fi diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index 9482c20..6ec933d 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -31,7 +31,11 @@ on: default: "" jobs: - release: + # 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 diff --git a/.github/workflows/template-release-on-merge.yml b/.github/workflows/template-release-on-merge.yml index 39cc84f..2aa17c2 100644 --- a/.github/workflows/template-release-on-merge.yml +++ b/.github/workflows/template-release-on-merge.yml @@ -13,8 +13,10 @@ 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: diff --git a/template/.github/workflows/release-on-merge.yml.jinja b/template/.github/workflows/release-on-merge.yml.jinja index 22995fa..4474dc8 100644 --- a/template/.github/workflows/release-on-merge.yml.jinja +++ b/template/.github/workflows/release-on-merge.yml.jinja @@ -2,8 +2,9 @@ name: Release on merge # 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. +# 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: @@ -12,8 +13,10 @@ 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: From cd317c7f8bdc97dbc020b19e49a40ea463d4e4a9 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Sat, 3 Oct 2026 11:39:50 +1000 Subject: [PATCH 5/5] Add the Actions setting to the template release steps, and log the safeguards From the review on #8. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ changelog.d/20261002_000000_release_workflows.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47ec7a5..0db05c1 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,8 @@ if you want that automatic. The same applies to release pull requests. 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`. diff --git a/changelog.d/20261002_000000_release_workflows.md b/changelog.d/20261002_000000_release_workflows.md index fd706c8..a830d8b 100644 --- a/changelog.d/20261002_000000_release_workflows.md +++ b/changelog.d/20261002_000000_release_workflows.md @@ -2,9 +2,9 @@ - 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, 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. +- 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. +- 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