Skip to content

Commit 4ce35d2

Browse files
alexkromanclaude
andauthored
Make release workflow trigger on version bump merge, not dispatch (#126)
* Start the release when the version-bump PR merges Releasing took two deliberate acts from the maintainer that were really the same decision: merge the version-bump PR, then go dispatch `release` with the same version the PR just landed. The second one carried no new judgement — it restated the first — but it was the one step that couldn't be done from a review page, so a release stalled on someone opening the Actions tab. `release.yml` now also triggers on a push to `main` that touches `App/Blurt/project.yml`, and a new Linux `resolve` job decides whether that push is actually a release: it diffs `CFBundleShortVersionString` against the push's previous commit and checks no `vX.Y.Z` tag exists yet. A project.yml edit that adds a source file resolves to `release=false` and skips the build and publish jobs; only a bump landing on main goes through. Running first, on ubuntu, means a non-release push costs seconds and never reaches the runner holding the Developer ID key. The dispatch path stays, and is what re-runs a failed build, republishes, and does the non-main dry run — `skip_checks`, `skip_smoke`, and `republish` are dispatch-only inputs, so the merged-bump path always runs the full checks. Both jobs still pin `github.sha`, so a release is still exactly one reviewed commit, and publishing still parks on the `release-publish` approval gate. The `resolve` job supersedes the build job's version guard as the source of the version; the guard stays as a re-check on the signing runner itself, so the artifact name and the built binary can't disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F952B3qz1QeXYv5sPGwPh3 * Start release-bump by pushing a marker branch The last dispatch in the release flow was the first one. Dispatching a workflow needs the Actions UI or `actions: write`; a chat or web session typically has neither, but does have push. So the one step that could kick a release off was the one step an agent couldn't do, and every release still began with the maintainer opening the Actions tab. `release-bump.yml` now also triggers on a push of `release/v[0-9]*`. The branch name is the request — `release/v0.1.37` asks for 0.1.37, with no default_target guessing, because a marker that named no version would be asking the workflow to guess what its own name meant. The job checks out main, verifies the marker is an ancestor of main, bumps on top of main's tip, and force-pushes the result onto the same branch with the lease pinned to the sha it vetted. The ancestor check is the load-bearing one. Without it, pushing a branch would be a way to get a bot-authored commit sitting on top of arbitrary content, and the PR that followed would quietly be about more than a version bump. With it, the marker is a signal and the bump is the only commit that lands on it. This leans on the GITHUB_TOKEN rule in the opposite direction from the rest of the release path: the bump commit the job force-pushes does not re-fire the push trigger that started it, which is what keeps it from looping. Noted in the workflow, RELEASE.md, and the skill's guardrails, since moving this job to a PAT would silently turn it into a loop. Dispatch still works, still guards that it came from main, and remains the only path that accepts an empty version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F952B3qz1QeXYv5sPGwPh3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 816ff52 commit 4ce35d2

5 files changed

Lines changed: 351 additions & 69 deletions

File tree

.claude/skills/release/SKILL.md

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ disable-model-invocation: true
77
# Releasing Blurt
88

99
Everything happens in GitHub Actions, not on this machine — so this works from a
10-
web or chat session with no terminal. **Confirm the target version with the user
11-
before dispatching** — publishing is hard to undo.
10+
web or chat session with no terminal, and without permission to dispatch a
11+
workflow. **Confirm the target version with the user before starting**
12+
publishing is hard to undo.
1213

1314
## Preconditions (verify first)
1415

@@ -23,17 +24,31 @@ before dispatching** — publishing is hard to undo.
2324

2425
## Steps
2526

26-
1. **Dispatch `release-bump`** with the target version. It runs
27+
1. **Start `release-bump`.** You almost certainly can't dispatch a workflow (that
28+
needs `actions: write`), so push a marker branch instead — it needs only the
29+
push you already do:
30+
31+
```sh
32+
git push origin main:refs/heads/release/v0.1.37
33+
```
34+
35+
The branch name names the version, and it must carry nothing of its own: the
36+
workflow refuses a marker that isn't an ancestor of `main`. It then runs
2737
`scripts/release-bump.sh` on `macos-26` (marketing version + build number in
28-
`App/Blurt/project.yml`, regenerate the project, commit) and pushes
29-
`release/vX.Y.Z`.
30-
2. **Open the PR** for that branch and merge it once green. The workflow does not
31-
open it, on purpose: a PR created by `GITHUB_TOKEN` never triggers `check` and
32-
so can never merge. Opening it from here works — an agent's own credentials
33-
are not `GITHUB_TOKEN`.
34-
3. **Dispatch `release`** with the same version. The `build` job does the whole
35-
Apple path (`xcodebuild` Release → sign nested code → notarize → staple → DMG
36-
→ verify) and uploads the artifacts.
38+
`App/Blurt/project.yml`, regenerate the project, commit) and force-pushes the
39+
bump onto that same branch. Dispatching still works if you do have the
40+
permission, and only that path accepts an empty version (next patch).
41+
42+
2. **Open the PR** for that branch and hand it to the user to merge. The workflow
43+
does not open it, on purpose: a PR created by `GITHUB_TOKEN` never triggers
44+
`check` and so can never merge. Opening it from here works — an agent's own
45+
credentials are not `GITHUB_TOKEN`.
46+
3. **Merging it starts `release`** — no dispatch. `release.yml` triggers on a
47+
push to `main` touching `project.yml`; its `resolve` job confirms the version
48+
changed and isn't already tagged, then the `build` job does the whole Apple
49+
path (`xcodebuild` Release → sign nested code → notarize → staple → DMG →
50+
verify) and uploads the artifacts. Dispatch `release` by hand only to re-run
51+
a failed build, to `republish`, or for a non-`main` dry run.
3752
4. **Hand the ship gate to the user** — the `publish` job parks on the
3853
`release-publish` environment. Tell them to download the DMG from the run's
3954
artifacts, install it, and approve once it works. Nothing is rebuilt after
@@ -42,17 +57,22 @@ before dispatching** — publishing is hard to undo.
4257

