Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/dev-build-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Dev build request

# This is the green-merge signal consumed by the fleet controller. The build
# itself stays on the managed Mac fleet, so this workflow remains a small,
# read-only post-merge job and never changes the nightly or release tracks.
on:
push:
branches:
- main
- feat-cmux-next
workflow_dispatch:
inputs:
reason:
description: "Why this one-shot dev build is being verified"
required: false
type: string
default: "manual verification"

permissions:
contents: read

concurrency:
group: dev-build-request-${{ github.ref_name }}
cancel-in-progress: false

jobs:
request:
name: request fleet dev build
runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Record the pushed merge commit
run: echo "Fleet dev build requested for ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ public final class UpdateController {
private let defaults: UserDefaults
private let fileManager: FileManager
private let hostBundle: Bundle
/// Whether the running build is a cmux DEV/staging build that must never be compared against
/// the public release appcast. See ``isDevLikeBundleIdentifier(_:)``.
/// Whether the running build is a local DEV/staging build that must never be compared against
/// a public release appcast. The signed team dev track uses its own stable bundle id and is
/// intentionally allowed through Sparkle.
let isDevLikeBundle: Bool

/// Host actions the updater delegates upward (retry, relaunch prep). Forwarded to the driver.
Expand Down Expand Up @@ -386,11 +387,12 @@ public final class UpdateController {
}

