Skip to content

ci: harden release flow against main advancing during a publish run#35

Open
manaporkun wants to merge 9 commits into
mainfrom
ci/publish-push-rebase
Open

ci: harden release flow against main advancing during a publish run#35
manaporkun wants to merge 9 commits into
mainfrom
ci/publish-push-rebase

Conversation

@manaporkun
Copy link
Copy Markdown
Owner

@manaporkun manaporkun commented Jun 1, 2026

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 main has moved past.

Root cause

actions/checkout pins the publish job to the triggering commit (github.sha). The job is serialized by the concurrency group, but while it waits on the ~5-min test job (or queues behind another release), main can 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/main

New Sync to latest main step (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)

main can still advance during this job's own (seconds-long) window between sync and push. The Commit version bump push 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 || true and leaving a half-finished rebase for later steps.

ci: only -> no release on merge. YAML validated; validate + test green.

Closes #37.


Note

Medium Risk
Changes only CI/release automation but directly controls version bumps, main pushes, tags, and GitHub releases—incorrect logic could skip or duplicate releases.

Overview
Hardens the UPM publish workflow so releases stay correct when main moves during the long test wait or between bump and push (stale github.sha checkout).

The old split steps (Get current version, Determine version bump, Calculate new version, separate manifest/CHANGELOG commits) are replaced by one Prepare release and push step that fetches and resets to origin/main, recomputes semver from conventional commits, updates package.json and CHANGELOG, commits, and retries push (up to 5 times with backoff) by re-running prepare() instead of rebasing a stale bump—so competing bumps become skip and slipped-in commits fold into notes/bump type.

Adds release_exists via gh api (404-only = not released; other errors fail the job) and recovery: if the last bump on main has 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 use steps.release.outputs (release_ref, version, bump, skip); upm content is checked out from release_ref, not floating main. 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.

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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread .github/workflows/publish-upm.yml Outdated
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread .github/workflows/publish-upm.yml Outdated
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.
Comment thread .github/workflows/publish-upm.yml
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.
@manaporkun manaporkun changed the title ci: make version-bump push resilient to main advancing ci: harden release flow against main advancing during a publish run Jun 4, 2026
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).
Comment thread .github/workflows/publish-upm.yml
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.
Comment thread .github/workflows/publish-upm.yml
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.
Comment thread .github/workflows/publish-upm.yml
Comment thread .github/workflows/publish-upm.yml Outdated
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.
Comment thread .github/workflows/publish-upm.yml
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.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread .github/workflows/publish-upm.yml Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: sync publish job to origin/main before computing version (eliminate stale-base release race)

1 participant