4358
**Never approve the `release-publish` deployment yourself**, even though the API
4459
allows it. The gate exists so a human confirms the real artifact reached users in
45-
working order; approving a build you dispatched is not a gate. If the user wants
60+
working order; approving a build you started is not a gate. If the user wants
4661
unattended releases, that is a deliberate change to the environment's reviewers,
4762
not something to route around.
4863

49-
Dispatching `release-bump` with no version takes the next patch. There is no
50-
local orchestrator script — the workflows are the only path.
64+
There is no local orchestrator script — the workflows are the only path.
5165

5266
## Guardrails / gotchas
5367

54-
- The workflow is dispatch-only and both jobs pin `github.sha`, so a release can
55-
only ever be the exact reviewed commit. Don't add a push/tag trigger.
68+
- `release.yml`'s only push trigger is `main` + `project.yml` changed, narrowed
69+
further by `resolve` (version actually changed, no existing tag), and both jobs
70+
pin `github.sha` — so a release can only ever be the exact reviewed commit that
71+
carried the bump. Don't widen that trigger, and never add a tag trigger.
72+
- `release-bump.yml`'s marker trigger is safe only because of its ancestor check
73+
(the branch carries nothing of its own) and because a `GITHUB_TOKEN` push
74+
doesn't re-fire the trigger. Don't drop either, and don't move that job to a
75+
PAT or app token without adding a loop guard.
5676
- Notarization rejects any nested mach-o/framework lacking a **secure
5777
timestamp**; the build re-signs frameworks for this reason — don't remove that.
5878
- The signer-pin (`verify_signer`) checks the produced artifacts against a
@@ -68,4 +88,5 @@ local orchestrator script — the workflows are the only path.
6888
ref — with the environment restricted to `main`, there is no branch dry run.
6989

7090
Read the script or workflow you're about to run before running it, surface what
71-
it will do, and get a go-ahead before dispatching.
91+
it will do, and get a go-ahead before starting it. Pushing a marker branch is as
92+
consequential as dispatching was — it starts the same pipeline.

