ci: harden release flow against main advancing during a publish run#35
ci: harden release flow against main advancing during a publish run#35manaporkun wants to merge 9 commits into
Conversation
The publish run checks out main, then commits + pushes the version bump. If another PR merges while this run is queued behind the concurrency gate, main advances and the push fails with non-fast-forward (this happened when two PRs were merged back-to-back: one publish run failed on push while the other cut the release). Rebase the bump onto the latest main and retry the push so the release still lands.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 185be353e8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for attempt in 1 2 3 4 5; do | ||
| if git push origin main; then pushed=1; break; fi | ||
| echo "push rejected (attempt $attempt); rebasing onto origin/main and retrying" | ||
| git pull --rebase origin main || true |
There was a problem hiding this comment.
Recompute the release after rebasing onto newer main
When the push is rejected because another merge reached main, this rebase moves the already-created version-bump commit after those newer commits, but steps.version.outputs.version and /tmp/release_entries.md were computed earlier at the original checkout. The current run can then publish the newer commits in the UPM branch without their changelog/semver bump, and their queued run will see this bump commit as the release boundary and skip them.
Useful? React with 👍 / 👎.
The retry loop swallowed rebase failures via `|| true`. On a conflicting rebase (a competing release bump already on main), that left a half-finished rebase in the tree and burned all 5 attempts plus backoff sleeps before exiting. Abort the rebase and stop immediately on conflict, skip the redundant final-attempt sleep, and report the real attempt count. The loop now only retries the recoverable case (main advanced with unrelated commits); a bump-on-bump conflict fails fast with a clear error instead of spinning. Front-loading a fresh main sync to avoid the conflict entirely is tracked as a follow-up.
actions/checkout pins the publish job to the triggering commit (github.sha). The job is serialized by the concurrency group, but while it waited on the ~5-min test job (or queued behind another release) main could advance, so the version was computed against a stale base. A queued run then recomputed a version another run had already shipped and collided on push or on the tag. Sync to origin/main before reading the version: a release that already landed is now seen as nothing-to-bump and the run skips cleanly, and notes/changelog reflect real latest main. The push-step retry loop now only has to cover main advancing during this job's own (seconds-long) window. Closes #37.
The loop rebased after every failed push, including the 5th, but only the next iteration pushes. So a 5th-attempt push that failed and then rebased cleanly ended the loop with the bump rebased-but-unpushed and exited 1 — one push short of success. Break before rebasing on the final attempt so all five iterations are real push attempts (5 pushes, 4 rebases between them).
Replace the rebase-and-retry push with a single 'Prepare release and push' step that computes the version, bump type, and release notes against the current origin/main, commits, and pushes as one unit. On a rejected push it recomputes against the now-latest main and retries, instead of rebasing the already-built bump commit. This fixes the stale-metadata class of bugs: a commit that lands in the sync-to-push window is now folded into this release's notes and bump type (no orphaned commits, no too-low version), and a competing release bump makes the range empty so the run skips cleanly (no duplicate version, no colliding non-force tag push). The consolidated step also removes the rebase entirely, so the earlier 'stuck rebase' and 'final attempt skips push' failure modes no longer exist. Folds the former Get current version / Determine version bump / Calculate new version / Update package.json / Prepend CHANGELOG / Commit steps into the one step and exposes version+bump+skip via steps.release outputs.
When the commit range is empty the run normally skips (already released). But if an earlier run pushed the version-bump commit and then died before tagging and releasing it, main sits at a version with no GitHub release, and because the bump commit makes the range empty forever, no future run would finish it. Detect that case in 'Prepare release and push': when bump=none but a bump commit exists for the current version AND its GitHub release is missing, run the publish for that version (no new commit to push) instead of skipping. The release notes are recovered from the version's committed CHANGELOG section, and the tag step is now idempotent so a recovery run won't fail on an existing tag. Uses gh release lookup (GH_TOKEN). A version with no automated bump commit (e.g. a manually set version) never triggers recovery.
Two fixes to the interrupted-publish recovery: - Recover before bumping. The check ran only when bump=none, so if a commit merged after the unreleased bump, the run bumped to a newer version and left the earlier version permanently untagged/unreleased. Move the recovery check ahead of bump computation: if the last bump's version has no release, finish that release first regardless of newer commits (they ship on the next run). Safe because publish runs are serialized — an unreleased bump means its run already terminated, so there is no concurrent release to race. - Don't treat API errors as 'no release'. The lookup used a bare 'gh api ... || recover', so any transient auth/rate-limit/network failure looked like a missing release and triggered a false recovery. Add release_exists(), which only treats a real HTTP 404 as absent and fails the job loudly on any other error.
The UPM/tag steps copied the package from main at HEAD. During a recovery that completes an interrupted publish after newer commits have landed, that shipped those newer commits under the recovered tag while their notes listed only the recovered version's commits. Pin the packaged tree to a release_ref: the bump commit itself (normal) or the interrupted bump being recovered (recovery), so a tag always contains exactly its own version's code. Also immunizes the normal path against main advancing again between the push and the UPM/tag steps.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c2a9cfd. Configure here.
Recovery read the version's notes from CHANGELOG.md in the working tree (main HEAD) but ships the package from the bump commit. If main's changelog changed after the bump, the GitHub release notes could disagree with the shipped in-package changelog. Read the notes via 'git show $RANGE_START:$CHANGELOG' so they come from the same commit the package is built from.

