Skip to content

Commit 3d2ce0a

Browse files
fix(release): land the version bump before the build, not after it
The first real automatic release did everything right — chose 0.25.0, bumped, passed the gate, built all six binaries, smoke-tested the linux one — and then threw it away, because main had moved during the eleven minutes it took and the bump would not fast-forward. Holding the bump until the end was meant to keep a failed release from leaving anything behind. What it actually did was make every release race every other push to main, over a window as long as the whole job, and lose the entire build when it lost. Push the bump seconds after checkout instead, rebasing onto main and retrying up to three times. Before anything is built, a rebase costs nothing and stays honest: the gate and the builds then run on exactly the tree that gets tagged. Rebasing at the END would have been worse than losing the race — the binaries come from this tree, and the tag would have named a commit whose tree was never built. The tag still goes out last, after everything has proven itself. It is what publishes, so it is what is worth withholding. A release that fails now leaves a bump commit with no tag. That is self-healing, not wreckage: the next run picks the same version, finds the files already at it, and prepare-release.sh takes its tag-only path — verified against a clone.
1 parent 01ab18b commit 3d2ce0a

2 files changed

Lines changed: 76 additions & 22 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,46 @@ jobs:
141141
version="$(./scripts/next-version.sh --bump "$BUMP" --explain)"
142142
echo "selected ${version} for release"
143143
./scripts/prepare-release.sh "$version" --no-gate
144+
145+
# Push the bump NOW, seconds after checkout, rather than at the end.
146+
#
147+
# It used to go out after the builds, so that a failed release left the
148+
# branch untouched. That made every release lose a race it could not
149+
# win: the job takes ~40 minutes, and any push in that window — a
150+
# dependabot merge, an unrelated fix — made the bump un-fast-forwardable
151+
# and threw the whole build away. It happened on the first real run.
152+
#
153+
# Rebasing at the END instead would be worse than losing the race: the
154+
# binaries are built from this tree, and a rebased tag would name a
155+
# commit whose tree is not the one that was built. Here, before
156+
# anything is built, a rebase is free and honest — the gate and the
157+
# builds then run on exactly what was pushed.
158+
#
159+
# What this trades: a release that fails later leaves the bump on the
160+
# branch with no tag and no release. That state is self-healing, not
161+
# wreckage — the next run computes the same version, finds the files
162+
# already at it, and prepare-release.sh takes its tag-only path.
163+
pushed=0
164+
for attempt in 1 2 3; do
165+
if git push origin "HEAD:${GITHUB_REF_NAME}"; then
166+
pushed=1
167+
break
168+
fi
169+
echo "push rejected — ${GITHUB_REF_NAME} moved (attempt ${attempt}/3); rebasing the bump onto it"
170+
git fetch origin "${GITHUB_REF_NAME}"
171+
git rebase "origin/${GITHUB_REF_NAME}"
172+
done
173+
if [[ "$pushed" != 1 ]]; then
174+
echo "::error::could not land the version bump on ${GITHUB_REF_NAME} after 3 attempts — it is being pushed to faster than this job can rebase. Nothing has been published and no tag exists; try again when it is quieter."
175+
exit 1
176+
fi
177+
178+
# A rebase moves the commit, and prepare-release.sh's tag still points
179+
# at where it used to be. Re-point it at what was actually pushed, so
180+
# the tag published later names the tree that gets built below.
181+
git tag -f "v${version}" HEAD
182+
echo "bump landed on ${GITHUB_REF_NAME} as $(git rev-parse --short HEAD)"
183+
144184
echo "version=${version}" >> "$GITHUB_OUTPUT"
145185
echo "tag=v${version}" >> "$GITHUB_OUTPUT"
146186
@@ -345,32 +385,33 @@ jobs:
345385
print("binary reports version=%s commit=%s built=%s" % (info["version"], info["commit"], info["built"]))
346386
PY
347387
348-
# The point of no return for an automatic release, and deliberately the
349-
# last thing before publishing: the gate has passed, the Studio UI is
350-
# verified embedded, all six binaries are built and the linux/amd64 one
351-
# has been run and asked what version it thinks it is. Only now do the
352-
# bump and its tag leave this runner.
388+
# The point of no return, and deliberately the last thing before
389+
# publishing: the gate has passed, the Studio UI is verified embedded, all
390+
# six binaries are built and the linux/amd64 one has been run and asked
391+
# what version it thinks it is. Only now does the tag exist.
392+
#
393+
# The bump commit itself went out at the start (see "Choose the version and
394+
# bump it"), because holding it until here meant losing a 40-minute race
395+
# with every other push to the branch. The tag is what actually publishes —
396+
# it is what the GitHub Release is cut from and what users download — so it
397+
# is the thing worth withholding until everything has proven itself. A bump
398+
# without a tag is a no-op that the next run absorbs.
353399
#
354-
# A rejected push means the branch moved during the ~40 minutes this took.
355-
# That is NOT something to force past: the binaries above were built from
356-
# THIS tree, and forcing would publish them under a branch that no longer
357-
# contains the commit they came from. Fail and let a person look.
400+
# Unlike the branch, a tag cannot be rejected for moving: nothing else
401+
# creates it. The only way this fails is if the tag already exists, which
402+
# prepare-release.sh refuses up front.
358403
#
359-
# Pushing the tag here does not start a second Release run: events raised
360-
# by GITHUB_TOKEN do not trigger workflows. That restriction is usually a
404+
# Pushing it here does not start a second Release run: events raised by
405+
# GITHUB_TOKEN do not trigger workflows. That restriction is usually a
361406
# nuisance; here it is what keeps one button from producing two releases.
362-
- name: Publish the version bump (automatic releases)
407+
- name: Publish the release tag (automatic releases)
363408
if: steps.auto.outputs.tag != ''
364409
env:
365410
TAG: ${{ steps.auto.outputs.tag }}
366411
run: |
367412
set -euo pipefail
368-
if ! git push origin "HEAD:${GITHUB_REF_NAME}"; then
369-
echo "::error::${GITHUB_REF_NAME} moved while this release was building, so the bump could not be fast-forwarded. Nothing has been published and no tag exists. Re-run the release — the new run reads the newer commits and picks its version again."
370-
exit 1
371-
fi
372413
git push origin "refs/tags/${TAG}"
373-
echo "pushed the bump and ${TAG} to ${GITHUB_REF_NAME}"
414+
echo "published ${TAG} at $(git rev-parse --short HEAD)"
374415
375416
- name: Create GitHub Release
376417
uses: softprops/action-gh-release@v3

