Skip to content

Automate releases: Prepare release and release-on-merge workflows - #6

Merged
MattFisher merged 7 commits into
mainfrom
feat/release-workflows
Oct 3, 2026
Merged

MattFisher merged 7 commits into
mainfrom
feat/release-workflows

Conversation

@MattFisher

@MattFisher MattFisher commented Oct 2, 2026 •

Copy link
Copy Markdown

Why

Releasing a scaffolded project by hand takes about six steps: bump the version, collect the changelog, open a PR, merge it, tag it, and create the GitHub release. This PR automates them. You run one workflow from the Actions tab, review the pull request it opens, and merge it.

What changes

Libraries: uv_build and a static version (f15897a, c00283c)

  • The version is a static [project] version in pyproject.toml, which is what uv version --bump manages. __version__ reads it back from package metadata.
  • hatchling was only there to read the version from __init__.py, which uv can't bump. So libraries build with uv's own backend, uv_build, which takes src/<package>/ with no further config.
  • This matches the model Set the version once in pyproject.toml, the way uv manages it inspect_dataset#57 adopts.
  • MIT projects set license-files = ["LICENSE"], because uv_build ships only the licence files named there.
  • Updating an existing library would otherwise merge the template's version = "0.1.0" in silently. A copier migration sets the version back to the __version__ the project had.

Publish dispatch and guard (7d1d060, cfe5dac)

  • A tag created with the default Actions token starts no workflows. So publish.yml gains workflow_dispatch, and release-on-merge starts it explicitly.
  • A dispatched publish still runs as publish.yml. That matters for PyPI trusted publishing, which can't run from a reusable workflow. Existing publishers need no change.
  • publish.yml now refuses any ref but a v* tag, and fails if the tag doesn't match the built wheel's version.
  • The first push dispatched ci.yml too. A review showed that doesn't work: dispatched checks don't satisfy a required check. cfe5dac removes it. A PR opened with the default token does start its own pull_request runs, held at Approve workflows to run, and those count.

Prepare release and release-on-merge (aab78c5)

  • prepare-release.yml (manual, with a bump choice). It picks the bump: auto gives minor for Added, Changed, Deprecated or Removed, and patch for Fixed or Security only, and never gives major. It then runs uv version --bump, scriv collect and mdformat, pushes release/vX.Y.Z, opens a "Release vX.Y.Z" pull request whose body is the changelog section, and says in the PR body to approve its CI runs. It refuses to run from another branch, with no fragments, with a fragment that has entries but no heading, with an existing tag, or with a release PR already open. A re-run after a partial failure works.
  • release-on-merge.yml. When a release/v* pull request from this repository merges into the default branch, it checks that pyproject.toml at the merge commit agrees with the branch, creates the tag at the merge commit (refusing one already on another commit), and creates the GitHub release. A re-run skips steps that already succeeded. For PyPI projects it then starts publish.yml. Apps get no PyPI step and no actions: write permission.

Docs and checks (600567c)

  • RELEASING.md and the README describe the flow, its one-time setting, and the manual fallback.
  • The template CHANGELOG has entries and upgrade steps.
  • Template CI parses the new workflows in both variants, checks that the PyPI step appears only for libraries, and builds the library with uv_build.

One-time setting per repo, and one click per release

Turn on Settings → Actions → General → Allow GitHub Actions to create and approve pull requests. Then, for each release PR, click Approve workflows to run to start its CI. It's off in Generality-Labs/inspect-evals-lint and Generality-Labs/inspect_dataset today. The weekly template-update workflow needs it too: it has only succeeded so far because there was nothing to update.