Follow-up hardening after the 3.0.3 release race. Two layers, addressing the same root cause — a publish run computing/pushing a version against a base that
mainhas moved past.Root cause
actions/checkoutpins thepublishjob to the triggering commit (github.sha). The job is serialized by the concurrency group, but while it waits on the ~5-mintestjob (or queues behind another release),maincan advance. The version was then computed against a stale base, and a queued run could recompute a version another run had already shipped.1. Compute the version against fresh
origin/mainNew
Sync to latest mainstep (git fetch origin main && git checkout -B main origin/main) runs before version determination. A release that already landed is now seen as nothing-to-bump, so the run skips cleanly instead of recomputing a stale/duplicate version that collides on push or on the non-force tag push. Release notes / CHANGELOG also reflect real latest main.2. Fail-clean push retry (backstop)
maincan still advance during this job's own (seconds-long) window between sync and push. TheCommit version bumppush retries with rebase on non-fast-forward. A rebase that conflicts means a competing bump landed in that window (both edit the same version line / CHANGELOG anchor) — it aborts the rebase and fails fast with a clear error, rather than swallowing the failure via|| trueand leaving a half-finished rebase for later steps.ci:only -> no release on merge. YAML validated;validate+testgreen.Closes #37.
Note
Medium Risk
Changes only CI/release automation but directly controls version bumps,
mainpushes, tags, and GitHub releases—incorrect logic could skip or duplicate releases.Overview
Hardens the UPM publish workflow so releases stay correct when
mainmoves during the longtestwait or between bump and push (stalegithub.shacheckout).The old split steps (
Get current version,Determine version bump,Calculate new version, separate manifest/CHANGELOG commits) are replaced by onePrepare release and pushstep thatfetches and resets toorigin/main, recomputes semver from conventional commits, updatespackage.jsonandCHANGELOG, commits, and retries push (up to 5 times with backoff) by re-runningprepare()instead of rebasing a stale bump—so competing bumps become skip and slipped-in commits fold into notes/bump type.Adds
release_existsviagh api(404-only = not released; other errors fail the job) and recovery: if the last bump onmainhas no GitHub release, finish that version first, package from the bump commit (release_ref), and restore release notes from that commit’s changelog. Downstream steps usesteps.release.outputs(release_ref,version,bump,skip);upmcontent is checked out fromrelease_ref, not floatingmain. Tag creation is idempotent if the tag already exists.Reviewed by Cursor Bugbot for commit bfbe397. Bugbot is set up for automated code reviews on this repo. Configure here.