.github/workflows/release-bump.yml

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,39 @@ name: release-bump
1313
# and would sit unmergeable. Opening it from outside Actions — a person, or an
1414
# agent with its own credentials — makes `check` run normally. The job summary
1515
# prints a one-click link for that.
16+
#
17+
# The same GITHUB_TOKEN rule applies at the other end: merging that PR is what
18+
# starts `release`, so the merge has to come from outside Actions too. A human
19+
# clicking Merge, auto-merge, or an agent's own credentials all qualify.
20+
#
21+
# Two ways in:
22+
#
23+
# - **Push a marker branch** `release/vX.Y.Z` pointing at `main`, carrying no
24+
# commits of its own. The branch name is the request — it names the version —
25+
# and pushing a branch is something anyone with repo write can do from a web
26+
# or chat session, unlike dispatching a workflow. This is what lets an agent
27+
# start a release end to end, leaving the maintainer nothing but the two
28+
# approvals (merge the PR, approve the ship gate).
29+
# - **Dispatch it**, which additionally accepts an empty version and takes the
30+
# next patch itself.
31+
#
32+
# The marker-branch path relies on the same GITHUB_TOKEN rule as everything else
33+
# here, in the opposite direction: the bump commit this job force-pushes onto
34+
# `release/vX.Y.Z` does NOT re-trigger the push event that started it. That is
35+
# what keeps this from looping. Don't switch this job to a PAT or an app token
36+
# without adding a loop guard.
1637
on:
1738
workflow_dispatch:
1839
inputs:
1940
version:
2041
description: "Version to bump to (X.Y.Z). Leave empty to take the next patch."
2142
required: false
2243
type: string
44+
push:
45+
branches:
46+
# The digit keeps a typo'd branch from spending a macOS runner just to
47+
# fail the semver check; that check is still the authority.
48+
- "release/v[0-9]*"
2349