Testing

  • The full template CI render step and the rendered projects' own hooks (app and frontend) pass locally. zizmor (1.30.1, offline) finds nothing in the rendered workflows of any variant: app, PyPI library, and library without PyPI.
  • Prepare release's own steps, dry-run in a rendered library. A headingless fragment is refused, and the notes start at the first heading:
    • Fixed alone picks patch, and adding an Added fragment picks minor.
    • No fragments fails with a clear error.
    • The bump goes to 0.2.0, the fragments are collected, and the mdformat pass succeeds.
    • The notes extraction yields exactly the new section.
  • In a rendered library: __version__ is 0.1.0, uv build works, and after uv version --bump patch the next uv run sees 0.1.1. uv version --short --frozen, which release-on-merge uses, leaves uv.lock untouched.
  • Migration: I generated a library from v1.9.1, released it at 0.4.2 with a local edit in __init__.py, and updated it to this branch tagged locally as v1.10.0. The migration ran, pyproject.toml ends at version = "0.4.2" on uv_build, and only __init__.py conflicts.
  • The library wheel includes dist-info/licenses/LICENSE, and template CI now checks for it.
  • Not testable outside a real repo: opening the PR, approving its held CI, and the tag, release and publish dispatch. The first real release through an adopting repo will exercise them.

🤖 Generated with Claude Code

MattFisher and others added 4 commits October 2, 2026 21:17
….toml

hatchling read the version from __init__.py, which `uv version` can't read
or bump. A static [project] version is what uv manages, so libraries keep
version = "0.1.0" in pyproject.toml, build with uv_build (which takes
src/<package>/ with no further config), and read __version__ back from the
installed package metadata. scriv reads pyproject.toml for every kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pull request or tag created with the default Actions token starts no
workflows, so the release workflows start ci.yml and publish.yml
explicitly. A dispatched publish still runs as publish.yml, which is what
PyPI trusted publishing checks; it can't run as a reusable workflow.
publish.yml now refuses any ref but a v* tag and checks the tag against
the built wheel's version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prepare release (run from the Actions tab) bumps the version with
`uv version --bump`, collects changelog.d/ with scriv, opens a
"Release vX.Y.Z" pull request, and starts CI on it. Its auto bump picks
minor for Added/Changed/Deprecated/Removed and patch for Fixed/Security,
never major. Merging the pull request runs release-on-merge, which checks
the version, tags the merge commit, creates the GitHub release from the
changelog section, and starts publish.yml for PyPI projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RELEASING.md and the scaffold README describe the Prepare release flow,
its one-time Actions setting, and the manual fallback. The template
CHANGELOG records the additions and the upgrade steps. Template CI parses
the new workflows in both variants, checks the PyPI step appears only for
libraries, and builds the library with uv_build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MattFisher

Copy link
Copy Markdown
Author

Review of 600567c

I reviewed this in a fresh clone. I rendered three variants with copier copy --vcs-ref=HEAD: app, library with PyPI, and library without PyPI. I ran the extracted workflow steps against a local bare remote with a stub gh, and simulated copier update from v1.9.1. Each finding says how I checked it: [ran], [docs] or [reasoned].

Two findings break the documented flow, and I would fix both before merging. The rest are smaller.

1. The dispatched CI run never satisfies the required check (high)

Where: template/.github/workflows/prepare-release.yml.jinja lines 43 and 126-128, template/.github/workflows/ci.yml.jinja lines 8-11, CHANGELOG.md line 19, RELEASING.md.jinja line 21.

Problem [docs]: GitHub's troubleshooting required status checks page says only checks from push, pull_request, pull_request_review, pull_request_target, deployment and deployment_status runs count. It gives this exact case as its example: "if a workflow is triggered by workflow_dispatch on a pull request's head branch, checks reported by its jobs do not appear in the pull request's checks section. Even if the checks pass for the head commit, they do not satisfy a required status check in a branch ruleset."

The premise behind the dispatch is also out of date. Since the 2026-06-11 changelog, a pull request that GITHUB_TOKEN opens does start its pull_request workflows. They wait in an approval-required state until a user with write access clicks Approve workflows to run in the merge box (trigger-a-workflow docs).

Failure scenario [reasoned from the docs]: take a repo with the template's protect-main ruleset, which requires ci / Lint, type-check, and test. The dispatched run goes green in the Actions tab but does not appear on the PR. The required check stays "Expected" until someone approves the held run. The PR text, the code comment and RELEASING.md all say CI has already started and counts. A maintainer who trusts them will look for a check that never arrives, or will use the admin bypass. A dispatched run also does no harm, so the approval click is the step that matters, and nothing tells the user to do it.

