From cd7a48edfd4afb0c3f25f115e79a0fd975f99a6a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 1 May 2026 20:01:29 -0700 Subject: [PATCH] fix(release): recover orphan tags instead of hard-failing the cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a cut pushes the tag but the workflow is cancelled (or the dependent release.yml job otherwise fails to start) before a GitHub release is published, every subsequent patch cut recomputes the same version and dies on "Tag already exists." This wedged the pipeline on 2026-05-01 when v1.3.26 was pushed by cancelled run 25237882049 — patch cuts kept failing for hours until release.yml was dispatched by hand. When the tag exists but no published release is attached, skip the bump/push steps and emit the existing tag as the job output so release.yml runs against it and finishes what the prior attempt started. Still refuse the collision when a real published release exists — that's a genuine conflict (e.g. a hand-tagged version) and needs human attention. Co-authored-by: Orca --- .github/workflows/release-cut.yml | 53 ++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 2a8159c927a..20d62e6e86f 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 outputs: - tag: ${{ steps.tag.outputs.tag }} + tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} steps: - name: Checkout ref uses: actions/checkout@v4 @@ -187,10 +187,53 @@ jobs: ;; esac - # Refuse tag collisions (possible if someone already tagged manually). + # Orphan-tag recovery. + # + # Why: if a previous cut pushed the tag but was cancelled (or the + # dependent release.yml job otherwise failed to start) before the + # GitHub Release was published, the tag now exists on the remote + # but "latest stable" still points at the prior version. Every + # subsequent patch cut then recomputes the same version and dies + # on "Tag already exists." This exact sequence wedged the cut + # pipeline on 2026-05-01 when v1.3.26 was pushed by a cancelled + # run (25237882049) — every patch cut after that rehit the same + # tag for hours until the orphan release was dispatched by hand. + # + # Recovery policy: if the tag exists AND no GitHub release has + # been published for it (draft-or-absent both count as "not + # shipped"), treat this as a resumable state: emit the existing + # tag as the job output so the downstream release.yml job runs + # against it and finishes what the earlier attempt started. The + # bump/commit/push steps are skipped in that case — there is + # nothing to bump; the tag is already on the remote. + # + # Refuse collisions only when the tag *and* a published release + # already exist — that's a real conflict (someone tagged manually + # over a shipped version) and needs human attention. if git rev-parse "v$new" >/dev/null 2>&1; then - echo "::error::Tag v$new already exists." >&2 - exit 1 + # Use the REST API, not `gh release view`: the latter returns + # "release not found" for drafts *and* for absent releases, + # which would cause us to "recover" over a draft that a human + # is preparing. The API distinguishes them. + release_state="$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/tags/v$new" \ + --jq '.draft' 2>/dev/null || echo "missing")" + case "$release_state" in + missing|true) + echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag." + echo "recovered_tag=v$new" >>"$GITHUB_OUTPUT" + echo "recovered=true" >>"$GITHUB_OUTPUT" + exit 0 + ;; + false) + echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2 + exit 1 + ;; + *) + echo "::error::Unexpected release state for v$new: $release_state" >&2 + exit 1 + ;; + esac fi echo "version=$new" >>"$GITHUB_OUTPUT" @@ -198,6 +241,7 @@ jobs: - name: Bump package.json and tag id: tag + if: steps.version.outputs.recovered != 'true' env: VERSION: ${{ steps.version.outputs.version }} run: | @@ -213,6 +257,7 @@ jobs: echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - name: Push tag + if: steps.version.outputs.recovered != 'true' env: PUSH_MAIN: ${{ steps.resolve.outputs.push_main }} TAG: ${{ steps.tag.outputs.tag }}