cmux NEXT: never offer the running build as Update Ready

Merged
TE
17d ago
+115-3

Right after NIGHTLY updated itself and relaunched (Leo, 2026-10-20 18:55), the sidebar showed "Update Ready / Automatic Updates / Restart to Update" again, with no version line under the title. Leo's rule: updates just install, and cmux never offers the build that is already running.

What was wrong

Two gaps let the card show for a build that is not newer than the running one:

  1. Sparkle's silent install never told cmux which build it staged. With SUAutomaticallyUpdate on (the NIGHTLY defaults have it), Sparkle's own automatic driver stages an update through updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:). UpdateDriver turned that into "Restart to Complete Update" but kept no item and cleared the detected version (UpdateDriver+SPUUpdaterDelegate.swift:58-67). UpdaterService.indicatorPhase (UpdaterService+Indicator.swift:13) then read stagedUpdate == nil and detectedUpdateVersion == nil, giving .ready(version: nil). That is a card with no version line, which matches the screenshot.
  2. Nothing compared a staged update with the running build. UpdateIndicatorPhase(_:version:) maps every auto-update .installing state to .ready, and UpdateFlow.footerPill and readyCard show any .ready. Sparkle can come back with an update for the build that now runs. After a relaunch, SPUUpdater probes for an installer that is still running (probeInstallerInProgressForHostBundleIdentifier) and, if it finds one, resumes it (resumeInstallingUpdate). It can also resume a download it kept (_resumableUpdate). In both cases it reports the installer's own item, so the build just installed came back as Update Ready.

Fix

  • UpdateDriver keeps the item Sparkle passes to willInstallUpdateOnQuit (quitInstallItem). stagedUpdate returns it while that "Restart to Complete Update" state holds, so the card gets its version, notes and build. The item is cleared when the update installs or a new download starts.
  • UpdaterService.indicatorPhase drops a .ready phase unless offersStagedUpdate(version:) passes. That check compares the staged sparkle:version with the running CFBundleVersion (SUStandardVersionComparator); with no build it falls back to "display version differs from the running one"; with neither it returns false. The flow, the card, the footer pill and the circle all read this phase, so none of them shows a build that equals or is older than the running one, or one whose version is unknown.

Changelog

  • Fixed: After an update relaunches cmux, the sidebar no longer offers "Update Ready" again for the build that is already running.

Tests

Fleet runs (cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg=suite ..., Xcode 26.6):

  • CmuxUpdater, BackgroundInstallTests:
    • Red on 970e8743f5639: sparklesSilentInstallNamesTheStagedUpdate fails (stagedUpdate?.versionString → nil); the other 12 pass.
    • Green on 59a5f1273445c: 13/13 pass. UpdateControllerPipelineTests and UpdateStateModelTests also pass.
  • CmuxNext, UpdateReadyNotNewerTests:
    • Red on 970e8743f5639 plus #22821's one-line palette fix: 4 of 5 fail, with 10 issues. The card shows "Update Ready" with detail "1.0.0" for the running build, "0.9.9" for an older one, and nil for an unknown one, and flow.phase stays .ready("1.0.0"). The newer-build control passes.
    • Green on 59a5f1273445c plus the same palette fix: 5/5 pass. SilentBackgroundUpdateTests, UpdateLoadingIndicatorTests, UpdateCheckFeedbackTests, UpdatedCardCurrentBuildNotesTests and UpdateReadyCardLayoutTests also pass (41 tests in all).

CmuxNext does not compile on base bc71d0f292889 (RegistryPaletteProvider+Families.swift:58: missing return, from #22764; the fix is open as #22821). The CmuxNext runs therefore used throwaway commits that add only that one-line return on top of each commit here. Those scratch branches are already deleted. Until #22821 lands, this PR's own CmuxNext compile is red for the same base reason.

Observed / Not run

  • Observed: the fleet red and green runs above. python3 scripts/cmux-next/prepush-gate.py passed on both commits. I read Sparkle 2.10's SPUUpdater, SPUBasicUpdateDriver, SPUUIBasedUpdateDriver and SPUAutomaticUpdateDriver sources to trace the resume and silent-install paths.
  • Not run: no local build. I did not reproduce the relaunch on a real NIGHTLY, and Leo's cmux update log from 18:55 was not read. So which Sparkle path produced his card (a resumed installer, a kept download, or a genuinely newer nightly published a minute later) is inferred, not confirmed. If it was a newer nightly, the card is correct, and with this fix it now shows that version.
  • Not changed: on a resume, Sparkle may still ask cmux's driver to install the same build again; this PR only keeps it from being offered.

🤖 Generated with Claude Code

Agent session: cosmic-gray