Fix: drop gh workflow run ci.yml and actions: write from prepare-release. Drop the new workflow_dispatch trigger and its comment from ci.yml, or keep the trigger and reword the comment. Add a line to the PR body, RELEASING.md and the README: "Approve the held CI run (Approve workflows to run) before merging." Correct the CHANGELOG entry and the PR description to match. The same approval prompt now applies to template-update's PRs.

2. copier update quietly resets an existing library's version to 0.1.0 (high)

Where: CHANGELOG.md line 30 (Upgrading), and template/pyproject.toml.jinja line 12.

Problem [ran]: I rendered a library at v1.9.1, set __version__ = "0.4.2" and committed, then ran copier update --vcs-ref=HEAD. pyproject.toml merged with no conflict. It now says version = "0.1.0", because the v1.9.1 line was the pristine dynamic = ["version"]. Only __init__.py conflicted. The Upgrading note says [build-system] and [project] version will conflict. Neither did. It also says to resolve to the template's side, and doing that in __init__.py throws away the only record of 0.4.2.

Failure scenario [ran + reasoned]: after the update, __version__ reports 0.1.0 and CI still passes. The next Prepare release with only a Fixed fragment proposes v0.1.1. If tag v0.1.1 already exists, see finding 4. A manual tag push of v0.4.3 at least fails the new tag-versus-wheel check, which is good.

Fix: make the first Upgrading step "Set [project] version to your current __version__ (uv version X.Y.Z) before you resolve __init__.py." Fix the list of conflicting files. Better still, add a copier _migrations task for updates from below 1.10.0 that reads __version__ from src/<pkg>/__init__.py and runs uv version --frozen <it>.

3. uv_build drops LICENSE from the wheel and the sdist (medium)

Where: template/pyproject.toml.jinja line 15.

Problem [ran]: with hatchling at v1.9.1, the wheel has dist-info/licenses/LICENSE and the METADATA has License-File: LICENSE. With uv_build at this head, both are gone, and the sdist has no LICENSE either. hatchling picks up LICEN[CS]E* by default. uv_build only includes license-files when they are declared.

Failure scenario: every MIT library published from this template ships without its license text. The MIT terms require that text in copies.

Fix [ran]: when license == 'MIT', add license-files = ["LICENSE"] under [project]. I checked that this restores both the wheel entry and the License-File metadata. The sdist also drops tests/, CHANGELOG.md and the rest that hatchling included. That is probably fine, but the Upgrading note's "check that the wheel's contents don't change" should mention the sdist too.

4. Release-on-merge does not check whether the tag already exists (medium)

Where: template/.github/workflows/release-on-merge.yml.jinja line 70.

Problem [docs]: the create-release API says target_commitish is "Unused if the Git tag already exists". gh release create vX --target $SHA therefore attaches a new release to an old tag.

Failure scenario [reasoned]: this follows from finding 2, or from any lightweight tag pushed by hand without a release. The workflow creates "v0.1.1" on the old commit with the new notes, then dispatches publish.yml at that old tag. Old tags have no workflow_dispatch, so the dispatch fails. If an old tag did have one, publish would build old code.

Fix: create the ref explicitly so the run fails if it exists: gh api repos/$GITHUB_REPOSITORY/git/refs -f ref="refs/tags/v${VERSION}" -f sha="$SHA", then run gh release create --verify-tag. prepare-release should also refuse a version whose tag exists (git ls-remote --exit-code --tags origin "v${VERSION}"), so the problem surfaces before the PR.

5. Neither workflow can be retried after a partial failure (medium)

Where: prepare-release.yml.jinja lines 112-124, release-on-merge.yml.jinja lines 70-76.

Failure scenario [ran]: I simulated the likely first run, where "Allow GitHub Actions to create and approve pull requests" is still off (the PR body says it is off in two repos today). The push of release/v0.1.1 succeeded, then gh pr create failed. After the setting is turned on, the re-run builds a new commit, and git push is rejected as non-fast-forward. The user has to find and delete the branch. The same happens if a release PR is already open and someone runs Prepare release again.

