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
"""
}
}