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
+130
View File
@@ -0,0 +1,130 @@
import Foundation
/// The decisions in an Xcode install that are pure string and arithmetic work.
///
/// Installing Xcode into a guest is a long chain of SSH commands, and the parts
/// of it that are easy to get wrong — which `.app` came out of the archive, how
/// much disk the expansion is going to want, what `df` actually said — are all
/// decidable from text. They live here so they can be tested without a VM,
/// which is the only way they ever get tested: the surrounding code takes forty
/// minutes and a 12 GB file to run once.
public enum XcodeInstall {
// MARK: - Which app came out of the archive
/// Picks the expanded application bundle out of an `ls -d …/*.app` listing.
///
/// The archive's payload is *not* reliably named `Xcode.app`. Beta releases
/// expand to `Xcode-beta.app`, and Apple has shipped version-qualified names
/// before, so the name has to be discovered rather than assumed — hardcoding
/// it is what made a successful 12 GB upload and a 30-minute expansion fail
/// on the very last `mv`.
///
/// - Parameters:
/// - listing: Standard output of `ls -d <staging>/*.app`.
/// - staging: The directory that was listed, named in error messages.
/// - Returns: The full path of the single matching bundle.
/// - Throws: ``CoreError/provisioningFailed(_:)`` for zero or several
/// matches. Both are ambiguous rather than recoverable: picking one of two
/// candidates would install something the operator did not ask for.
public static func expandedAppPath(fromListing listing: String, staging: String) throws -> String {
let candidates = listing
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
// An unmatched glob is echoed back verbatim by /bin/sh, so the
// no-match case arrives looking like a path that ends in `*.app`.
.filter { !$0.contains("*") }
switch candidates.count {
case 1:
return candidates[0]
case 0:
throw CoreError.provisioningFailed(
"the Xcode archive expanded but produced no .app in \(staging). "
+ "The download may be truncated — check the .xip and try again."
)
default:
let names = candidates.map { ($0 as NSString).lastPathComponent }
throw CoreError.provisioningFailed(
"the Xcode archive expanded to \(candidates.count) applications in \(staging) "
+ "(\(names.joined(separator: ", "))), so it is not clear which to install. "
+ "Expand the .xip by hand and pass a single-application archive."
)
}
}
// MARK: - Disk
/// How much room the expansion of an archive is expected to need, excluding
/// the archive itself.
///
/// A `.xip` is an LZMA-compressed cpio of the whole application, and Xcode
/// compresses well: recent releases land near 3.5× on expansion. This is an
/// estimate used for a pre-flight, so it is deliberately the ratio at the
/// pessimistic end of what has been observed rather than an average — the
/// cost of overestimating is a clear error message, and the cost of
/// underestimating is a guest that runs out of disk 35 minutes in.
public static func expansionEstimateBytes(xipBytes: Int) -> Int {
(xipBytes * 7) / 2
}
/// Total free space the guest needs before the upload starts: the uploaded
/// archive plus everything it expands into.
///
/// Both have to coexist — `xip --expand` reads the archive while it writes —
/// and the archive is deleted as soon as the expansion succeeds, before the
/// move, which is a same-volume rename that needs no headroom of its own.
public static func requiredFreeBytes(xipBytes: Int) -> Int {
xipBytes + expansionEstimateBytes(xipBytes: xipBytes)
}
/// Reads the available-bytes column out of `df -Pk` output.
///
/// `-P` matters: without it `df` wraps a long device name onto its own line
/// and the columns stop lining up. `-k` fixes the block size at 1024, so the
/// value does not depend on the guest's `BLOCKSIZE`.
///
/// - Returns: Free bytes, or `nil` if the output was not in the expected
/// shape — the caller treats that as "could not check" rather than as a
/// failure, since refusing to install because `df` was unparseable would
/// be worse than the risk it guards against.
public static func availableBytes(dfOutput: String) -> Int? {
for line in dfOutput.split(separator: "\n") {
let fields = line.split(whereSeparator: \.isWhitespace)
guard fields.count >= 4, fields[0] != "Filesystem",
let kilobytes = Int(fields[3])
else { continue }
return kilobytes * 1024
}
return nil
}
/// Renders a byte count the way the progress lines do, e.g. `12.4 GB`.
///
/// Decimal gigabytes, matching how the archives are advertised and how
/// Finder reports them, so the number in an error message is the number the
/// operator can see on their own disk.
public static func formatGB(_ bytes: Int) -> String {
String(format: "%.1f GB", Double(bytes) / 1_000_000_000)
}
/// The message shown when the guest cannot fit the install.
///
/// Built here, with the numbers spelled out, because "no space left on
/// device" 35 minutes into an expansion tells the operator nothing about how
/// much bigger the image needed to be.
public static func insufficientDiskMessage(xipBytes: Int, availableBytes: Int) -> String {
let needed = requiredFreeBytes(xipBytes: xipBytes)
return """
not enough free disk in the guest to install Xcode: \
\(formatGB(availableBytes)) available, about \(formatGB(needed)) needed \
(\(formatGB(xipBytes)) for the archive plus roughly \
\(formatGB(expansionEstimateBytes(xipBytes: xipBytes))) once expanded).
Rebuild the base image with a larger disk, e.g.:
gitea-macos-runner image delete <name>
gitea-macos-runner image build --ipsw <path> --disk-gb 200
"""
}
}
+187 -29
View File
@@ -304,53 +304,134 @@ public struct GuestProvisioner: Sendable {
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for /// running `xcodebuild -runFirstLaunch` so the first job does not pay for
/// component installation. /// 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: /// - Parameters:
/// - executor: A connected guest executor. /// - executor: A connected guest executor.
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded. /// - 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) let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
guard FileManager.default.fileExists(atPath: localURL.path) else { guard FileManager.default.fileExists(atPath: localURL.path) else {
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)") 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" 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" let staging = "/tmp/xcode-expand"
// `xip --expand` writes into the current directory and needs no sudo, but let quotedXIP = Self.shellQuote(remoteXIP)
// /tmp is small on some layouts; staging under /tmp keeps it beside the let quotedStaging = Self.shellQuote(staging)
// 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)
)
// Expansion of a full Xcode takes 20–45 minutes on VM-backed storage. // Is a previous run's expansion still sitting there, complete? Then the
try await executor.runChecked( // upload and the expansion — between them the entire cost of this
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))", // function — are already paid for. Opportunistic only: macOS clears /tmp
timeout: .seconds(5400) // 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. // Writing into /Applications needs root.
progress?("installing \(appName)…")
try await executor.runChecked( try await executor.runChecked(
"sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app " "sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)",
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
timeout: .seconds(1800) 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( 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) timeout: .seconds(300)
) )
@@ -361,6 +442,7 @@ public struct GuestProvisioner: Sendable {
"sudo -n /usr/bin/xcodebuild -license accept", "sudo -n /usr/bin/xcodebuild -license accept",
timeout: .seconds(600) timeout: .seconds(600)
) )
progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…")
try await executor.runChecked( try await executor.runChecked(
"sudo -n /usr/bin/xcodebuild -runFirstLaunch", "sudo -n /usr/bin/xcodebuild -runFirstLaunch",
timeout: .seconds(3600) timeout: .seconds(3600)
@@ -373,6 +455,82 @@ public struct GuestProvisioner: Sendable {
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr) + 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. /// The Node.js version installed when none is specified.
+5 -1
View File
@@ -672,10 +672,14 @@ public struct ImageBuilder: Sendable {
if let xcodeXIPPath { if let xcodeXIPPath {
progress?(.provisioning(step: "Xcode")) progress?(.provisioning(step: "Xcode"))
// Xcode is the one step measured in hours, so it reports its own
// sub-stages rather than going quiet behind a single headline.
try await provisioner.installXcode( try await provisioner.installXcode(
executor: executor, executor: executor,
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
) ) { step in
progress?(.provisioning(step: step))
}
} }
} catch { } catch {
await executor.close() await executor.close()
+12 -2
View File
@@ -309,13 +309,23 @@ struct ImageCommand: AsyncParsableCommand {
case .installing: return "install" case .installing: return "install"
case .firstBoot: return "firstBoot" case .firstBoot: return "firstBoot"
// Each provisioning step is its own headline — "installing Node.js" // Each provisioning step is its own headline — "installing Node.js"
// should not erase "downloading gitea-runner". // should not erase "downloading gitea-runner" — but a step that carries
case .provisioning(let step): return "provisioning:\(step)" // a live percentage keeps rewriting one line rather than scrolling a
// hundred of them, so only the part before the payload identifies it.
case .provisioning(let step): return "provisioning:\(Self.stableHead(of: step))"
case .finalizing: return "finalizing" case .finalizing: return "finalizing"
case .done: return "done" case .done: return "done"
} }
} }
/// The fixed part of a status line: everything before the two-space run that
/// separates a headline from its payload, following the same convention as
/// `installing macOS [====]`. A step with no payload is its own head.
static func stableHead(of step: String) -> String {
guard let separator = step.range(of: " ") else { return step }
return String(step[step.startIndex..<separator.lowerBound])
}
/// Renders a build stage as one status line. /// Renders a build stage as one status line.
static func describe(_ stage: ImageBuildStage) -> String { static func describe(_ stage: ImageBuildStage) -> String {
switch stage { switch stage {
@@ -0,0 +1,116 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``XcodeInstall`` — the decidable parts of installing Xcode.
///
/// The bug these exist for: the installer assumed the archive expanded to
/// `Xcode.app`. A beta expands to `Xcode-beta.app`, so a 12 GB upload and a
/// half-hour expansion both succeeded and then the final `mv` failed with
/// "No such file or directory". Nothing about that is worth discovering from a
/// forty-minute live run twice.
@Suite("XcodeInstall")
struct XcodeInstallTests {
// MARK: - Discovering the expanded app
@Test("a release archive's Xcode.app is found")
func findsReleaseApp() throws {
let path = try XcodeInstall.expandedAppPath(
fromListing: "/tmp/xcode-expand/Xcode.app\n", staging: "/tmp/xcode-expand")
#expect(path == "/tmp/xcode-expand/Xcode.app")
}
/// The reported failure, in one line.
@Test("a beta archive's Xcode-beta.app is found")
func findsBetaApp() throws {
let path = try XcodeInstall.expandedAppPath(
fromListing: "/tmp/xcode-expand/Xcode-beta.app", staging: "/tmp/xcode-expand")
#expect(path == "/tmp/xcode-expand/Xcode-beta.app")
}
@Test("a version-qualified name is found")
func findsVersionQualifiedApp() throws {
let path = try XcodeInstall.expandedAppPath(
fromListing: " /tmp/xcode-expand/Xcode_16.2.app \n\n", staging: "/tmp/xcode-expand")
#expect(path == "/tmp/xcode-expand/Xcode_16.2.app")
}
/// `/bin/sh` echoes an unmatched glob back verbatim, so "no match" arrives
/// as a plausible-looking path rather than as empty output.
@Test("an unmatched glob reads as no match, not as a path")
func unmatchedGlobIsNotAPath() {
#expect(throws: CoreError.self) {
try XcodeInstall.expandedAppPath(
fromListing: "/tmp/xcode-expand/*.app\n", staging: "/tmp/xcode-expand")
}
}
@Test("empty output is an error naming the staging directory")
func emptyListingThrows() {
do {
_ = try XcodeInstall.expandedAppPath(fromListing: "\n \n", staging: "/tmp/xcode-expand")
Issue.record("expected a failure")
} catch {
#expect("\(error)".contains("/tmp/xcode-expand"))
}
}
/// Guessing between two candidates would install something the operator did
/// not ask for, so this stops instead.
@Test("several apps is an error listing them")
func ambiguousListingThrows() {
do {
_ = try XcodeInstall.expandedAppPath(
fromListing: "/tmp/x/Xcode.app\n/tmp/x/Xcode-beta.app\n", staging: "/tmp/x")
Issue.record("expected a failure")
} catch {
let text = "\(error)"
#expect(text.contains("Xcode.app"))
#expect(text.contains("Xcode-beta.app"))
}
}
// MARK: - Disk arithmetic
@Test("the requirement covers the archive and its expansion")
func requiredFreeSpaceCoversBoth() {
let xip = 12_000_000_000
#expect(XcodeInstall.expansionEstimateBytes(xipBytes: xip) == 42_000_000_000)
#expect(XcodeInstall.requiredFreeBytes(xipBytes: xip) == 54_000_000_000)
}
@Test("df -Pk output yields available bytes")
func parsesDF() {
let output = """
Filesystem 1024-blocks Used Available Capacity Mounted on
/dev/disk3s5 488245288 120000000 62914560 66% /
"""
#expect(XcodeInstall.availableBytes(dfOutput: output) == 62_914_560 * 1024)
}
/// Unparseable output means "could not check", not "no space" — the caller
/// must not refuse to install because `df` printed something unexpected.
@Test("unparseable df output yields nil rather than zero")
func unparseableDFIsNil() {
#expect(XcodeInstall.availableBytes(dfOutput: "df: /nope: No such file or directory") == nil)
#expect(XcodeInstall.availableBytes(dfOutput: "") == nil)
}
@Test("the shortfall message names every number and a remedy")
func messageNamesTheNumbers() {
let text = XcodeInstall.insufficientDiskMessage(
xipBytes: 12_000_000_000, availableBytes: 20_000_000_000)
#expect(text.contains("20.0 GB")) // available
#expect(text.contains("54.0 GB")) // needed
#expect(text.contains("12.0 GB")) // the archive
#expect(text.contains("--disk-gb"))
}
@Test("byte counts render as decimal gigabytes")
func formatsGigabytes() {
#expect(XcodeInstall.formatGB(12_400_000_000) == "12.4 GB")
#expect(XcodeInstall.formatGB(0) == "0.0 GB")
}
}