From 185be353e88724c420af06d4aa6551b95f8b4c54 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:35:23 +0200 Subject: [PATCH 1/9] ci: make version-bump push resilient to main advancing 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. --- .github/workflows/publish-upm.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index ee83094..8967c9d 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -284,7 +284,21 @@ jobs: git add "$PACKAGE_PATH/package.json" "$PACKAGE_PATH/CHANGELOG.md" git commit -m "chore: bump version to $NEW_VERSION [skip ci]" || echo "No changes to commit" - git push origin main + + # main can advance between this run's checkout and now (e.g. another PR merged while this + # run was queued behind the concurrency gate), which makes a plain push fail with + # non-fast-forward. Rebase the bump onto the latest main and retry so the release lands. + pushed=0 + 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 + sleep $((attempt * 3)) + done + if [ "$pushed" -ne 1 ]; then + echo "::error::Failed to push the version bump after 5 attempts." + exit 1 + fi - name: Create/Update UPM branch if: steps.version.outputs.skip != 'true' From 1d1a13a8c327ceac82ee8f6857afc20af6fd4e13 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:34:55 +0200 Subject: [PATCH 2/9] ci: fail clean on version-bump rebase conflict 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. --- .github/workflows/publish-upm.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index 8967c9d..1502109 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -286,17 +286,23 @@ jobs: git commit -m "chore: bump version to $NEW_VERSION [skip ci]" || echo "No changes to commit" # main can advance between this run's checkout and now (e.g. another PR merged while this - # run was queued behind the concurrency gate), which makes a plain push fail with - # non-fast-forward. Rebase the bump onto the latest main and retry so the release lands. + # run was queued behind the concurrency gate), making a plain push fail non-fast-forward. + # Rebase the bump onto the latest main and retry. A rebase that CONFLICTS means a competing + # release bump already landed (both edit the same version line / CHANGELOG anchor); abort + # cleanly and stop rather than leaving a half-finished rebase for later steps to copy. pushed=0 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 - sleep $((attempt * 3)) + echo "push rejected (attempt $attempt); rebasing onto origin/main" + if ! git pull --rebase origin main; then + git rebase --abort 2>/dev/null || true + echo "::error::version-bump rebase conflicted with main (a competing release bump already landed)." + break + fi + if [ "$attempt" -lt 5 ]; then sleep $((attempt * 3)); fi done if [ "$pushed" -ne 1 ]; then - echo "::error::Failed to push the version bump after 5 attempts." + echo "::error::Failed to push the version bump after $attempt attempt(s)." exit 1 fi From 296257ee9e6f1352e02cb273c86e99da8d7b0ec5 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:55:22 +0200 Subject: [PATCH 3/9] ci: compute release version against fresh origin/main 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. --- .github/workflows/publish-upm.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index 1502109..e48c965 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -131,6 +131,19 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + - name: Sync to latest main + # The publish job is serialized by the concurrency group, but actions/checkout pins to the + # triggering commit (github.sha). While this run waited on the ~5-min test job (or queued + # behind another release), main may have advanced. Compute the version against the REAL + # latest main so that a release another run already landed is seen as "nothing to bump" and + # this run skips cleanly, instead of recomputing a stale/duplicate version that then collides + # on push (rebase conflict) or on the non-force tag push. The retry loop in "Commit version + # bump" only has to cover main advancing during this job's own (seconds-long) window. + run: | + git fetch origin main + git checkout -B main origin/main + echo "Synced to origin/main: $(git rev-parse --short HEAD)" + - name: Get current version id: current run: | @@ -285,11 +298,11 @@ jobs: git add "$PACKAGE_PATH/package.json" "$PACKAGE_PATH/CHANGELOG.md" git commit -m "chore: bump version to $NEW_VERSION [skip ci]" || echo "No changes to commit" - # main can advance between this run's checkout and now (e.g. another PR merged while this - # run was queued behind the concurrency gate), making a plain push fail non-fast-forward. + # We synced to origin/main at job start, but main can still advance during this run (the + # window between that sync and this push), making a plain push fail non-fast-forward. # Rebase the bump onto the latest main and retry. A rebase that CONFLICTS means a competing - # release bump already landed (both edit the same version line / CHANGELOG anchor); abort - # cleanly and stop rather than leaving a half-finished rebase for later steps to copy. + # release bump landed inside that window (both edit the same version line / CHANGELOG + # anchor); abort cleanly and stop rather than leaving a half-finished rebase for later steps. pushed=0 for attempt in 1 2 3 4 5; do if git push origin main; then pushed=1; break; fi From 71a0dc4188c45208f11b7332e9f9e08e54a52083 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:03:09 +0200 Subject: [PATCH 4/9] ci: don't waste the final push attempt on a rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .github/workflows/publish-upm.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index e48c965..3ef2a9f 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -306,13 +306,16 @@ jobs: pushed=0 for attempt in 1 2 3 4 5; do if git push origin main; then pushed=1; break; fi + # The final attempt is a push only: a rebase here would have no following push to use it, + # so stop instead of leaving a rebased-but-unpushed bump and failing one push short. + if [ "$attempt" -eq 5 ]; then break; fi echo "push rejected (attempt $attempt); rebasing onto origin/main" if ! git pull --rebase origin main; then git rebase --abort 2>/dev/null || true echo "::error::version-bump rebase conflicted with main (a competing release bump already landed)." break fi - if [ "$attempt" -lt 5 ]; then sleep $((attempt * 3)); fi + sleep $((attempt * 3)) done if [ "$pushed" -ne 1 ]; then echo "::error::Failed to push the version bump after $attempt attempt(s)." From b8b847cd4b8863f5c0b5842130864a83e7827e5a Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:10:23 +0200 Subject: [PATCH 5/9] ci: recompute release against latest main on every push attempt 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. --- .github/workflows/publish-upm.yml | 321 +++++++++++++----------------- 1 file changed, 137 insertions(+), 184 deletions(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index 3ef2a9f..055fdaa 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -131,199 +131,152 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Sync to latest main - # The publish job is serialized by the concurrency group, but actions/checkout pins to the - # triggering commit (github.sha). While this run waited on the ~5-min test job (or queued - # behind another release), main may have advanced. Compute the version against the REAL - # latest main so that a release another run already landed is seen as "nothing to bump" and - # this run skips cleanly, instead of recomputing a stale/duplicate version that then collides - # on push (rebase conflict) or on the non-force tag push. The retry loop in "Commit version - # bump" only has to cover main advancing during this job's own (seconds-long) window. - run: | - git fetch origin main - git checkout -B main origin/main - echo "Synced to origin/main: $(git rev-parse --short HEAD)" - - - name: Get current version - id: current - run: | - VERSION=$(cat Packages/com.orkunmanap.runtime-transform-handles/package.json | jq -r '.version') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Current version: $VERSION" - - - name: Determine version bump - id: bump - run: | - # Release boundary = the last automated version-bump commit on main. Tags are created - # on the orphan `upm` branch, which is NOT reachable from main, so `git describe --tags` - # found nothing here and the range fell back to the ENTIRE history — re-counting old - # BREAKING/feat commits and over-bumping (major) on every release. The bump commit is - # always reachable from main, so it is a reliable, tag-independent boundary. - RANGE_START=$(git log --grep='^chore: bump version to' --format=%H -n 1 HEAD || true) - - if [ -n "$RANGE_START" ]; then - RANGE="$RANGE_START..HEAD" - echo "Previous release commit: $RANGE_START" - else - RANGE="HEAD" - echo "No previous bump commit found; treating all history as the first release." - fi - echo "Commit range: $RANGE" - - # Read FULL commit messages (%B) so `BREAKING CHANGE:` body/footer lines are seen, - # not just subjects. - COMMITS=$(git log $RANGE --format=%B) - echo "Commits in range:" - echo "$COMMITS" - - # Subjects for the changelog / release notes, computed once here (on main) and reused by - # later steps via this file — later steps run while checked out on the `upm` branch, where - # re-deriving the range would be wrong. Use tformat (trailing newline after every entry, - # including the last) so the GITHUB_OUTPUT heredoc in "Generate changelog" finds its EOF - # delimiter on its own line; plain format: omits the final newline and breaks it. - git log $RANGE --pretty=tformat:"- %s" > /tmp/release_entries.md - - # Strict Conventional Commits only — no bare ^break/^feature/^bugfix aliases - # (they mis-bump), and no catch-all "any change -> patch" fallback. - BUMP="none" - - if echo "$COMMITS" | grep -qE "^BREAKING CHANGE:" \ - || echo "$COMMITS" | grep -qiE "^[a-z]+(\(.+\))?!:"; then - BUMP="major" - elif echo "$COMMITS" | grep -qiE "^feat(\(.+\))?:"; then - BUMP="minor" - elif echo "$COMMITS" | grep -qiE "^(fix|perf|refactor)(\(.+\))?:"; then - BUMP="patch" - elif echo "$COMMITS" | grep -qiE "^(docs|style|chore|ci|test|build)(\(.+\))?:"; then - # Housekeeping types only bump when SHIPPING code/manifest changed in the range. - # Test- or sample-only changes (Tests/, Samples~/) don't affect consumers, so they - # must not trigger a release (otherwise adding a test cuts a pointless version). - if git log $RANGE --name-only --pretty=format: | sort -u \ - | grep -vE "/Tests/|/Samples~/" \ - | grep -qE "\.cs$|package\.json$"; then - BUMP="patch" - fi - fi - - echo "bump=$BUMP" >> $GITHUB_OUTPUT - echo "Bump type: $BUMP" - - - name: Calculate new version - id: version - run: | - CURRENT="${{ steps.current.outputs.version }}" - BUMP="${{ steps.bump.outputs.bump }}" - - if [ "$BUMP" = "none" ]; then - echo "version=$CURRENT" >> $GITHUB_OUTPUT - echo "skip=true" >> $GITHUB_OUTPUT - echo "No version bump needed" - exit 0 - fi - - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" - - case $BUMP in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) - PATCH=$((PATCH + 1)) - ;; - esac - - NEW_VERSION="$MAJOR.$MINOR.$PATCH" - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "skip=false" >> $GITHUB_OUTPUT - echo "New version: $NEW_VERSION" - - - name: Update package.json version - if: steps.version.outputs.skip != 'true' - run: | - NEW_VERSION="${{ steps.version.outputs.version }}" - PACKAGE_FILE="Packages/com.orkunmanap.runtime-transform-handles/package.json" - - jq ".version = \"$NEW_VERSION\"" $PACKAGE_FILE > tmp.json && mv tmp.json $PACKAGE_FILE - - echo "Updated package.json to version $NEW_VERSION" - cat $PACKAGE_FILE - - - name: Prepend release notes into in-package CHANGELOG - if: steps.version.outputs.skip != 'true' - run: | - NEW_VERSION="${{ steps.version.outputs.version }}" - CHANGELOG="Packages/com.orkunmanap.runtime-transform-handles/CHANGELOG.md" - - # Reuse the release-boundary range computed in "Determine version bump" (tag-independent). - ENTRIES=$(cat /tmp/release_entries.md) - - DATE=$(date +%Y-%m-%d) - { - echo "## [$NEW_VERSION] - $DATE" - echo "" - echo "$ENTRIES" - echo "" - } > /tmp/new_entry.md - - if [ -f "$CHANGELOG" ]; then - # Insert the new section just before the first existing "## [" heading. - awk 'BEGIN{ins=0} - /^## \[/ && ins==0 {while((getline line < "/tmp/new_entry.md")>0) print line; ins=1} - {print} - END{if(ins==0){while((getline line < "/tmp/new_entry.md")>0) print line}}' \ - "$CHANGELOG" > /tmp/changelog.md && mv /tmp/changelog.md "$CHANGELOG" - else - cp /tmp/new_entry.md "$CHANGELOG" - fi - - echo "Updated $CHANGELOG" - - name: Configure Git run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - - name: Commit version bump - if: steps.version.outputs.skip != 'true' + - name: Prepare release and push + id: release + # actions/checkout pins this 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 can advance, and it can advance again between computing the bump and pushing it. + # So compute version + notes against the CURRENT origin/main and commit + push as one unit; if + # the push is rejected, RECOMPUTE against the now-latest main and retry. Recomputing (rather than + # rebasing a pre-built commit) keeps the release correct whatever landed in the window: + # - a non-bump commit that slipped in is folded into this release's notes and bump type; + # - a competing release bump makes the range empty -> bump=none -> this run skips cleanly, + # so there is no duplicate version and no colliding (non-force) tag push. run: | - NEW_VERSION="${{ steps.version.outputs.version }}" PACKAGE_PATH="Packages/com.orkunmanap.runtime-transform-handles" + MANIFEST="$PACKAGE_PATH/package.json" + CHANGELOG="$PACKAGE_PATH/CHANGELOG.md" + + # Recompute everything from the latest origin/main, edit the files, and commit the bump. + # Sets NEW_VERSION / BUMP / SKIP and (re)writes /tmp/release_entries.md for later steps. + prepare() { + git fetch origin main + git checkout -B main origin/main + + CURRENT=$(jq -r '.version' "$MANIFEST") + echo "Current version: $CURRENT" + + # Release boundary = the last automated version-bump commit on main (tag-independent: tags + # live on the orphan `upm` branch, unreachable from main, so `git describe` is wrong here). + RANGE_START=$(git log --grep='^chore: bump version to' --format=%H -n 1 HEAD || true) + if [ -n "$RANGE_START" ]; then + RANGE="$RANGE_START..HEAD" + echo "Previous release commit: $RANGE_START" + else + RANGE="HEAD" + echo "No previous bump commit found; treating all history as the first release." + fi + echo "Commit range: $RANGE" + + # Full messages (%B) so `BREAKING CHANGE:` footers are seen, not just subjects. + COMMITS=$(git log $RANGE --format=%B) + # tformat = trailing newline after every entry so the "Generate changelog" heredoc EOF + # lands on its own line (plain `format:` omits the final newline and breaks it). + git log $RANGE --pretty=tformat:"- %s" > /tmp/release_entries.md + + # Strict Conventional Commits only — no bare aliases, no "any change -> patch" fallback. + BUMP="none" + if echo "$COMMITS" | grep -qE "^BREAKING CHANGE:" \ + || echo "$COMMITS" | grep -qiE "^[a-z]+(\(.+\))?!:"; then + BUMP="major" + elif echo "$COMMITS" | grep -qiE "^feat(\(.+\))?:"; then + BUMP="minor" + elif echo "$COMMITS" | grep -qiE "^(fix|perf|refactor)(\(.+\))?:"; then + BUMP="patch" + elif echo "$COMMITS" | grep -qiE "^(docs|style|chore|ci|test|build)(\(.+\))?:"; then + # Housekeeping types bump only when SHIPPING code/manifest changed (not Tests/ or Samples~/), + # otherwise adding a test would cut a pointless release. + if git log $RANGE --name-only --pretty=format: | sort -u \ + | grep -vE "/Tests/|/Samples~/" \ + | grep -qE "\.cs$|package\.json$"; then + BUMP="patch" + fi + fi + echo "Bump type: $BUMP" - git add "$PACKAGE_PATH/package.json" "$PACKAGE_PATH/CHANGELOG.md" - git commit -m "chore: bump version to $NEW_VERSION [skip ci]" || echo "No changes to commit" + if [ "$BUMP" = "none" ]; then + SKIP="true" + NEW_VERSION="$CURRENT" + return 0 + fi + SKIP="false" + + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "New version: $NEW_VERSION" + + jq ".version = \"$NEW_VERSION\"" "$MANIFEST" > tmp.json && mv tmp.json "$MANIFEST" + + ENTRIES=$(cat /tmp/release_entries.md) + DATE=$(date +%Y-%m-%d) + { + echo "## [$NEW_VERSION] - $DATE" + echo "" + echo "$ENTRIES" + echo "" + } > /tmp/new_entry.md + if [ -f "$CHANGELOG" ]; then + # Insert the new section just before the first existing "## [" heading. + awk 'BEGIN{ins=0} + /^## \[/ && ins==0 {while((getline line < "/tmp/new_entry.md")>0) print line; ins=1} + {print} + END{if(ins==0){while((getline line < "/tmp/new_entry.md")>0) print line}}' \ + "$CHANGELOG" > /tmp/changelog.md && mv /tmp/changelog.md "$CHANGELOG" + else + cp /tmp/new_entry.md "$CHANGELOG" + fi + + git add "$MANIFEST" "$CHANGELOG" + git commit -m "chore: bump version to $NEW_VERSION [skip ci]" + } - # We synced to origin/main at job start, but main can still advance during this run (the - # window between that sync and this push), making a plain push fail non-fast-forward. - # Rebase the bump onto the latest main and retry. A rebase that CONFLICTS means a competing - # release bump landed inside that window (both edit the same version line / CHANGELOG - # anchor); abort cleanly and stop rather than leaving a half-finished rebase for later steps. + # Each iteration recomputes against the latest main, so the loop self-corrects when main moves: + # the next attempt's range/version/notes reflect whatever just landed. No rebase of a stale + # commit, so nothing gets orphaned and a competing bump is detected as nothing-to-do. pushed=0 for attempt in 1 2 3 4 5; do - if git push origin main; then pushed=1; break; fi - # The final attempt is a push only: a rebase here would have no following push to use it, - # so stop instead of leaving a rebased-but-unpushed bump and failing one push short. - if [ "$attempt" -eq 5 ]; then break; fi - echo "push rejected (attempt $attempt); rebasing onto origin/main" - if ! git pull --rebase origin main; then - git rebase --abort 2>/dev/null || true - echo "::error::version-bump rebase conflicted with main (a competing release bump already landed)." + prepare + if [ "$SKIP" = "true" ]; then + echo "Nothing to release (no bump in range); skipping." break fi - sleep $((attempt * 3)) + if git push origin main; then pushed=1; break; fi + echo "push rejected (attempt $attempt); main advanced — recomputing against latest origin/main" + if [ "$attempt" -lt 5 ]; then sleep $((attempt * 3)); fi done + + if [ "$SKIP" = "true" ]; then + { + echo "skip=true" + echo "version=$NEW_VERSION" + echo "bump=none" + } >> $GITHUB_OUTPUT + exit 0 + fi + if [ "$pushed" -ne 1 ]; then echo "::error::Failed to push the version bump after $attempt attempt(s)." exit 1 fi + { + echo "skip=false" + echo "version=$NEW_VERSION" + echo "bump=$BUMP" + } >> $GITHUB_OUTPUT + - name: Create/Update UPM branch - if: steps.version.outputs.skip != 'true' + if: steps.release.outputs.skip != 'true' run: | PACKAGE_PATH="Packages/com.orkunmanap.runtime-transform-handles" UPM_BRANCH="upm" @@ -355,15 +308,15 @@ jobs: if git diff --cached --quiet; then echo "No changes to commit" else - git commit -m "Release v${{ steps.version.outputs.version }}" + git commit -m "Release v${{ steps.release.outputs.version }}" git push origin $UPM_BRANCH --force echo "UPM branch updated successfully!" fi - name: Create version tag - if: steps.version.outputs.skip != 'true' + if: steps.release.outputs.skip != 'true' run: | - VERSION="v${{ steps.version.outputs.version }}" + VERSION="v${{ steps.release.outputs.version }}" git checkout upm @@ -373,7 +326,7 @@ jobs: echo "Tagged version: $VERSION" - name: Generate changelog - if: steps.version.outputs.skip != 'true' + if: steps.release.outputs.skip != 'true' id: changelog run: | # Reuse the entries computed on main. The earlier `git describe HEAD^` ran here while @@ -383,11 +336,11 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Create GitHub Release - if: steps.version.outputs.skip != 'true' + if: steps.release.outputs.skip != 'true' uses: softprops/action-gh-release@v1 with: - tag_name: v${{ steps.version.outputs.version }} - name: v${{ steps.version.outputs.version }} + tag_name: v${{ steps.release.outputs.version }} + name: v${{ steps.release.outputs.version }} body: | ## Changes ${{ steps.changelog.outputs.changelog }} @@ -396,7 +349,7 @@ jobs: Add to your `manifest.json`: ```json - "com.orkunmanap.runtime-transform-handles": "https://github.com/${{ github.repository }}.git#v${{ steps.version.outputs.version }}" + "com.orkunmanap.runtime-transform-handles": "https://github.com/${{ github.repository }}.git#v${{ steps.release.outputs.version }}" ``` Or use the latest: @@ -408,14 +361,14 @@ jobs: - name: Summary run: | - if [ "${{ steps.version.outputs.skip }}" = "true" ]; then + if [ "${{ steps.release.outputs.skip }}" = "true" ]; then echo "## No Release Needed" >> $GITHUB_STEP_SUMMARY echo "No version bump was triggered by the commits." >> $GITHUB_STEP_SUMMARY else echo "## UPM Package Released 🎉" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Version:** v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**Bump Type:** ${{ steps.bump.outputs.bump }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** v${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Bump Type:** ${{ steps.release.outputs.bump }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Installation" >> $GITHUB_STEP_SUMMARY echo "\`\`\`json" >> $GITHUB_STEP_SUMMARY From 7fd9a1177c704432635a2496ef3c7e3ef588f9b4 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:21:47 +0200 Subject: [PATCH 6/9] ci: complete an interrupted publish instead of skipping it 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. --- .github/workflows/publish-upm.yml | 44 ++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index 055fdaa..01638b2 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -138,6 +138,8 @@ jobs: - name: Prepare release and push id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # actions/checkout pins this 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 can advance, and it can advance again between computing the bump and pushing it. @@ -199,9 +201,28 @@ jobs: fi echo "Bump type: $BUMP" + RECOVER="false" if [ "$BUMP" = "none" ]; then - SKIP="true" NEW_VERSION="$CURRENT" + # bump=none normally means "already released, nothing to do". But if an earlier run pushed + # this version's bump commit and then died before tagging/releasing it, main sits at a + # version with no GitHub release and no future run would ever finish it (the bump commit + # makes the range empty forever). Detect that — a bump commit exists for CURRENT but its + # release does not — and complete the publish instead of skipping it. No new commit to push. + if [ -n "$RANGE_START" ] \ + && ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v$CURRENT" >/dev/null 2>&1; then + echo "v$CURRENT was bumped on main but has no release; completing the publish." + SKIP="false" + RECOVER="true" + # Recover this version's notes from its committed CHANGELOG section. + awk -v h="## [$CURRENT]" ' + index($0, h) == 1 { grab = 1; next } + grab && /^## \[/ { exit } + grab && /^- / { print } + ' "$CHANGELOG" > /tmp/release_entries.md + else + SKIP="true" + fi return 0 fi SKIP="false" @@ -247,7 +268,12 @@ jobs: for attempt in 1 2 3 4 5; do prepare if [ "$SKIP" = "true" ]; then - echo "Nothing to release (no bump in range); skipping." + echo "Nothing to release (already published, or no bump in range); skipping." + break + fi + if [ "$RECOVER" = "true" ]; then + echo "Completing an incomplete publish of v$NEW_VERSION (nothing to push)." + pushed=1 break fi if git push origin main; then pushed=1; break; fi @@ -320,10 +346,16 @@ jobs: git checkout upm - git tag -a $VERSION -m "Release $VERSION" - git push origin $VERSION - - echo "Tagged version: $VERSION" + # Idempotent: a recovery run (completing an earlier interrupted publish) may reach here with + # the tag already pushed. Only create + push it when it is missing, so the non-force tag push + # never fails the job on an existing tag. + if git ls-remote --tags origin "refs/tags/$VERSION" | grep -q "refs/tags/$VERSION"; then + echo "Tag $VERSION already exists on origin; leaving it." + else + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + echo "Tagged version: $VERSION" + fi - name: Generate changelog if: steps.release.outputs.skip != 'true' From 0a46441ec53d2fc90431b6b36948eb1b434a0a00 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:31:15 +0200 Subject: [PATCH 7/9] ci: make publish-recovery robust to new commits and API errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/publish-upm.yml | 60 ++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index 01638b2..19dafdc 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -154,12 +154,28 @@ jobs: MANIFEST="$PACKAGE_PATH/package.json" CHANGELOG="$PACKAGE_PATH/CHANGELOG.md" + # Robust "is this version already released?" check. Distinguishes a real 404 (no release) from + # a transient auth/rate-limit/network error: only a 404 means "not released", anything else + # fails the job loudly so a flaky API call can never trigger a false recovery. + release_exists() { + local tag="$1" err + if err=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/$tag" 2>&1 >/dev/null); then + return 0 + fi + if printf '%s' "$err" | grep -qE "HTTP 404|Not Found"; then + return 1 + fi + echo "::error::release lookup for $tag failed (not a 404): $err" + exit 1 + } + # Recompute everything from the latest origin/main, edit the files, and commit the bump. - # Sets NEW_VERSION / BUMP / SKIP and (re)writes /tmp/release_entries.md for later steps. + # Sets NEW_VERSION / BUMP / SKIP / RECOVER and (re)writes /tmp/release_entries.md for later steps. prepare() { git fetch origin main git checkout -B main origin/main + RECOVER="false" CURRENT=$(jq -r '.version' "$MANIFEST") echo "Current version: $CURRENT" @@ -175,6 +191,25 @@ jobs: fi echo "Commit range: $RANGE" + # Recovery takes priority over a new bump: if the last automated bump's version was never + # released (an earlier publish run pushed the bump commit, then died before tag/release), + # finish THAT release first — even if newer commits have since landed. Publish runs are + # serialized, so an unreleased bump means its run already terminated; there is no concurrent + # release to collide with. The newer commits keep their place in the range and ship next run. + if [ -n "$RANGE_START" ] && ! release_exists "v$CURRENT"; then + echo "v$CURRENT was bumped on main but has no release; completing it before any new bump." + SKIP="false" + RECOVER="true" + NEW_VERSION="$CURRENT" + # Recover this version's notes from its committed CHANGELOG section. + awk -v h="## [$CURRENT]" ' + index($0, h) == 1 { grab = 1; next } + grab && /^## \[/ { exit } + grab && /^- / { print } + ' "$CHANGELOG" > /tmp/release_entries.md + return 0 + fi + # Full messages (%B) so `BREAKING CHANGE:` footers are seen, not just subjects. COMMITS=$(git log $RANGE --format=%B) # tformat = trailing newline after every entry so the "Generate changelog" heredoc EOF @@ -201,28 +236,11 @@ jobs: fi echo "Bump type: $BUMP" - RECOVER="false" + # No new release-worthy commits and the current version is already released (recovery above + # handled the not-yet-released case): nothing to do. if [ "$BUMP" = "none" ]; then + SKIP="true" NEW_VERSION="$CURRENT" - # bump=none normally means "already released, nothing to do". But if an earlier run pushed - # this version's bump commit and then died before tagging/releasing it, main sits at a - # version with no GitHub release and no future run would ever finish it (the bump commit - # makes the range empty forever). Detect that — a bump commit exists for CURRENT but its - # release does not — and complete the publish instead of skipping it. No new commit to push. - if [ -n "$RANGE_START" ] \ - && ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v$CURRENT" >/dev/null 2>&1; then - echo "v$CURRENT was bumped on main but has no release; completing the publish." - SKIP="false" - RECOVER="true" - # Recover this version's notes from its committed CHANGELOG section. - awk -v h="## [$CURRENT]" ' - index($0, h) == 1 { grab = 1; next } - grab && /^## \[/ { exit } - grab && /^- / { print } - ' "$CHANGELOG" > /tmp/release_entries.md - else - SKIP="true" - fi return 0 fi SKIP="false" From c2a9cfd42f24de0e3fae8aa358648338a2708382 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:01:28 +0200 Subject: [PATCH 8/9] ci: package the exact bump commit, not main HEAD 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. --- .github/workflows/publish-upm.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index 19dafdc..202579c 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -200,7 +200,11 @@ jobs: echo "v$CURRENT was bumped on main but has no release; completing it before any new bump." SKIP="false" RECOVER="true" + BUMP="none" NEW_VERSION="$CURRENT" + # Package the bump commit's tree, NOT HEAD — commits that landed after the interrupted + # publish must not ship under this recovered tag (they release on a later run). + RELEASE_REF="$RANGE_START" # Recover this version's notes from its committed CHANGELOG section. awk -v h="## [$CURRENT]" ' index($0, h) == 1 { grab = 1; next } @@ -277,6 +281,8 @@ jobs: git add "$MANIFEST" "$CHANGELOG" git commit -m "chore: bump version to $NEW_VERSION [skip ci]" + # Package exactly this bump commit, so later steps are immune to main advancing again. + RELEASE_REF="$(git rev-parse HEAD)" } # Each iteration recomputes against the latest main, so the loop self-corrects when main moves: @@ -317,6 +323,7 @@ jobs: echo "skip=false" echo "version=$NEW_VERSION" echo "bump=$BUMP" + echo "release_ref=$RELEASE_REF" } >> $GITHUB_OUTPUT - name: Create/Update UPM branch @@ -324,6 +331,9 @@ jobs: run: | PACKAGE_PATH="Packages/com.orkunmanap.runtime-transform-handles" UPM_BRANCH="upm" + # Exact commit whose package this release ships: the bump commit (normal) or the + # interrupted bump being recovered. Never plain `main`, which may have moved on. + RELEASE_REF="${{ steps.release.outputs.release_ref }}" if git ls-remote --heads origin $UPM_BRANCH | grep -q $UPM_BRANCH; then echo "UPM branch exists, updating..." @@ -332,7 +342,7 @@ jobs: find . -maxdepth 1 ! -name '.git' ! -name '.' -exec rm -rf {} + - git checkout main -- "$PACKAGE_PATH" + git checkout "$RELEASE_REF" -- "$PACKAGE_PATH" mv "$PACKAGE_PATH"/* . rm -rf Packages else @@ -342,7 +352,7 @@ jobs: git rm -rf . || true git clean -fd - git checkout main -- "$PACKAGE_PATH" + git checkout "$RELEASE_REF" -- "$PACKAGE_PATH" mv "$PACKAGE_PATH"/* . rm -rf Packages fi From bfbe3979a05cc117a49a70a056b84d2a4616a6c2 Mon Sep 17 00:00:00 2001 From: Orkun Manap <31966136+manaporkun@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:41:17 +0200 Subject: [PATCH 9/9] ci: recover release notes from the bump commit's changelog 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. --- .github/workflows/publish-upm.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-upm.yml b/.github/workflows/publish-upm.yml index 202579c..40146b7 100644 --- a/.github/workflows/publish-upm.yml +++ b/.github/workflows/publish-upm.yml @@ -205,12 +205,15 @@ jobs: # Package the bump commit's tree, NOT HEAD — commits that landed after the interrupted # publish must not ship under this recovered tag (they release on a later run). RELEASE_REF="$RANGE_START" - # Recover this version's notes from its committed CHANGELOG section. - awk -v h="## [$CURRENT]" ' - index($0, h) == 1 { grab = 1; next } - grab && /^## \[/ { exit } - grab && /^- / { print } - ' "$CHANGELOG" > /tmp/release_entries.md + # Recover this version's notes from the CHANGELOG at the bump commit (the same ref we + # package from), so the GitHub release notes match the shipped in-package changelog even + # if main's CHANGELOG changed after the bump. + git show "$RANGE_START:$CHANGELOG" \ + | awk -v h="## [$CURRENT]" ' + index($0, h) == 1 { grab = 1; next } + grab && /^## \[/ { exit } + grab && /^- / { print } + ' > /tmp/release_entries.md return 0 fi