‎RELEASE.md‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,24 @@ reversible with `git tag -d` and `git reset`.
124124
beforehand: no local checkout, no bump, no tag. The job reads the version off the
125125
conventional commits since the last tag (`scripts/next-version.sh`), runs
126126
`prepare-release.sh` to bump `version.go`, the Makefile, the Formula, `docs/install.md`
127-
and the changelog, and commits it — **in the runner's tree only**. The bump and its tag are
128-
pushed at the very end, after the gate has passed, the Studio UI is verified embedded, all
129-
six binaries are built and the linux/amd64 one has been asked what version it reports. A
130-
release that fails before then leaves `main` untouched: no bump, no tag, nothing to roll
131-
back.
127+
and the changelog, and commits it.
128+
129+
Two things leave the runner, at two different moments:
130+
131+
- **The bump commit goes out immediately**, seconds after checkout. If `main` moved in
132+
between, the job rebases onto it and retries (three times) — so the gate and the six
133+
builds run on exactly the tree that will be tagged. Holding the bump until the end
134+
instead meant losing a 40-minute race with every other push to `main`, and one lost
135+
race threw away the whole build.
136+
- **The tag goes out at the very end**, after the gate has passed, the Studio UI is
137+
verified embedded, all six binaries are built and the linux/amd64 one has been asked
138+
what version it reports. The tag is what publishes — the GitHub Release is cut from it —
139+
so it is the thing worth withholding.
140+
141+
A release that fails partway therefore leaves a `chore: release vX.Y.Z` commit on `main`
142+
with no tag and no release. That is a no-op, not wreckage, and it heals itself: the next
143+
run picks the same version, finds the files already at it, and `prepare-release.sh` takes
144+
its tag-only path. Nothing to revert.
132145

133146
The `bump` dropdown forces the level when you disagree with the commits:
134147

0 commit comments

Comments
 (0)