Skip to content

Share the release workflows, and release the template with them - #8

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

MattFisher merged 5 commits into
mainfrom
feat/shared-release-workflows

Conversation

@MattFisher

@MattFisher MattFisher commented Oct 3, 2026 •

Copy link
Copy Markdown

Why

#6 put the release steps into each generated project as scaffolded workflows. Every fix to them would then need a copier update and a merge in each repo. This PR moves the steps into reusable workflows here, like python-ci.yml, so fixes reach every project when v1 moves. The template also releases itself with them, so each template release exercises them first.

What changes

Reusable workflows (18cfc59)

  • .github/workflows/prepare-release.yml and release-on-merge.yml are workflow_call workflows, and the scaffolded files become thin callers pinned to @v1.
  • version-source: pyproject (generated projects): the version is [project] version, bumped with uv version --bump, and scriv runs from the dev group.
  • version-source: tags (this repo): the next version comes from the latest vX.Y.Z tag, and scriv runs with uvx.
  • dispatch-workflow starts a workflow on the new tag. A PyPI library's caller sets publish.yml. Publishing stays in the project's own workflow, since PyPI trusted publishing can't run from a reusable workflow.
  • The reusable workflows declare no permissions, so they inherit what the caller grants. A called job that asks for more than its caller grants fails at startup, so an app's caller can leave out actions: write.
  • Prepare release now also keeps Keep a Changelog compare links current where a CHANGELOG.md has them, and runs mdformat only where the project's pre-commit config has it.
  • All of Automate releases: Prepare release and release-on-merge workflows #6's safeguards carry over unchanged.

The template releases itself (68abdda)

  • template-prepare-release.yml and template-release-on-merge.yml call the reusable workflows from this checkout (./) in tags mode.
  • 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.yml gains a dispatch trigger for v1.* tag refs. A release published by hand from the UI still triggers it.
  • The template's own changelog moves to scriv: changelog.d/scriv.ini (there's no pyproject.toml here), a TEMPLATE.md with an Upgrading category, and the insert marker.
  • The [Unreleased] entries move into one fragment, rewritten for the shared workflows. It includes the --trust upgrade step from the review of Release 1.10.0 #7.

Docs (b0f995a)

  • The README covers the release workflows, how the template releases, and uvx copier update --trust.
  • It and the template-update PR body now say that CI on an Actions-opened PR waits for Approve workflows to run, instead of never running.

Testing

  • Template CI's render step and the rendered projects' own hooks, actionlint and zizmor included, pass locally.
  • actionlint finds nothing in the changed root workflows. zizmor 1.26.1 in auditor persona, the mode generated projects use, is clean on them, with two documented ignores on release-on-merge's inherited permissions. zizmor 1.30's only other output is a low "self-repository" hint suggesting $/… over ./…. ./ is GitHub's documented syntax.
  • Tags-mode dry run on a clone of this branch, with the real tags: auto picks minor and computes 1.10.0 from v1.9.1. scriv collects the fragment using scriv.ini, and the compare links gain [1.10.0]. bump-v1's own changelog guard then passes for v1.10.0.
  • Pyproject-mode dry run in a rendered library: patch picked, 0.1.0 → 0.1.1, collected, and mdformat passes.
  • Three variants rendered: app, PyPI library, and library without PyPI. Only the PyPI library's caller grants actions: write and passes publish.yml.
  • Not testable outside GitHub: the reusable-call permission handling, and the dispatch of bump-v1. The first template release through this flow is the live test.

Review fixes (bf0218b, cd317c7)

  • The release-on-merge concurrency group is keyed on the branch, so an unrelated PR closing can't cancel a pending release run.
  • A fragment quoting scriv's marker is refused. scriv would silently drop the lines above it.
  • bump-v1's dispatch path refuses anything but the newest final v1.X.Y tag.
  • prepare-release declares its job permissions. scriv and pre-commit are pinned in tags mode.

After merge

  1. Turn on Settings → Actions → General → Allow GitHub Actions to create and approve pull requests here. It's off, so step 1 would fail at gh pr create, though a re-run is safe once it's on.
  2. Run Prepare template release with auto (or minor, never patch: the migration is keyed to v1.10.0). It should open "Release v1.10.0".
  3. Approve its CI, add a summary paragraph, and merge.
  4. Check that v1.10.0 is an annotated tag and that v1 moved.
  5. Close Release 1.10.0 #7, which this replaces.

