Push release target before publishing to avoid permanent tag-name reservation on Immutable Releases#196
Merged
WillAbides merged 3 commits intoJun 1, 2026
Conversation
…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>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #195.
Problem
On a repository with Immutable Releases enabled,
PublishRelease(flippingdraft = false) acquires a row inrelease_immutable_tagsthat permanently reserves the tag name on the repo (and across re-created repos with the same name). Per GitHub's docs:Runner.createReleasepreviously did:tagRelease(push tag)CreateReleaseas draft + registerDeleteReleasecleanupuploadAssetsPublishRelease— this is when the immutable row gets createdpushTarget(push bake-commit branch to e.g.main)When step 5 fails — typically because the runner can't bypass a branch ruleset — the deferred
cleanupAfterErrdeletes the published release, which orphans the row but never removes it. Every subsequent run that computes the same tag fails withGH013: Cannot create ref due to creations being restricted, and only GitHub Support can clear the row.Fix
Two coordinated changes in
release.go:pushTargetto run betweenuploadAssetsandPublishRelease. 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.addErrCleanupto return acancel func()and use it to cancel both theDeleteReleasecleanup (registered increateRelease) and the tag-delete cleanup (registered inrunand passed down) immediately beforePublishRelease. 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
pushTargetPublishReleaseThe 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.--draftmode 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_TARGETand relies on the existing non-bare origin's defaultreceive.denyCurrentBranch=refuseto reject the push. AssertsDeleteReleaseis called (safe — still a draft),PublishReleaseis not called (no mock expectation), and the tag was deleted from origin.PublishRelease failure does not delete release or tag— setsreceive.denyCurrentBranch=ignoresopushTargetsucceeds, then mocksPublishReleaseto return an error. AssertsDeleteReleaseis 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
WillAbides/setup-go-faster v1.18.0-0): only GitHub Support can do that.