release: repair a release published empty by the old publish-by-tag step

AU
8d ago
+465-18

Fixes #26321

Root cause

releases/latest pointed at v0.65.2.2, published with 0 assets, so the download page, Homebrew and Sparkle appcast URLs returned 404. That release is now repaired; this PR adds a tested recovery path for the next release published empty.

v0.65.2.2 was tagged from release/v0.65.2.2, a hotfix branch cut from v0.65.2. A tag runs the workflow file at that tag, so it ran the release workflow from before https://github.com/manaflow-ai/cmux/pull/21140. That workflow's last step was softprops/action-gh-release with tag_name: v0.65.2.2. The tags endpoint hides drafts, so the action found no release, created a second one and published it empty. The release run itself succeeded: all 12 signed assets (3 DMGs, 3 appcasts, 6 cmuxd-remote files) were uploaded at 13:33Z to the staged draft, release id 425503700. GitHub now lists that draft as untagged-7319b3bc503c80f29055, named v0.65.2.2. This is the same failure as v0.65.2.

Main already publishes by id, so new tags from main are safe. Two gaps remained: nothing could repair a release already in this state, and nothing stopped a hotfix branch from carrying the old workflow. The second gap is closed in HQ by https://github.com/manaflow-ai/cmuxterm-hq/pull/4689: the release gate now refuses a checkout whose release.yml still uses softprops/action-gh-release.

Change

New subcommand scripts/ci/finalize-release.py adopt --tag T --release-id DRAFT --make-latest true|false ... [--dry-run] (--make-latest is required so a repair of an older tag cannot move releases/latest back onto it):

  1. It requires the release id to be a draft whose tag is T, or untagged-* with the name T, and to hold every --expect-asset (exact --expect-count).
  2. It refuses if any other release for T is a draft, has an asset that is not a byte-identical copy (digest and size) of a draft asset, or already holds every draft asset. That last case is a working release; delete the draft instead.
  3. It rejects an effective prerelease with --make-latest true, and generates or reads requested notes before deletion. It stores notes on the draft, then deletes the duplicates and confirms each one is gone. The git tag stays.
  4. It moves T onto the draft and runs the existing verified publish path, which checks the tag resolves to the draft with all assets.

Existing publish and download CLI flags remain compatible. publish excludes duplicate ids that adopt has confirmed deleted, while still rejecting any new release on the same tag.

Validation

python3 tests/test_github_release_publication.py AdoptTests:

Observed / Not run

Observed at c9ab44766a729b16d3c8b9e977648123d2edcf22: the focused regression above, git diff --check, and main ancestry pass. The branch includes main at ebc53e7196657cd9a85be40ad2e383bebca755af without conflicts. The R2 upload CI run executed all 73 publication tests successfully, and all CI checks are green. The PR diff is byte-identical to the previously reviewed diff at 83ca746abe4c23e79ccbc18ba8b3de1a6471da2e. The earlier localization diff found no changed macOS/web message keys and its catalog validator passed. The latest universal DMG and stable appcast return HTTP 200.

Not run: a native build or app dogfood, because this changes only release-maintenance tooling. No live releases were changed during this follow-up; deletion/publication are covered with in-memory GitHub endpoint fixtures. Transient failure after deletion remains recoverable by rerunning adoption or publish as indicated by the release state.

Status of v0.65.2.2

The live release was repaired by hand on 2026-10-28: release 425504204 now holds all 12 assets, copied from the draft, and every releases/latest/download/... link returns 200. Running adopt there now refuses ("already holds every asset of draft 425503700; nothing to adopt. Delete the draft instead."). This PR stays as the tested repair path for the next release published empty. The leftover draft 425503700 is a duplicate and can be deleted.

Changelog

none

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Repairs releases published empty by the old publish-by-tag workflow, which left v0.65.2.2's releases/latest/download/... URLs 404ing. Adds an adopt subcommand to scripts/ci/finalize-release.py that replaces the empty release — or one holding only byte-identical copies of draft assets — with the staged draft, without rebuilding or re-signing.

Migration

  • A release owner runs adopt with the staged draft id, the tag, expected assets, and an explicit --make-latest; --dry-run previews the plan first.
  • adopt refuses to delete a draft, a release with non-copy assets, or a release already serving every draft asset (delete the draft instead).
  • Notes land on the draft before anything is deleted, so a missing --body-file or a failed notes generation or write leaves both releases unchanged; without a notes flag, the draft keeps the deleted release's notes.
  • publish and download are unchanged; publish skips ids adopt just deleted so a lagging release list can't strand the tag.
  • 28 AdoptTests cover the delete-before-tag-move order, dry-run no-write, copy detection, an already-deleted or reread release, and refusals for drafts, mismatched tags, already-complete releases, and incomplete drafts.

Summary by CodeRabbit

  • New Features
    • Release publishing can adopt an eligible staged draft when another release already uses its tag. Matching assets are verified, and existing release notes can be carried over.
    • A dry-run option lets you review a recovery without changing releases.
    • Publishing commands require an expected asset or asset count.
  • Tests
    • Added coverage for adoption scenarios, safety checks, dry-run behavior, and command-line help.