2450
# One bump at a time; never cancel one mid-push.
2551
concurrency:
@@ -43,7 +69,10 @@ jobs:
4369
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
4470
with:
4571
path: blurt
46-
ref: ${{ github.sha }}
72+
# `main`, not `github.sha`: a bump is always computed against main's
73+
# current version, and on the marker-branch path `github.sha` is the
74+
# marker, which is a signal rather than the base to bump from.
75+
ref: main
4776
# Tags are what release-bump.sh checks the target version against, and
4877
# a shallow clone has none — it would miss an existing vX.Y.Z.
4978
fetch-depth: 0
@@ -53,45 +82,71 @@ jobs:
5382
- name: Guard the dispatch ref
5483
# Versions are bumped from main; a bump computed against any other ref
5584
# would be measured from the wrong current version.
56-
if: github.ref != 'refs/heads/main'
85+
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main'
5786
run: |
5887
echo "::error::release-bump must be dispatched from main (got ${GITHUB_REF})"
5988
exit 1
6089
90+
- name: Guard the marker branch
91+
# The marker must carry nothing of its own. Otherwise pushing a branch
92+
# would be a way to get a bot-authored commit sitting on top of
93+
# arbitrary content, and the PR that follows would quietly be about
94+
# more than a version bump.
95+
if: github.event_name == 'push'
96+
working-directory: blurt
97+
run: |
98+
set -euo pipefail
99+
# HEAD is main, from the checkout above.
100+
if ! git merge-base --is-ancestor "$GITHUB_SHA" HEAD; then
101+
echo "::error::$GITHUB_REF_NAME carries commits that are not on main. Push it at main's tip with nothing of its own. (If it already has the bump commit, this job already ran — open the pull request instead.)"
102+
exit 1
103+
fi
104+
echo "marker $GITHUB_REF_NAME is clean (an ancestor of main)"
105+
61106
- name: Resolve the target version
62107
id: target
63108
working-directory: blurt
64109
# Before the brew install, so a version problem costs seconds rather
65110
# than minutes. release-bump.sh re-checks all of this; these are the
66111
# same rules applied early enough to be useful.
67112
env:
113+
EVENT: ${{ github.event_name }}
68114
INPUT_VERSION: ${{ inputs.version }}
115+
BRANCH: ${{ github.ref_name }}
69116
run: |
70117
set -euo pipefail
71118
REPO_ROOT="$PWD"
72119
# shellcheck disable=SC1091 # sourced at runtime from the checkout
73120
source scripts/release-lib.sh
74121
75122
main_v="$(require_project_version App/Blurt/project.yml)"
76-
version="${INPUT_VERSION:-}"
77-
if [ -z "$version" ]; then
78-
version="$(default_target "$main_v" "$(latest_release_tag)")" \
79-
|| die "could not derive a default target from main ($main_v)"
80-
info "no version given — defaulting to $version"
123+
if [ "$EVENT" = "push" ]; then
124+
# The branch name is the request. No default_target on this path:
125+
# a marker branch that named no version would be asking the
126+
# workflow to guess what its own name meant.
127+
version="${BRANCH#release/v}"
128+
info "marker branch $BRANCH asks for $version"
129+
else
130+
version="${INPUT_VERSION:-}"
131+
if [ -z "$version" ]; then
132+
version="$(default_target "$main_v" "$(latest_release_tag)")" \
133+
|| die "could not derive a default target from main ($main_v)"
134+
info "no version given — defaulting to $version"
135+
fi
81136
fi
82137
is_semver "$version" || die "version must be X.Y.Z (got: $version)"
83138
84139
run="$(decide_run "$main_v" "$version")" \
85140
|| die "target $version is behind main ($main_v)"
86141
[ "$run" = "bump" ] \
87-
|| die "main is already at $version and it has not been released — dispatch the 'release' workflow instead of bumping again"
142+
|| die "main is already at $version and it has not been released — that bump already landed, so the release has already started from it; check the 'release' workflow's runs rather than bumping again"
88143
89144
# An abandoned release leaves a tag with no release behind it, which
90145
# burns that version. default_target takes the next patch rather than
91146
# the next unused one, deliberately: stopping here is better than
92147
# silently renumbering the release someone asked for.
93148
if tag_exists_on_origin "v$version"; then
94-
die "tag v$version already exists on origin — that version is burned; dispatch again with an explicit higher version"
149+
die "tag v$version already exists on origin — that version is burned; start again at an explicit higher version"
95150
fi
96151
97152
echo "version=$version" >>"$GITHUB_OUTPUT"
@@ -110,19 +165,35 @@ jobs:
110165
- name: Bump the version
111166
working-directory: blurt
112167
env:
168+
EVENT: ${{ github.event_name }}
113169
VERSION: ${{ steps.target.outputs.version }}
114170
run: |
115171
set -euo pipefail
116172
branch="release/v$VERSION"
117-
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
173+
174+
if [ "$EVENT" = "workflow_dispatch" ] \
175+
&& git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
118176
echo "::error::branch $branch already exists on origin — delete it or pick another version"
119177
exit 1
120178
fi
121-
git checkout -b "$branch"
179+
180+
# -B, not -b: on the marker path the branch already exists on origin,
181+
# and this puts the bump on top of main's tip rather than on top of
182+
# wherever the marker was pointing.
183+
git checkout -B "$branch"
122184
# Unmodified: it owns the semver / greater-than / tag-collision guards,
123185
# the project.yml edit, the xcodegen regeneration, and the commit.
124186
scripts/release-bump.sh "$VERSION"
125-
git push -u origin "$branch"
187+
188+
if [ "$EVENT" = "push" ]; then
189+
# The marker is at or behind main, so adding a commit on main's tip
190+
# is not always a fast-forward. The lease is pinned to the exact sha
191+
# the guard above vetted, so this can only ever overwrite the marker
192+
# that started this run — never a commit that arrived since.
193+
git push --force-with-lease="$branch:$GITHUB_SHA" origin "HEAD:refs/heads/$branch"
194+
else
195+
git push -u origin "$branch"
196+
fi
126197
127198
- name: Summarize
128199
env:
@@ -132,10 +203,12 @@ jobs:
132203
{
133204
echo "### Bumped to v$VERSION"
134205
echo
135-
echo "Branch \`release/v$VERSION\` pushed. It has no pull request yet —"
136-
echo "one opened from inside Actions would never run \`check\`."
206+
echo "Branch \`release/v$VERSION\` now carries the bump commit. It has no pull"
207+
echo "request yet — one opened from inside Actions would never run \`check\`."
137208
echo
138209
echo "[**Open the pull request**](https://github.com/$REPO/compare/main...release/v$VERSION?expand=1)"
139210
echo
140-
echo "Then merge it and dispatch the \`release\` workflow with the same version."
211+
echo "Merging it starts the \`release\` workflow by itself — the version landing on"
212+
echo "\`main\` is the trigger, so there is nothing left to dispatch. Publishing then"
213+
echo "waits on the \`release-publish\` approval once you've tested the DMG."
141214
} >>"$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)