Separate cmux NIGHTLY as standalone app with its own bundle ID (#164)

The nightly build is now a distinct app called "cmux NIGHTLY" with
bundle ID com.cmuxterm.app.nightly, allowing side-by-side installation
with the stable release. The nightly appcast URL is baked into the
app's Info.plist by CI, so no in-app channel switching is needed.

- Nightly workflow: rename app to "cmux NIGHTLY", set bundle ID to
  com.cmuxterm.app.nightly, hardcode nightly Sparkle feed URL, publish
  DMG as cmux-nightly-macos.dmg
- Remove "Receive Nightly Builds" toggle from settings
- Remove UpdateChannelSettings enum and simplify feed URL resolution
  to just use SUFeedURL from Info.plist
- Remove UpdateChannelSettingsTests (no longer applicable)
This commit is contained in:
Lawrence Chen 2026-02-20 03:54:07 -08:00 committed by GitHub
parent c11453fa6c
commit 707be44aaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 37 additions and 109 deletions

View file

@ -151,16 +151,29 @@ jobs:
run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
- name: Inject Sparkle keys and nightly metadata
- name: Inject nightly identity and metadata
run: |
set -euo pipefail
APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist"
APP_DIR="build/Build/Products/Release"
APP_PLIST="${APP_DIR}/cmux.app/Contents/Info.plist"
SHORT_SHA="${{ needs.decide.outputs.short_sha }}"
# --- Separate app identity: "cmux NIGHTLY" with its own bundle ID ---
/usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$APP_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$APP_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.cmuxterm.app.nightly" "$APP_PLIST"
# Rename the .app bundle to match the display name
mv "${APP_DIR}/cmux.app" "${APP_DIR}/cmux NIGHTLY.app"
# Update plist path after rename
APP_PLIST="${APP_DIR}/cmux NIGHTLY.app/Contents/Info.plist"
# --- Sparkle: point at the nightly appcast ---
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$APP_PLIST" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST"
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" "$APP_PLIST"
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" "$APP_PLIST"
# Marketing version: append -nightly.YYYYMMDD so users can identify the channel and date
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST")
@ -175,6 +188,8 @@ jobs:
/usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST"
echo "Nightly app name: cmux NIGHTLY"
echo "Nightly bundle ID: com.cmuxterm.app.nightly"
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
echo "Nightly build number: ${NIGHTLY_BUILD}"
echo "Commit SHA: ${SHORT_SHA}"
@ -210,7 +225,7 @@ jobs:
echo "Missing APPLE_SIGNING_IDENTITY secret" >&2
exit 1
fi
APP_PATH="build/Build/Products/Release/cmux.app"
APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app"
ENTITLEMENTS="cmux.entitlements"
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
if [ -f "$CLI_PATH" ]; then
@ -230,9 +245,9 @@ jobs:
echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2
exit 1
fi
APP_PATH="build/Build/Products/Release/cmux.app"
APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app"
ZIP_SUBMIT="cmux-nightly-notary.zip"
DMG_RELEASE="cmux-macos.dmg"
DMG_RELEASE="cmux-nightly-macos.dmg"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT"
APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")"
@ -250,7 +265,7 @@ jobs:
--identity="$APPLE_SIGNING_IDENTITY" \
"$APP_PATH" \
./
mv ./cmux*.dmg "$DMG_RELEASE"
mv ./"cmux NIGHTLY"*.dmg "$DMG_RELEASE" 2>/dev/null || mv ./cmux*.dmg "$DMG_RELEASE"
DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")"
DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")"
@ -270,7 +285,7 @@ jobs:
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
exit 1
fi
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg nightly appcast.xml
./scripts/sparkle_generate_appcast.sh cmux-nightly-macos.dmg nightly appcast.xml
- name: Move nightly tag to built commit
run: |
@ -289,8 +304,12 @@ jobs:
make_latest: false
body: |
Automated nightly build for `${{ needs.decide.outputs.short_sha }}`.
**cmux NIGHTLY** is a separate app (bundle ID `com.cmuxterm.app.nightly`) that can be installed alongside the stable release. It receives nightly updates automatically via its own Sparkle feed.
[Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
files: |
cmux-macos.dmg
cmux-nightly-macos.dmg
appcast.xml
overwrite_files: true

View file

@ -31,7 +31,6 @@ class UpdateController {
"SUEnableAutomaticChecks": false,
"SUSendProfileInfo": false,
"SUAutomaticallyUpdate": false,
UpdateChannelSettings.includeNightlyBuildsKey: UpdateChannelSettings.defaultIncludeNightlyBuilds,
])
let hostBundle = Bundle.main

View file

@ -1,26 +1,6 @@
import Sparkle
import Cocoa
enum UpdateChannelSettings {
static let includeNightlyBuildsKey = "cmux.includeNightlyBuilds"
static let defaultIncludeNightlyBuilds = false
static let stableFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml"
static let nightlyFeedURL = "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
static func resolvedFeedURLString(
infoFeedURL: String?,
defaults: UserDefaults = .standard
) -> (url: String, isNightly: Bool, usedFallback: Bool) {
let stableURL = (infoFeedURL?.isEmpty == false) ? infoFeedURL! : stableFeedURL
let includeNightlyBuilds = defaults.bool(forKey: includeNightlyBuildsKey)
if includeNightlyBuilds {
return (url: nightlyFeedURL, isNightly: true, usedFallback: false)
}
return (url: stableURL, isNightly: false, usedFallback: stableURL == stableFeedURL)
}
}
extension UpdateDriver: SPUUpdaterDelegate {
func feedURLString(for updater: SPUUpdater) -> String? {
#if DEBUG
@ -31,11 +11,15 @@ extension UpdateDriver: SPUUpdaterDelegate {
return override
}
#endif
let infoURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoURL)
UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")")
recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback)
return resolved.url
// The feed URL is baked into Info.plist at build time:
// - Stable releases use the stable appcast URL
// - cmux NIGHTLY has the nightly appcast URL injected by CI
let feedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let isNightly = feedURL?.contains("/nightly/") == true
UpdateLogStore.shared.append("update channel: \(isNightly ? "nightly" : "stable")")
let usedFallback = feedURL == nil || feedURL?.isEmpty == true
recordFeedURLString(feedURL ?? "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml", usedFallback: usedFallback)
return feedURL
}
/// Called when an update is scheduled to install silently,