extension UpdateController {
/// Whether `bundleIdentifier` is a cmux DEV (`com.cmuxterm.app.debug[.<tag>]`) or staging
/// (`com.cmuxterm.app.staging[.<tag>]`) build.
/// Whether `bundleIdentifier` is a local cmux DEV (`com.cmuxterm.app.debug[.<tag>]`) or
/// staging (`com.cmuxterm.app.staging[.<tag>]`) build.
///
/// Such builds are produced from local source and are not on the public release train, so
/// they must never be compared against the public Sparkle appcast (#6292).
/// Such builds are produced from local source and are not on a published update train, so
/// they must never be compared against a public Sparkle appcast (#6292). The separate
/// `com.cmuxterm.app.dev` track is intentionally excluded from this predicate.
///
/// Mirrors `SocketControlSettings.isDebugLikeBundleIdentifier` +
/// `isStagingBundleIdentifier` (in the CmuxSettings package). The classification is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public struct UpdateFeedResolver: Sendable {
case nightly
/// The release-candidate feed; its path contains `/rc/`.
case rc
/// The team dev feed; its path contains `/dev/` or `/cmux-dev/`.
case dev

/// Classifies `feedURL` by the channel path segment it contains.
static func classify(feedURL: String) -> Channel {
Expand All @@ -35,6 +37,9 @@ public struct UpdateFeedResolver: Sendable {
if feedURL.contains("/rc/") {
return .rc
}
if feedURL.contains("/dev/") || feedURL.contains("/cmux-dev/") {
return .dev
}
return .stable
}

Expand All @@ -43,6 +48,7 @@ public struct UpdateFeedResolver: Sendable {
switch self {
case .stable: false
case .nightly, .rc: true
case .dev: false
}
}
}
Expand All @@ -59,6 +65,8 @@ public struct UpdateFeedResolver: Sendable {

/// Whether `url` points at the nightly channel (its path contains `/nightly/`).
public var isNightly: Bool { channel == .nightly }
/// Whether `url` points at the team dev channel.
public var isDev: Bool { channel == .dev }

/// Creates a resolution result.
public init(url: String, channel: Channel, usedFallback: Bool) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ private let sparkleInstallationWriteNoPermissionErrorCode = 4012
/// Chooses a direct-download recovery URL for update failures where the in-app install path is
/// broken but fetching the active channel manually is still safe.
public struct UpdateManualDownloadRecovery: Sendable {
private static let defaultDevDownloadURLString = "https://files.cmux.com/cmux-dev/classic/latest.zip"
private let stableDownloadURLString: String
private let nightlyDownloadURLString: String
private let rcDownloadURLString: String
private let devDownloadURLString: String

/// Creates a recovery resolver.
///
Expand All @@ -29,18 +31,21 @@ public struct UpdateManualDownloadRecovery: Sendable {
/// nightly DMG for `hostArchitecture`, since nightly ships one DMG per architecture.
/// - rcDownloadURLString: Direct DMG URL for the RC channel. Defaults to the RC DMG for
/// `hostArchitecture`, since RC ships one DMG per architecture like nightly.
/// - devDownloadURLString: Direct ZIP URL for the team dev channel.
/// - hostArchitecture: The architecture whose nightly and RC DMGs are offered by default.
public init(
stableDownloadURLString: String = "https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg",
nightlyDownloadURLString: String? = nil,
rcDownloadURLString: String? = nil,
devDownloadURLString: String = "https://files.cmux.com/cmux-dev/classic/latest.zip",
hostArchitecture: UpdateHostArchitecture = .current
) {
self.stableDownloadURLString = stableDownloadURLString
self.nightlyDownloadURLString = nightlyDownloadURLString
?? Self.nightlyDownloadURLString(for: hostArchitecture)
self.rcDownloadURLString = rcDownloadURLString
?? Self.rcDownloadURLString(for: hostArchitecture)
self.devDownloadURLString = devDownloadURLString
}

/// The direct nightly DMG URL for `architecture`.
Expand Down Expand Up @@ -96,8 +101,48 @@ public struct UpdateManualDownloadRecovery: Sendable {
return URL(string: nightlyDownloadURLString)
case .rc:
return URL(string: rcDownloadURLString)
case .dev:
return URL(string: devDownloadURL(for: feedURLString))
case .stable:
return URL(string: stableDownloadURLString)
}
}

private func devDownloadURL(for feedURLString: String?) -> String {
guard devDownloadURLString == Self.defaultDevDownloadURLString else {
return devDownloadURLString
}
guard let feedURLString,
let components = URLComponents(string: feedURLString),
let track = Self.devTrack(in: components.path)
else {
return devDownloadURLString
}
guard var download = URLComponents(string: devDownloadURLString) else {
return devDownloadURLString
}
var path = download.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard let devIndex = path.firstIndex(of: "cmux-dev") else {
return devDownloadURLString
}
if path.count > devIndex + 1 {
path[devIndex + 1] = track
} else {
path.append(track)
}
if path.last != "latest.zip" {
path.append("latest.zip")
}
download.path = "/" + path.joined(separator: "/")
return download.string ?? devDownloadURLString
}

private static func devTrack(in path: String) -> String? {
let components = path.split(separator: "/").map(String.init)
guard let index = components.firstIndex(of: "cmux-dev"), components.count > index + 1 else {
return nil
}
let track = components[index + 1]
return track == "classic" || track == "next" ? track : nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import Testing
// RC is on the public release train: it must keep auto-update on.
#expect(!UpdateController.isDevLikeBundleIdentifier("com.cmuxterm.app.rc"))
#expect(!UpdateController.isDevLikeBundleIdentifier("com.cmuxterm.app.rc.candidate1"))
#expect(!UpdateController.isDevLikeBundleIdentifier("com.cmuxterm.app.dev"))
#expect(!UpdateController.isDevLikeBundleIdentifier(nil))
// A look-alike that is neither the exact base id nor a dotted suffix must not match.
#expect(!UpdateController.isDevLikeBundleIdentifier("com.cmuxterm.app.debugger"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,21 @@ import Testing
#expect(!intelURL.absoluteString.contains("/nightly/"))
}

@Test func manualDownloadRoutesDevFeedToItsTrackArchive() throws {
let error = NSError(domain: UpdateStateModel.updateErrorDomain,
code: UpdateStateModel.installDidNotStartCode)
let recovery = UpdateManualDownloadRecovery()
let url = try #require(recovery.url(for: error, feedURLString: "https://files.cmux.com/cmux-dev/next/appcast.xml"))
#expect(url.absoluteString == "https://files.cmux.com/cmux-dev/next/latest.zip")

let classic = try #require(recovery.url(for: error, feedURLString: "https://files.cmux.com/cmux-dev/classic/appcast.xml"))
#expect(classic.absoluteString == "https://files.cmux.com/cmux-dev/classic/latest.zip")

let custom = UpdateManualDownloadRecovery(devDownloadURLString: "https://example.com/cmux-dev/latest.zip")
let customURL = try #require(custom.url(for: error, feedURLString: "https://files.cmux.com/cmux-dev/next/appcast.xml"))
#expect(customURL.absoluteString == "https://example.com/cmux-dev/latest.zip")
}

/// The watchdog can fire before Sparkle asks its delegate for a feed URL; in that passive path
/// the driver still needs to recover the build's appcast channel so NIGHTLY installs offer the
/// nightly DMG instead of downgrading to stable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,21 @@ import Testing
== "https://files.cmux.com/rc/appcast-arm64.xml")
}

@Test func devFeedStaysOnOneStableFeed() {
let resolver = UpdateFeedResolver(hostArchitecture: .x86_64)
let resolution = resolver.resolve(infoFeedURL: "https://files.cmux.com/cmux-dev/classic/appcast.xml")
#expect(resolution.channel == .dev)
#expect(resolution.isDev)
#expect(!resolution.isNightly)
#expect(resolution.url == "https://files.cmux.com/cmux-dev/classic/appcast.xml")
#expect(!resolution.usedFallback)
}

@Test func channelsAreClassifiedFromTheFeedPath() {
let resolver = UpdateFeedResolver(hostArchitecture: .arm64)
#expect(resolver.resolve(infoFeedURL: "https://files.cmux.com/nightly/appcast.xml").channel == .nightly)
#expect(resolver.resolve(infoFeedURL: "https://files.cmux.com/rc/appcast.xml").channel == .rc)
#expect(resolver.resolve(infoFeedURL: "https://files.cmux.com/cmux-dev/next/appcast.xml").channel == .dev)
#expect(resolver.resolve(infoFeedURL: "https://example.com/stable/appcast.xml").channel == .stable)
#expect(resolver.resolve(infoFeedURL: nil).channel == .stable)
}
Expand Down
Loading
Loading