[reasoned]: in release-on-merge, if gh workflow run publish.yml fails, a re-run fails at gh release create because the release now exists, so the publish step can't be reached again.

Fix: in prepare-release, check git ls-remote --exit-code --heads origin "$branch" first and fail with "delete branch X or merge its PR". Alternatively, force-push the bot-owned branch and use gh pr create || gh pr edit. In release-on-merge, skip the create when gh release view "v${VERSION}" succeeds and the tag points at $SHA.

6. Smaller points

  • [ran] A fragment with no ### heading is collected by scriv as uncategorised text, but the bump script doesn't see it. On its own, it makes Prepare release fail with "nothing to release". Next to other fragments, it has no effect on the bump. That is acceptable if documented. Capitalised filenames are skipped by both the script and scriv's skip_fragments = "[A-Z]*", so the two agree.
  • [ran] The PR body and notes.md start with an extra blank line, because the awk keeps the blank line after the heading. This is cosmetic.
  • [reasoned] A dispatch from a non-default branch shows as a skipped job, not as a refusal with a message. The PR body says it "refuses to run".
  • [reasoned] prepare-release targets default_branch, but release-on-merge filters branches: [main]. That is fine while ci.yml also hardcodes main, but the two disagree on a repo whose default branch has another name.
  • [ran] publish.yml still pins checkout v7.0.0 and setup-uv v8.3.2, while the new workflows use v7.0.1 and v10.0.1. This predates the PR.
  • Template CI doesn't check that a library without PyPI gets no actions: write in release-on-merge. I checked it by hand and it doesn't.

What I checked and found correct

  • Rendering [ran]: all three variants render valid YAML. The raw/if blocks leave correct whitespace, and the ${{ }} expressions survive literally, including the '{{' escapes in the PyPI step. actions: write and the PyPI step appear only when publish_to_pypi is true.
  • Hooks [ran]: every pre-commit hook passes in all three variants with --hook-stage manual. That includes actionlint, shellcheck, mdformat, and zizmor 1.26.1 both offline and online with a real token. zizmor 1.30.1 online with --persona=auditor finds nothing.
  • GITHUB_TOKEN and dispatch [docs]: workflow_dispatch runs are created even when GITHUB_TOKEN triggers them. A tag or release that GITHUB_TOKEN creates starts no push or release run, so there is no double publish. gh release create creates the tag at --target when the tag is missing (from gh release create --help).
  • pull_request closed [docs + reasoned]: for same-repo branches the job gets the permissions it declares. Fork PRs get a read-only token. The human merge is the triggering actor, so the run starts.
  • The if: guard [reasoned]: it is sufficient. A fork's head.repo.full_name differs, and a deleted fork gives null. Only someone with write access can open a same-repo release/v* branch, and they can already push tags. The pyproject-versus-branch check stops a mislabelled branch.
  • Injection [ran]: every user-controlled value reaches run: through env. HEAD_REF is checked against ^[0-9]+\.[0-9]+\.[0-9]+$ before use. I tested release/vfoo$(id) and release/v0.2.0-rc1, and both are refused.
  • PyPI trusted publishing [docs]: PyPI matches owner, repository, workflow filename and environment, with no constraint on event or ref. A dispatched publish.yml run on refs/tags/vX.Y.Z with environment pypi therefore matches the existing publisher.
  • prepare-release logic [ran]: Fixed alone gives patch. Added plus Fixed gives minor, including when the fragment keeps the TEMPLATE comment blocks. An untouched TEMPLATE copy is ignored, and on its own it refuses. mdformat passes after collect on the first and the second release. Notes extraction stops at the previous ## [ heading. uv version --bump updates pyproject.toml and uv.lock for the app (package = false) and for the library.
  • release-on-merge version check [ran]: uv version --short --frozen leaves uv.lock untouched and needs no interpreter.
  • uv_build [ran]: the library builds. __version__ is 0.1.0 both from the editable install and from the installed wheel. uv_build>=0.12.22,<0.13.0 is the uv init convention, and 0.12.22 is on PyPI. A local uv older than 0.12.22 fetches uv_build instead of using its built-in backend, which is fine. No hatchling references remain in the template, the docs or the rendered projects.

