Merge nucleic/mellow-dewy-falcon-rjhr into main
build / build (push) Canceled after 0s

This commit is contained in:
2026-08-07 04:14:34 -07:00
parent 042bd813a8
commit af0369d443
5 changed files with 450 additions and 32 deletions
+187 -29
View File
@@ -304,53 +304,134 @@ public struct GuestProvisioner: Sendable {
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for
/// component installation.
///
/// The application's name is **discovered, not assumed**. A release archive
/// expands to `Xcode.app`, a beta to `Xcode-beta.app`, and Apple has shipped
/// version-qualified names too; whatever comes out keeps its name under
/// `/Applications`, because `xcode-select -s` makes the name irrelevant to
/// anything that builds.
///
/// - Parameters:
/// - executor: A connected guest executor.
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
public func installXcode(executor: any GuestExecutor, xipPath: String) async throws {
/// - progress: Optional stage callback. Every phase here runs for tens of
/// minutes, so silence is indistinguishable from a hang — this is the
/// only thing that says otherwise.
public func installXcode(
executor: any GuestExecutor,
xipPath: String,
progress: (@Sendable (String) -> Void)? = nil
) async throws {
let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
guard FileManager.default.fileExists(atPath: localURL.path) else {
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)")
}
let localBytes =
(try? FileManager.default.attributesOfItem(atPath: localURL.path))?[.size] as? Int ?? 0
let remoteXIP = "/tmp/Xcode.xip"
// Uploads go over an SSH exec channel with the payload as stdin, and
// `GuestExecutor.upload` reads the whole local file into memory first —
// fine for a 90 MB pkg, ruinous for a 12 GB xip. So this streams the file
// in bounded chunks and appends them guest-side instead. It is still slow
// (an exec channel is not SCP), but it is functional and its host memory
// use is capped at one chunk.
try await executor.runChecked("rm -f \(Self.shellQuote(remoteXIP))", timeout: .seconds(120))
try await Self.uploadLargeFile(executor: executor, localURL: localURL, remotePath: remoteXIP)
// Free the disk the old copy occupies before expanding into ~40 GB more.
_ = try? await executor.run("sudo -n rm -rf /Applications/Xcode.app", timeout: .seconds(600))
let staging = "/tmp/xcode-expand"
// `xip --expand` writes into the current directory and needs no sudo, but
// /tmp is small on some layouts; staging under /tmp keeps it beside the
// archive so the later move is a rename within one volume where possible.
try await executor.runChecked(
"rm -rf \(Self.shellQuote(staging)) && mkdir -p \(Self.shellQuote(staging))",
timeout: .seconds(300)
)
let quotedXIP = Self.shellQuote(remoteXIP)
let quotedStaging = Self.shellQuote(staging)
// Expansion of a full Xcode takes 20–45 minutes on VM-backed storage.
try await executor.runChecked(
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))",
timeout: .seconds(5400)
)
// Is a previous run's expansion still sitting there, complete? Then the
// upload and the expansion — between them the entire cost of this
// function — are already paid for. Opportunistic only: macOS clears /tmp
// on boot, so after the guest has been power-cycled this finds nothing,
// which is fine.
var expandedApp = try await Self.reusableExpandedApp(executor: executor, staging: staging)
if let expandedApp {
progress?("reusing expanded \((expandedApp as NSString).lastPathComponent)")
} else {
try await Self.checkGuestDisk(executor: executor, xipBytes: localBytes, progress: progress)
if try await Self.hasMatchingUpload(
executor: executor, remotePath: remoteXIP, expectedBytes: localBytes)
{
progress?("reusing uploaded xip (\(XcodeInstall.formatGB(localBytes)))")
} else {
// Uploads go over an SSH exec channel with the payload as stdin,
// and `GuestExecutor.upload` reads the whole local file into
// memory first — fine for a 90 MB pkg, ruinous for a 12 GB xip.
// So this streams the file in bounded chunks and appends them
// guest-side instead. It is still slow (an exec channel is not
// SCP), but it is functional and its host memory use is capped at
// one chunk.
let headline = "uploading Xcode (\(XcodeInstall.formatGB(localBytes)))"
progress?(headline + " 0%")
try await executor.runChecked("rm -f \(quotedXIP)", timeout: .seconds(120))
try await Self.uploadLargeFile(
executor: executor,
localURL: localURL,
remotePath: remoteXIP,
progress: { fraction in
progress?(headline + " \(Int(fraction * 100))%")
}
)
progress?(headline + " 100%")
}
// A half-finished expansion from an earlier attempt would leave
// `ls *.app` ambiguous, or leave a truncated bundle to be installed.
// Clear it before, not after.
try await executor.runChecked(
"rm -rf \(quotedStaging) && mkdir -p \(quotedStaging)", timeout: .seconds(600))
// `xip --expand` writes into the current directory and needs no sudo,
// but staging beside the archive keeps the later move a rename within
// one volume. Expansion of a full Xcode takes 20–45 minutes on
// VM-backed storage.
progress?("expanding xip (takes 15-40 min)…")
try await executor.runChecked(
"cd \(quotedStaging) && sudo -n /usr/bin/xip --expand \(quotedXIP)",
timeout: .seconds(5400)
)
// Immediately, and unconditionally on success: the archive is dead
// weight from here on, and the guest is at its tightest right now
// holding both copies. Deleting it as part of a success-only `&&`
// chain at the very end — which is what this used to do — means a
// failure anywhere later strands 12 GB in /tmp.
_ = try? await executor.run("rm -f \(quotedXIP)", timeout: .seconds(300))
let listing = try await executor.run(
"ls -d \(quotedStaging)/*.app 2>/dev/null", timeout: .seconds(300))
expandedApp = try XcodeInstall.expandedAppPath(
fromListing: listing.stdout, staging: staging)
}
guard let sourceApp = expandedApp else {
throw CoreError.provisioningFailed("could not locate the expanded Xcode in \(staging)")
}
let appName = (sourceApp as NSString).lastPathComponent
let destination = "/Applications/" + appName
let quotedDestination = Self.shellQuote(destination)
// Whatever is already there loses. This is a golden image being built to
// a specification, not a user's Mac, and leaving the old copy would both
// fail the move and waste tens of gigabytes in every clone.
let existing = try await executor.run(
"test -e \(quotedDestination) && echo present", timeout: .seconds(120))
if existing.stdout.contains("present") {
progress?("replacing existing \(appName) in the guest")
try await executor.runChecked(
"sudo -n rm -rf \(quotedDestination)", timeout: .seconds(1800))
}
// Writing into /Applications needs root.
progress?("installing \(appName)…")
try await executor.runChecked(
"sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app "
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
"sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)",
timeout: .seconds(1800)
)
_ = try? await executor.run("rm -rf \(quotedStaging)", timeout: .seconds(600))
// xcode-select writes /var/db/xcode_select_link — root only.
// xcode-select writes /var/db/xcode_select_link — root only. Pointing it
// at the discovered path is what makes the bundle's name a non-issue:
// `xcodebuild`, `swift`, and every `xcrun` shim resolve through this.
try await executor.runChecked(
"sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app/Contents/Developer",
"sudo -n /usr/bin/xcode-select -s "
+ Self.shellQuote(destination + "/Contents/Developer"),
timeout: .seconds(300)
)
@@ -361,6 +442,7 @@ public struct GuestProvisioner: Sendable {
"sudo -n /usr/bin/xcodebuild -license accept",
timeout: .seconds(600)
)
progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…")
try await executor.runChecked(
"sudo -n /usr/bin/xcodebuild -runFirstLaunch",
timeout: .seconds(3600)
@@ -373,6 +455,82 @@ public struct GuestProvisioner: Sendable {
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
)
}
// Proof, in the operator's log, that the thing they waited an hour for
// is actually there and selected — on one line, since `-version` prints
// two.
let version = check.stdout
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.joined(separator: " — ")
progress?("Xcode ready: \(version) at \(destination)")
}
/// An already-expanded application left by an earlier attempt, if one is
/// there and looks complete.
///
/// "Complete" is `Contents/MacOS` existing: an expansion killed part-way
/// leaves a directory tree that `ls` is perfectly happy to list, and
/// installing that would produce an Xcode that fails at first use rather
/// than at install time. Never throws — a guest with nothing staged is the
/// normal case, and an ambiguous listing here just means "do it properly".
static func reusableExpandedApp(
executor: any GuestExecutor,
staging: String
) async throws -> String? {
let listing = try await executor.run(
"ls -d \(shellQuote(staging))/*.app 2>/dev/null", timeout: .seconds(120))
guard let app = try? XcodeInstall.expandedAppPath(fromListing: listing.stdout, staging: staging)
else { return nil }
let complete = try await executor.run(
"test -d \(shellQuote(app + "/Contents/MacOS")) && echo ok", timeout: .seconds(120))
return complete.stdout.contains("ok") ? app : nil
}
/// Whether the guest already holds a byte-for-byte-sized copy of the upload.
///
/// Size only — hashing 12 GB over an exec channel would cost more than the
/// upload it is trying to avoid. The archive is written by this code alone,
/// to a fixed path, so a size match is strong enough evidence; a partial
/// upload from an interrupted run is shorter and fails the check.
static func hasMatchingUpload(
executor: any GuestExecutor,
remotePath: String,
expectedBytes: Int
) async throws -> Bool {
guard expectedBytes > 0 else { return false }
let result = try await executor.run(
"stat -f %z \(shellQuote(remotePath)) 2>/dev/null", timeout: .seconds(120))
let reported = Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines))
return reported == expectedBytes
}
/// Refuses the install before the upload when the guest cannot hold it.
///
/// The failure this replaces is the worst kind: `xip --expand` fills the
/// disk half an hour in, and the error names neither how much was needed nor
/// what to do about it. Unparseable `df` output is treated as "cannot check"
/// and allowed through — a pre-flight that blocks the install because it did
/// not recognise the output is worse than the problem.
static func checkGuestDisk(
executor: any GuestExecutor,
xipBytes: Int,
progress: (@Sendable (String) -> Void)?
) async throws {
guard xipBytes > 0 else { return }
let result = try await executor.run("df -Pk /", timeout: .seconds(120))
guard let available = XcodeInstall.availableBytes(dfOutput: result.stdout) else { return }
let needed = XcodeInstall.requiredFreeBytes(xipBytes: xipBytes)
guard available >= needed else {
throw CoreError.provisioningFailed(
XcodeInstall.insufficientDiskMessage(xipBytes: xipBytes, availableBytes: available)
)
}
progress?(
"guest disk: \(XcodeInstall.formatGB(available)) free, "
+ "\(XcodeInstall.formatGB(needed)) needed")
}
/// The Node.js version installed when none is specified.