🤖 Generated with Claude Code

MattFisher and others added 3 commits October 3, 2026 11:23
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@MattFisher MattFisher mentioned this pull request Oct 3, 2026
@MattFisher

Copy link
Copy Markdown
Author

Review of b0f995a

I reviewed this in a fresh clone with all tags. I rendered the app, the PyPI library and the non-PyPI library, and ran the extracted workflow steps locally. Each point says how I checked it: ran (executed locally), docs (GitHub docs), or reasoning (couldn't run outside GitHub).

The design holds up. Three findings matter before the first release. The rest are small.

Findings, most important first

1. Prepare template release will fail on this repo as it is configured now (ran, read-only API)

  • Where: README.md:243-247 and the "After merge" plan. prepare-release.yml:13-14 documents the requirement, but this repo doesn't meet it.
  • Problem: gh api repos/Generality-Labs/python-project-template/actions/permissions/workflow returns "can_approve_pull_request_reviews": false. That is the "Allow GitHub Actions to create and approve pull requests" checkbox.
  • Failure: step 1 of the plan pushes release/v1.10.0, then gh pr create fails with "GitHub Actions is not permitted to create or approve pull requests". A re-run after turning the setting on is safe, because of the force push.
  • Fix: turn the setting on before step 1. If the org policy blocks it, that has to change too. I couldn't read the org setting. Add it as a one-time step under "Releasing this template" in the README.

2. A busy moment can silently cancel the release run (docs)

  • Where: .github/workflows/template-release-on-merge.yml:16-18, and the scaffolded template/.github/workflows/release-on-merge.yml.jinja:15-17.
  • Problem: every closed PR starts a run in the single release-on-merge group. GitHub keeps one running and one pending run per group. "By default, any existing pending job or workflow in the same concurrency group will be canceled." cancel-in-progress: false doesn't prevent that.
  • Failure: the release PR merges while another PR's run is still in progress. Its run is pending. A third PR closes, for example a Dependabot batch or a stack retargeting after its base merged. The release run is cancelled. You get no tag, no release and no v1 move, and only a grey "cancelled" run shows it. Re-running it recovers.
  • Fix: key the group on the branch, for example group: release-on-merge-${{ github.event.pull_request.head.ref }}. Unrelated PRs then never share a group with the release. queue: max also works, if actionlint accepts it.

3. scriv silently drops fragment text above any line that quotes the insert marker (ran)

  • Where: prepare-release.yml:121 and :135 (scriv collect). A guard fits in the "Choose the version bump" step at :88-98.
  • Problem: scriv 1.8.0 MdTools.parse_text starts each fragment after the first line that contains scriv-insert-here. Inside backticks counts too.
  • Failure: I put this fragment in the rendered library: ### Added / - First. / - Mentions `<!-- scriv-insert-here -->` inline. / - Third. / ### Fixed / - Fix. The collected section kept only - Third. and the Fixed section. The heading and the first two bullets were gone, and the fragment file was deleted. bump-v1's changelog guard still passes, because the ## [X.Y.Z] heading exists. This repo's own history makes it likely: the 1.9.0 entry quotes the marker twice (CHANGELOG.md:25 and :27).
  • Fix: fail early in the choose step when any fragment contains scriv-insert-here, and tell the author to describe the marker instead of quoting it.
  • The CHANGELOG itself is fine. scriv inserts after the first line containing the marker. That is the real one at CHANGELOG.md:15, and the two quoted copies come after it. The quoted scriv-end-here on line 27 doesn't lose anything either. The collect diff was 24 lines added and 1 changed.

4. zizmor reports more than the PR description says (ran)

  • Where: prepare-release.yml:1 and :45, release-on-merge.yml:1 and :34.
  • Problem: with --persona=auditor, the mode the generated projects use, zizmor 1.26.1 and 1.30.1 report 4 medium excessive-permissions findings on the new reusable workflows. They aren't the low self-repository hint only. The default persona suppresses them, and template CI doesn't run zizmor on root workflows, so nothing fails today. On main, auditor mode is clean.
  • Fix: in prepare-release.yml, declare the job's contents: write and pull-requests: write. Every caller grants exactly those. In release-on-merge.yml, keep inheriting, since actions: write is conditional. Add # zizmor: ignore[excessive-permissions] there with the reason. Also correct the Testing note in the PR description.

5. The dispatch path of bump-v1 skips two guards the release path has (reasoning)

  • Where: .github/workflows/bump-v1.yml:37-39.
  • Problem: on a dispatch there is no prerelease check, and no check that the tag is the newest v1.x.y. The main check accepts an ancestor commit ("behind").
  • Failure: a maintainer picks v1.9.1 in the "Use workflow from" menu. v1 moves back to 1.9.1 for every consumer. The changelog check passes, since 1.9.1 has its section. A v1.10.0-rc1 tag is stopped only if the changelog lacks an [1.10.0-rc1] section.
  • Fix: optional. Either refuse a dispatch unless the tag matches ^v1\.[0-9]+\.[0-9]+$ and is the highest such tag, or document dispatch as the rollback lever. Only maintainers can dispatch, so I'd treat this as low.

6. Nits

  • template/.github/workflows/release-on-merge.yml.jinja:5: the app renders # section. Other pull / # requests are ignored., an odd wrap.
  • prepare-release.yml:135 and :164: tags mode runs unpinned uvx --from 'scriv>=1.8' and uvx pre-commit in a job with write permissions. Pinning scriv==1.8.0 makes template releases reproducible.
  • Release plan: pick auto or minor, never patch. A 1.9.2 would ship uv_build without the v1.10.0 migration. A later 1.10.0 update then finds the old __version__ already gone from HEAD, prints "version left as is", and the 0.1.0 reset sticks.

What I checked and found fine

  • Reusable-workflow context (docs). "When a reusable workflow is triggered by a caller workflow, the github context is always associated with the caller workflow." So github.event, github.ref, github.repository and github.event.repository.default_branch belong to the caller. That includes the job-level if: on the caller's pull_request event. The workflow_dispatch payload carries repository, so the branch check works. github.token is available in called workflows.
  • Permissions (docs and reasoning). Caller permissions "can be only downgraded (not elevated) by the called workflow". The called jobs declare none, so they get exactly what the caller job grants. An app caller that grants only contents: write can't cause a startup failure. A missing actions: write with dispatch-workflow set would fail at runtime, after the tag and release exist, and a re-run is safe.
  • Inputs (docs, and actionlint ran clean). A choice input is a string, so passing bump: ${{ inputs.bump }} to a type: string input works. actionlint 1.7.12 checks local reusable-workflow inputs and found nothing in the changed root workflows.
  • ./ refs (docs). "the called workflow is from the same commit as the caller workflow." On a merged PR, GITHUB_REF is the base branch and the run uses the merge commit.
  • bump-v1 dispatch (docs and reasoning).
    • workflow_dispatch is one of the two events a GITHUB_TOKEN can start. The file is on main with the trigger after merge.
    • With --ref v1.10.0, github.ref_type is tag, github.ref_name is v1.10.0, and github.sha is the "Last commit on the GITHUB_REF branch or tag". The tag is still lightweight at that point.
    • The main check returns identical or behind, and the contents API reads at that SHA.
    • Annotate sees object.type == commit and rewrites the ref. Move v1 PATCHes it.
    • event.release.tag_name || ref_name falls back correctly.
    • On the release event, the first clause still requires a non-prerelease v1. tag, and the dispatch clause is false.
    • A release made by gh release create with GITHUB_TOKEN raises no release event, so bump-v1 doesn't run twice.
  • Tags mode (ran).
    • auto picked minor, 1.9.1 became 1.10.0, the fragment was collected and deleted, and [1.10.0] and [unreleased] compare links were added.
    • bump-v1's changelog check passed for v1.10.0 and failed for v1.11.0. The release-notes awk extracted the section.
    • With the real tags, patch gave 1.9.2. With v1.9.10 and v1.10.0-rc1 added, sort -V picked v1.9.10, ignoring the rc tag, v2 and v1.9.2.1. major gave 2.0.0.
    • With no tags, or only v1, the step failed with "no vX.Y.Z tag to bump from".
    • %% in scriv.ini is required: with a single %, configparser raises InterpolationSyntaxError. uvx --from scriv scriv create uses TEMPLATE.md. mdformat is correctly skipped, since this repo has no .pre-commit-config.yaml.
  • Pyproject mode (ran). In a rendered library, a Fixed fragment picked patch, 0.1.0 became 0.1.1 in pyproject.toml and uv.lock, the fragment was collected, and mdformat passed.
  • Callers (ran).
    • All three variants render valid YAML with uses: Generality-Labs/python-project-template/.github/workflows/{prepare-release,release-on-merge}.yml@v1.
    • Prepare grants contents: write and pull-requests: write. Release grants contents: write, plus actions: write and dispatch-workflow: publish.yml for the PyPI library only.
    • All pre-commit hooks pass in all three, including actionlint and offline zizmor. Online zizmor with a token passes in the PyPI library. The generated .github/zizmor.yml ref-pins {{ github_owner }}/*, so @v1 is accepted. This repo has no .github/zizmor.yml, and its own callers use ./.
  • Changelog fragment (read against the code). It is accurate and nothing in it is stale from Automate releases: Prepare release and release-on-merge workflows #6's scaffolded design. It includes the --trust note. The claims about publish.yml match the rendered file: the v* ref guard, the wheel-version check, checkout 7.0.1 and setup-uv 10.0.1. Every kind gets literal: pyproject.toml: project.version.
  • Release plan (ran a local simulation; nothing pushed).
    • Run prepare with auto: tags mode gives 1.10.0. After the setting in item 1, it opens "Release v1.10.0" from release/v1.10.0. That branch doesn't clash with Release 1.10.0 #7's docs/changelog-1.10.0.
    • Approve and merge: the required checks have no path filters. Release on merge tags the merge commit with a lightweight tag, creates the release with --verify-tag, and dispatches bump-v1. bump-v1 annotates v1.10.0 and moves v1.
    • In a clone, I committed the collected changelog, tagged v1.10.0 annotated and moved v1 lightweight onto the same commit. Copier 9.18.2 then read the version as 1.10.0.
    • Updating projects rendered at v1.9.1: without --trust, copier stopped for both the app and the library. With --trust, the library's v1.10.0 migration ran ("restored version 2.3.4") and the new callers arrived.
    • One thing to know: copier gets the version from dunamai, which sorts tags by -creatordate. It doesn't use git describe as the bump-v1 comment says. v1.10.0 wins because its annotation is dated after the merge commit, which is v1's date. When I made both in the same second, copier read "1" and refused the update as a downgrade. The real flow is seconds to minutes apart, so this doesn't bite.

Posted by Claude Code on Matt's behalf.

MattFisher and others added 2 commits October 3, 2026 11:39
- 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 <noreply@anthropic.com>
…feguards

From the review on #8.

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

Copy link
Copy Markdown
Author

Thanks. Actioned in bf0218b and cd317c7:

  1. The Actions setting is off here. It's now a one-time step under "Releasing this template" in the README, and step 0 of the PR's after-merge plan. Matt needs to turn it on. The org policy may need changing too.
  2. Concurrency. The group is release-on-merge-${{ github.event.pull_request.head.ref }}, in this repo's caller and in the scaffold, so unrelated PRs never share a group with a release run.
  3. scriv marker. Reproduced with scriv 1.8.0: a fragment quoting the insert marker lost its heading and first two bullets. "Choose the version bump" now refuses any fragment containing scriv-insert-here or scriv-end-here, and the guard fires in a dry run.
  4. zizmor auditor.
    • prepare-release declares contents: write and pull-requests: write on its job, with permissions: {} at workflow level.
    • release-on-merge keeps inheriting, with a commented # zizmor: ignore[excessive-permissions].
    • zizmor 1.26.1 --persona=auditor is now clean on the changed workflows, with 2 ignored. The PR description's Testing note is corrected.
  5. bump-v1 dispatch. A new first step refuses anything but a final v1.X.Y tag that is the newest one, read through git/matching-refs. Checked against the live tags.
  6. Nits.
    • The comment wrap is fixed.
    • Tags mode pins scriv==1.8.0 and pre-commit==4.6.2.
    • The plan says auto or minor, never patch.

The dunamai sorting note is useful. The flow's gap between merge and annotation makes it safe in practice, so I left bump-v1's comment as is for now.

After the fixes, the template CI render step, the rendered hooks (actionlint and zizmor) and the tags-mode dry run all pass locally. The dry run gives 1.10.0, and bump-v1's changelog guard passes on it.

Posted by Claude Code on Matt's behalf.

@MattFisher
MattFisher merged commit 9171e88 into main Oct 3, 2026
3 checks passed
@MattFisher
MattFisher deleted the feat/shared-release-workflows branch October 3, 2026 01:49
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.

1 participant