@@ -2,50 +2,84 @@ name: release
22
33# Two-stage release flow, forced by GitHub's immutable-releases
44# policy (assets can't be added after publish — PR #1569 dropped a
5- # prior `gh release upload` job for the same reason):
5+ # prior `gh release upload` job for the same reason). Naïvely you'd
6+ # expect `release: created` to fire for draft saves and let us
7+ # attach binaries before publish, but GitHub Actions filters
8+ # release events on drafts: nothing fires until the release becomes
9+ # visible. So the prepare stage runs from `workflow_dispatch`
10+ # instead — the maintainer drives it from the Actions UI.
611#
7- # 1. `release: created` with `draft == true` — maintainer clicks
8- # "Save draft" in the UI. We build wheels, extract the binary
9- # from each, and attach `mergify-<target>.{tar.gz,zip}` +
10- # `SHA256SUMS` to the still-mutable draft. The assets appear in
11- # the draft's asset list within a few minutes.
12+ # Stage 1 — prepare-draft (`workflow_dispatch`):
13+ # Maintainer opens Actions → "release" → "Run workflow" → enters
14+ # the tag (e.g. `2026.6.12.1`). The workflow builds the wheel
15+ # matrix, extracts the binary from each wheel, packages
16+ # `mergify-<target>.{tar.gz,zip}` + `SHA256SUMS`, and creates the
17+ # GitHub Release as a *draft* with all assets attached and notes
18+ # auto-generated from the commit log via `--generate-notes`.
1219#
13- # 2. `release: published` — maintainer reviews the now-asset-laden
14- # draft and clicks "Publish release". We assert the expected
15- # asset names are attached (blocks the PyPI step with a clear
16- # error if the maintainer bypassed the draft flow and published
17- # directly — the release is then immutable and binaries can't be
18- # backfilled), then build wheels again and publish to PyPI.
20+ # Stage 2 — publish-stable (`release: published`):
21+ # Maintainer goes to Releases, reviews the draft (notes + asset
22+ # list), optionally edits, clicks "Publish release". This fires
23+ # `release: published`. We assert the expected asset names are
24+ # present (sanity net if the maintainer somehow stripped them
25+ # before publish), then build wheels again and push to PyPI.
26+ # The release is immutable from this point on.
1927#
20- # Yes, wheels are built twice (once per stage). The alternative —
21- # stash wheels somewhere between runs — adds storage plumbing for
22- # ~5 min of CI savings; not worth it. `build-wheels.yml` is shared
23- # with `ci.yaml` so adding a new platform target there extends both
24- # stages automatically.
28+ # Yes, wheels are built twice (once per stage). Stashing them
29+ # between two separately-triggered runs adds cross-run artifact
30+ # plumbing for ~5 min of CI savings; not worth it. `build-wheels.yml`
31+ # is shared with `ci.yaml` so adding a new platform target there
32+ # extends both stages automatically.
2533
2634on :
35+ workflow_dispatch :
36+ inputs :
37+ tag :
38+ description : |
39+ Release tag to publish (e.g. `2026.6.12.1`). The workflow
40+ creates this tag and a matching draft release with all
41+ platform binaries attached. The maintainer then publishes
42+ the draft from the Releases UI.
43+ required : true
44+ type : string
45+ target_commitish :
46+ description : |
47+ Branch or commit SHA to tag. Defaults to the branch the
48+ workflow runs from — keep as-is unless you're cherry-
49+ picking a release commit off an older line.
50+ required : false
51+ type : string
52+ default : ' '
2753 release :
28- types : [created, published]
54+ types : [published]
2955
3056jobs :
31- # ---------------- Draft stage: attach binaries ----------------
57+ # ---------------- Stage 1: prepare draft ----------------
3258
3359 build-wheels-for-draft :
3460 name : build wheels (draft stage)
35- if : github.event.action == 'created' && github.event.release.draft
61+ if : github.event_name == 'workflow_dispatch'
3662 uses : ./.github/workflows/build-wheels.yml
3763 with :
3864 stamp-version : true
65+ override-version : ${{ inputs.tag }}
3966
40- upload-binaries :
41- name : attach binaries to the draft release
42- if : github.event.action == 'created' && github.event.release.draft
67+ create-draft-release :
68+ name : create draft release with binaries
69+ if : github.event_name == 'workflow_dispatch'
4370 needs : build-wheels-for-draft
4471 runs-on : ubuntu-24.04
4572 permissions :
46- # `gh release upload ` writes to the release.
73+ # `gh release create ` writes the release.
4774 contents : write
4875 steps :
76+ - uses : actions/checkout@v6.0.2
77+ with :
78+ # `--generate-notes` needs full history to walk back to
79+ # the previous tag.
80+ fetch-depth : 0
81+ fetch-tags : true
82+
4983 - uses : actions/download-artifact@v8
5084 with :
5185 # Skip `wheel-sdist` (single dash); each wheel artifact is
@@ -87,21 +121,49 @@ jobs:
87121 echo "Built release assets:"
88122 ls -la dist
89123
90- - name : Upload assets to the draft release
124+ - name : Create draft release
91125 env :
92126 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
93127 GH_REPO : ${{ github.repository }}
128+ # Read inputs via env, not `${{ }}` interpolation, so a
129+ # tag or commitish value containing shell metacharacters
130+ # can't break out of the script. With direct
131+ # interpolation, `inputs.tag = "foo'; rm -rf /; '"` would
132+ # render the script as runnable injected commands;
133+ # through env they reach bash as plain string values.
134+ INPUT_TAG : ${{ inputs.tag }}
135+ INPUT_TARGET : ${{ inputs.target_commitish }}
136+ shell : bash
94137 run : |
95- # `--clobber` so re-saving the draft (which can re-fire
96- # `release: created`) replaces stale assets instead of
97- # erroring out.
98- gh release upload "${{ github.event.release.tag_name }}" dist/* --clobber
138+ set -euo pipefail
139+ # Build the gh argument list as an array — keeps each
140+ # element a single argv slot regardless of the value's
141+ # contents.
142+ args=(
143+ "${INPUT_TAG}"
144+ --draft
145+ --generate-notes
146+ --title "${INPUT_TAG}"
147+ )
148+ if [ -n "${INPUT_TARGET}" ]; then
149+ args+=(--target "${INPUT_TARGET}")
150+ fi
151+ # `--draft` keeps the release mutable until the maintainer
152+ # clicks Publish. `--generate-notes` builds a changelog
153+ # from PRs merged since the previous tag — overridable in
154+ # the UI before publish if needed.
155+ gh release create "${args[@]}" dist/*
156+
157+ url=$(gh release view "${INPUT_TAG}" --json url --jq .url)
158+ echo
159+ echo "Draft release created: ${url}"
160+ echo "Review the draft and click Publish in the Releases UI to ship."
99161
100- # ---------------- Publish stage: assert + PyPI ----------------
162+ # ---------------- Stage 2: publish + PyPI ----------------
101163
102164 assert-binaries-present :
103165 name : assert binary assets attached
104- if : github.event.action == 'published'
166+ if : github.event_name == 'release' && github. event.action == 'published'
105167 runs-on : ubuntu-24.04
106168 permissions :
107169 contents : read
@@ -134,32 +196,31 @@ jobs:
134196 cat <<EOF >&2
135197 ::error::Release $tag is missing assets: ${missing[*]}
136198
137- Binaries are attached by the 'upload-binaries ' job, which only runs when
138- a release is saved as a *draft* ( release: created event). It looks like
139- this release was published directly without first being saved as a
140- draft. Because release metadata is now immutable, the assets can't be
199+ Binary assets are attached by the 'create-draft-release ' job, which
200+ runs from Actions → " release" → "Run workflow". Did you create this
201+ release through the Releases UI directly instead? That path doesn't
202+ attach binaries, and the release is now immutable so they can't be
141203 backfilled — the PyPI publish is being blocked so the package and the
142204 binary distributions don't diverge.
143205
144- Recovery: delete this release, then re-create it via the UI making sure
145- to click "Save draft" first. Wait for the binaries to appear in the
146- draft's asset list before clicking "Publish".
206+ Recovery: delete this release, then re-create it via Actions →
207+ "release" → "Run workflow" with the same tag.
147208 EOF
148209 exit 1
149210 fi
150211 echo "All expected assets present — proceeding."
151212
152213 build-wheels-for-publish :
153214 name : build wheels (publish stage)
154- if : github.event.action == 'published'
215+ if : github.event_name == 'release' && github. event.action == 'published'
155216 needs : assert-binaries-present
156217 uses : ./.github/workflows/build-wheels.yml
157218 with :
158219 stamp-version : true
159220
160221 publish :
161222 name : publish to PyPI
162- if : github.event.action == 'published'
223+ if : github.event_name == 'release' && github. event.action == 'published'
163224 runs-on : ubuntu-24.04
164225 needs :
165226 - assert-binaries-present
0 commit comments