View file

@ -2239,7 +2239,6 @@ struct SettingsView: View {
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(UpdateChannelSettings.includeNightlyBuildsKey) private var includeNightlyBuilds = UpdateChannelSettings.defaultIncludeNightlyBuilds
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@ -2317,25 +2316,6 @@ struct SettingsView: View {
}
}
SettingsSectionHeader(title: "Updates")
SettingsCard {
SettingsCardRow(
"Receive Nightly Builds",
subtitle: includeNightlyBuilds
? "Using nightly update channel. Builds may be less stable."
: "Using stable update channel."
) {
Toggle("", isOn: $includeNightlyBuilds)
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsIncludeNightlyBuildsToggle")
}
SettingsCardDivider()
SettingsCardNote("Nightly builds are published from the latest main branch commit when available.")
}
SettingsSectionHeader(title: "Automation")
SettingsCard {
SettingsCardRow(
@ -2551,7 +2531,6 @@ struct SettingsView: View {
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
includeNightlyBuilds = UpdateChannelSettings.defaultIncludeNightlyBuilds
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
KeyboardShortcutSettings.resetAll()
shortcutResetToken = UUID()

View file

@ -339,59 +339,6 @@ final class WorkspacePlacementSettingsTests: XCTestCase {
}
}
final class UpdateChannelSettingsTests: XCTestCase {
func testDefaultNightlyPreferenceIsDisabled() {
XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds)
}
func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() {
let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults)
XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL)
XCTAssertFalse(resolved.isNightly)
XCTAssertTrue(resolved.usedFallback)
}
func testResolvedFeedUsesInfoFeedForStableChannel() {
let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let infoFeed = "https://example.com/custom/appcast.xml"
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults)
XCTAssertEqual(resolved.url, infoFeed)
XCTAssertFalse(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
func testResolvedFeedUsesNightlyWhenPreferenceEnabled() {
let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey)
let resolved = UpdateChannelSettings.resolvedFeedURLString(
infoFeedURL: "https://example.com/custom/appcast.xml",
defaults: defaults
)
XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL)
XCTAssertTrue(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
}
final class WorkspaceReorderTests: XCTestCase {
@MainActor
func testReorderWorkspaceMovesWorkspaceToRequestedIndex() {