Posted by Claude Code on Matt's behalf.

MattFisher and others added 3 commits October 2, 2026 21:34
…pdate

uv_build ships only the license files [project] license-files names, so
MIT projects name LICENSE; hatchling included it by default.

Updating a library across 1.10.0 merges the template's version = "0.1.0"
into pyproject.toml with no conflict, silently resetting a released
project. A copier migration now sets it back to the __version__ the
project's __init__.py had at HEAD, which copier leaves untouched since it
only updates a clean tree. Tested by updating a v1.9.1 library released at
0.4.2: pyproject.toml ends at 0.4.2.

From the review on #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unnable

Checks from a dispatched run don't satisfy a required check, but a pull
request opened with the default token does start its pull_request runs,
in an approval-required state. So Prepare release no longer dispatches
ci.yml or needs actions: write, and ci.yml drops its dispatch trigger; the
PR body says to approve the runs.

Prepare release now fails clearly when run from another branch, on a
fragment with entries but no heading, on an existing tag, or with a
release PR already open, and force-pushes its own branch so a re-run after
a partial failure works. Release on merge checks the base against the
default branch, creates the tag at the merge commit itself (refusing one
already on another commit), and skips an existing tag or release on
re-run. Release notes lose their leading blank line, and publish.yml takes
the current checkout and setup-uv pins.

From the review on #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RELEASING.md, the scaffold README and the template CHANGELOG say to click
Approve workflows to run on the release PR, describe the re-run and tag
safeguards, and replace the upgrade note's wrong conflict list with what
the migration does. Template CI drops the ci.yml dispatch check and checks
that LICENSE reaches the library's wheel.

From the review on #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MattFisher

Copy link
Copy Markdown
Author

Thanks, this caught a real design flaw. Actioned in c00283c, cfe5dac and 737cdd4:

  1. Dispatched CI. Confirmed in GitHub's docs. "Troubleshooting required status checks" says checks from a workflow_dispatch run "do not satisfy a required status check in a branch ruleset". "Triggering a workflow" says a pull request opened with GITHUB_TOKEN starts its pull_request runs "in an approval-required state". So the gh workflow run ci.yml step, actions: write and ci.yml's dispatch trigger are gone. The PR body, RELEASING.md, the README and the CHANGELOG say to click Approve workflows to run.
  2. Version reset on update. A copier migration (_migrations, version v1.10.0, after stage, libraries only) sets [project] version back to the __version__ that git show HEAD: finds in the old __init__.py. I tested it on a v1.9.1 library released at 0.4.2 with a local edit. It printed "restored version 0.4.2", and only __init__.py conflicts. The Upgrading note is rewritten to match.
  3. LICENSE. MIT projects set license-files = ["LICENSE"]. Template CI now checks the wheel has dist-info/licenses/LICENSE.
  4. Existing tag. release-on-merge creates the ref itself and runs gh release create --verify-tag. It refuses a tag on another commit and skips one already on the merge commit. Prepare release refuses an existing tag, checked through the API, since the checkout keeps no credentials.
  5. Re-runs. Prepare release refuses when a release PR is already open, and otherwise force-pushes its own branch, so a re-run after a failed gh pr create works. release-on-merge skips an existing release, then dispatches publish again.
  6. Nits. A fragment with entries but no heading fails with its path. Notes lose the leading blank line. A run from another branch fails as a step, not a skipped job. release-on-merge compares the base with the default branch instead of filtering on main. publish.yml takes checkout 7.0.1 and setup-uv 10.0.1.

All variants (app, PyPI library, library without PyPI) render valid YAML, and zizmor finds nothing in any of them. The full template CI step and the rendered hooks pass locally.

Posted by Claude Code on Matt's behalf.

@MattFisher
MattFisher merged commit 1d6d990 into main Oct 3, 2026
3 checks passed
@MattFisher
MattFisher deleted the feat/release-workflows branch October 3, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants