Skip to content

Push release target before publishing to avoid permanent tag-name reservation on Immutable Releases#196

Merged
WillAbides merged 3 commits into
mainfrom
willabides/fix-immutable-releases-tag-reservation
Jun 1, 2026
Merged

Push release target before publishing to avoid permanent tag-name reservation on Immutable Releases#196
WillAbides merged 3 commits into
mainfrom
willabides/fix-immutable-releases-tag-reservation

Conversation

@WillAbides

Copy link
Copy Markdown
Owner

Fixes #195.

Problem

On a repository with Immutable Releases enabled, PublishRelease (flipping draft = false) acquires a row in release_immutable_tags that permanently reserves the tag name on the repo (and across re-created repos with the same name). Per GitHub's docs:

If you delete the immutable release, you can delete the tag, but you cannot reuse the same tag name.

Runner.createRelease previously did:

  1. tagRelease (push tag)
  2. CreateRelease as draft + register DeleteRelease cleanup
  3. uploadAssets
  4. PublishReleasethis is when the immutable row gets created
  5. pushTarget (push bake-commit branch to e.g. main)

When step 5 fails — typically because the runner can't bypass a branch ruleset — the deferred cleanupAfterErr deletes the published release, which orphans the row but never removes it. Every subsequent run that computes the same tag fails with GH013: Cannot create ref due to creations being restricted, and only GitHub Support can clear the row.

Fix

Two coordinated changes in release.go:

  • Reorder. Move pushTarget to run between uploadAssets and PublishRelease. The release stays a draft (no immutable row) until the only plausibly-rejected step has succeeded. This matches GitHub's recommended Immutable Releases workflow: create draft → upload assets → publish.
  • Cancellable cleanup. Change addErrCleanup to return a cancel func() and use it to cancel both the DeleteRelease cleanup (registered in createRelease) and the tag-delete cleanup (registered in run and passed down) immediately before PublishRelease. After publish has been attempted — success or failure — neither cleanup is safe: the server may have processed the publish even when the client saw a network error, and deleting either side in that ambiguous state orphans the row.

Behavior changes

Failure point Before After
pushTarget Release deleted → orphaned immutable row, tag permanently reserved Draft release + tag deleted cleanly. Retry works.
PublishRelease Release deleted, tag deleted, target not pushed → clean retry Release/tag/target left in place. User publishes the draft manually, or cleans up by hand.

The new PublishRelease-failure shape is a small regression for non-Immutable-Releases users, but it's necessary: the previous auto-cleanup is exactly what produces the catastrophic permanent reservation on Immutable Releases. Recovery is mild — one click on "Publish" in the GitHub UI.

--draft mode is unchanged (it doesn't push the target and doesn't publish; #195 doesn't apply to it).

Tests

Two new subtests of Test_releaseRunner_run:

  • pushTarget failure cleans up draft release and tag — uses a pre-tag hook that writes the current branch ref to $RELEASE_TARGET and relies on the existing non-bare origin's default receive.denyCurrentBranch=refuse to reject the push. Asserts DeleteRelease is called (safe — still a draft), PublishRelease is not called (no mock expectation), and the tag was deleted from origin.
  • PublishRelease failure does not delete release or tag — sets receive.denyCurrentBranch=ignore so pushTarget succeeds, then mocks PublishRelease to return an error. Asserts DeleteRelease is not called (the mock controller fails the test if it is) and the tag still exists on origin.

All existing tests pass unchanged.

Out of scope

  • A pre-flight push permission check (the issue's option let's do v1 #2): requires either dry-pushing or reading rulesets and matching against the runner identity; adds API surface for marginal extra coverage on top of the reorder.
  • Detect-and-warn on Immutable Releases (option validate_version #3): informational only; structural fix removes the need.
  • Clearing existing orphaned rows (e.g. WillAbides/setup-go-faster v1.18.0-0): only GitHub Support can do that.

…n Immutable Releases (#195)

When a repository has Immutable Releases enabled, publishing a release
acquires a row in release_immutable_tags that permanently reserves the
tag name on the repo. Previously, createRelease published the release
*before* pushTarget, so a rejected push to a protected branch caused the
deferred DeleteRelease cleanup to orphan that row, making the tag name
unrecoverable without GitHub Support.

Reorder createRelease so pushTarget happens between uploadAssets and
PublishRelease. This matches GitHub's documented Immutable Releases
workflow (create draft -> upload assets -> publish) and keeps the
release as a draft (no immutable row) through the only step that
plausibly fails for non-retriable reasons.

Make addErrCleanup return a cancel function and use it to disable both
the DeleteRelease and tag-delete cleanups immediately before
PublishRelease. After publish has been attempted, neither cleanup is
safe: the server may have processed the publish even when the client
saw an error, and deleting the release or tag in that ambiguous state
permanently reserves the tag name.

Behavior changes:
- pushTarget failure now cleans up cleanly (draft release + tag deleted).
- PublishRelease failure now leaves the release, tag, and target push in
  place for manual recovery. Previously the cleanup would delete the
  release and tag; that auto-cleanup is exactly what causes the
  immutable-row orphan, so it had to go.

Fixes #195.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
WillAbides and others added 2 commits June 1, 2026 12:35
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@WillAbides
WillAbides merged commit 7b56e55 into main Jun 1, 2026
3 checks passed
@WillAbides
WillAbides deleted the willabides/fix-immutable-releases-tag-reservation branch June 1, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v3.8.6 can permanently reserve a tag name if pushTarget fails on a repo with Immutable Releases enabled

1 participant