Skip to content

Commit b67c59f

Browse files
jdclaude
andauthored
fix(release): switch draft preparation to workflow_dispatch (#1587)
The two-stage flow shipped in #1583 wired the binary-attach stage to `release: created` on a draft save — but GitHub Actions intentionally filters release events for drafts: nothing fires until the release becomes visible. The webhook docs imply otherwise, hence the bug. Result: a maintainer who saves a draft via the UI sees no workflow run, no binaries attached, and ends up publishing an empty release (which the assert job then blocks from reaching PyPI). Restructure stage 1 to run on `workflow_dispatch` instead. The maintainer goes to Actions → "release" → "Run workflow", enters the calver tag, and the workflow: 1. Builds the wheel matrix with the tag stamped in (new `override-version` input on `build-wheels.yml`; the prior `${GITHUB_REF#refs/tags/}` derivation doesn't work here because no tag exists yet — it gets created by `gh release create` later in the run). 2. Extracts the binary from each wheel, packages the `mergify-<target>.{tar.gz,zip}` + `SHA256SUMS` set. 3. Creates the release via `gh release create <tag> --draft --generate-notes` with all assets attached and notes auto-generated from the commit log. Stage 2 (`release: published`) is unchanged: assert the six expected assets are present, rebuild wheels, push to PyPI. `RELEASING.md` documents the new flow + the "do not click 'Draft a new release' in the UI" gotcha — that path creates a draft without binaries and ends up blocked at assert time. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b596c2d commit b67c59f

3 files changed

Lines changed: 208 additions & 47 deletions

File tree

.github/workflows/build-wheels.yml

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,25 @@ on:
2626
stamp-version:
2727
description: |
2828
When true, replace `version = "..."` in pyproject.toml
29-
with the git tag value (`${GITHUB_REF#refs/tags/}`)
30-
before maturin runs. Keep false for PR smoke builds so
31-
CI exercises the full path with the placeholder version.
29+
before maturin runs. Source is `inputs.override-version`
30+
when set (the workflow_dispatch draft path doesn't have a
31+
tag yet), otherwise `${GITHUB_REF#refs/tags/}`. Keep false
32+
for PR smoke builds so CI exercises the full path with
33+
the placeholder version.
3234
required: false
3335
default: false
3436
type: boolean
37+
override-version:
38+
description: |
39+
Explicit version string to stamp into the wheel. Lets the
40+
dispatch-driven draft job pass its `tag` input through —
41+
no git tag exists yet at that point, so the build can't
42+
derive it from `$GITHUB_REF`. Ignored when `stamp-version`
43+
is false; required when both `stamp-version` is true and
44+
the caller wasn't triggered by a `release` event.
45+
required: false
46+
default: ''
47+
type: string
3548

3649
jobs:
3750
build-wheels:
@@ -62,12 +75,25 @@ jobs:
6275
with:
6376
python-version: 3.14
6477

65-
- name: Stamp wheel version from git tag
78+
- name: Stamp wheel version
6679
if: inputs.stamp-version
6780
shell: bash
6881
run: |
6982
set -eu
70-
VERSION="${GITHUB_REF#refs/tags/}"
83+
# `override-version` wins; falls back to the git tag the
84+
# `release: published` flow rides on. The dispatch path
85+
# passes `inputs.tag` here because no tag exists yet — it
86+
# gets created by `gh release create` later in the
87+
# workflow.
88+
if [ -n "${{ inputs.override-version }}" ]; then
89+
VERSION='${{ inputs.override-version }}'
90+
else
91+
VERSION="${GITHUB_REF#refs/tags/}"
92+
fi
93+
if [ -z "${VERSION}" ] || [ "${VERSION}" = "${GITHUB_REF}" ]; then
94+
echo "::error::stamp-version is true but no version source is available (no tag in GITHUB_REF, no override-version input)"
95+
exit 1
96+
fi
7197
# `sed -i.bak`/rm form works on both BSD (macOS) and GNU
7298
# (Linux) sed without divergent flag handling. On Windows
7399
# the runner ships GNU sed via Git Bash, which honours the
@@ -131,12 +157,22 @@ jobs:
131157
with:
132158
python-version: 3.14
133159

134-
- name: Stamp wheel version from git tag
160+
- name: Stamp wheel version
135161
if: inputs.stamp-version
136162
shell: bash
137163
run: |
138164
set -eu
139-
VERSION="${GITHUB_REF#refs/tags/}"
165+
# Same source-priority as the wheel job above —
166+
# `override-version` wins, falls back to the tag.
167+
if [ -n "${{ inputs.override-version }}" ]; then
168+
VERSION='${{ inputs.override-version }}'
169+
else
170+
VERSION="${GITHUB_REF#refs/tags/}"
171+
fi
172+
if [ -z "${VERSION}" ] || [ "${VERSION}" = "${GITHUB_REF}" ]; then
173+
echo "::error::stamp-version is true but no version source is available"
174+
exit 1
175+
fi
140176
sed -i.bak -E "s/^version = \".*\"$/version = \"${VERSION}\"/" pyproject.toml
141177
rm -f pyproject.toml.bak
142178

.github/workflows/release.yml

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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

2634
on:
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

3056
jobs:
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

RELEASING.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Releasing `mergify-cli`
2+
3+
Two-stage flow keyed off the immutable-releases policy on
4+
`Mergifyio/*` — once a release is published, asset metadata is
5+
locked. So binaries are attached to the release **as a draft**
6+
before the maintainer clicks Publish.
7+
8+
## Stage 1 — build the draft
9+
10+
1. Go to **Actions****release****Run workflow** (top right of
11+
the workflow page).
12+
2. Fill in the **tag** field with the new calver, e.g.
13+
`2026.6.12.1`. Leave **target_commitish** empty unless you're
14+
cherry-picking a release commit off an older branch.
15+
3. Click **Run workflow**.
16+
17+
The workflow builds the wheel matrix (Linux x86_64/aarch64, macOS
18+
x86_64/aarch64, Windows x86_64), extracts the `mergify` binary out
19+
of each, packages `mergify-<target>.{tar.gz,zip}` + `SHA256SUMS`,
20+
and runs `gh release create <tag> --draft --generate-notes` to
21+
create the release with the assets attached and notes
22+
auto-generated from PRs merged since the previous tag. Takes ~10
23+
minutes.
24+
25+
## Stage 2 — review and publish
26+
27+
1. Go to **Releases** → the new draft.
28+
2. Review the auto-generated notes; edit if needed (drafts are
29+
mutable).
30+
3. Confirm all six asset names are listed:
31+
- `mergify-x86_64-unknown-linux-gnu.tar.gz`
32+
- `mergify-aarch64-unknown-linux-gnu.tar.gz`
33+
- `mergify-x86_64-apple-darwin.tar.gz`
34+
- `mergify-aarch64-apple-darwin.tar.gz`
35+
- `mergify-x86_64-pc-windows-msvc.zip`
36+
- `SHA256SUMS`
37+
4. Click **Publish release**.
38+
39+
Publishing fires `release: published`, which kicks the second half
40+
of the workflow: assert all six assets are present, rebuild wheels
41+
with the same version stamp, and publish to PyPI through
42+
Trusted-Publisher. Takes ~10 minutes.
43+
44+
## Do not
45+
46+
- **Don't click "Draft a new release" in the Releases UI.** That
47+
path creates a draft without binaries; once you publish it the
48+
release is immutable and the asset assertion will fail, blocking
49+
PyPI. To recover you'd have to delete the release and re-run
50+
stage 1 with the same tag.
51+
- **Don't run `gh release create` from your laptop.** The
52+
workflow does this so the binary build is reproducible and the
53+
PyPI Trusted-Publisher identity matches the tag.
54+
55+
## If stage 2 fails
56+
57+
The release is already published and immutable. If the assert job
58+
fails (binaries missing) or the PyPI publish fails (transient
59+
PyPI outage, version conflict, etc.):
60+
61+
- **Asset assertion failed** — someone bypassed the workflow.
62+
Delete the release, re-run stage 1.
63+
- **PyPI publish failed** — re-run just the `publish` job from
64+
Actions. The wheels were built; PyPI is the only step left.

0 commit comments

Comments
 (0)