Compare commits

...
18 Commits
Author SHA1 Message Date
abkslm af0369d443 Merge nucleic/mellow-dewy-falcon-rjhr into main
build / build (push) Canceled after 0s
2026-08-07 04:14:34 -07:00
abkslm 042bd813a8 Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 03:46:54 -07:00
abkslm b84835649c Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 03:30:45 -07:00
abkslm c71a457bcf Nucleic: Gitea Runner macOS VM Support 2026-08-07 03:30:44 -07:00
abkslm adb7dedd80 Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 03:12:18 -07:00
abkslm bddb120a56 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 03:12:18 -07:00
abkslm 748bc7e5e5 Nucleic: Gitea Runner macOS VM Support 2026-08-07 03:12:18 -07:00
abkslm e7163de22f Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 02:30:26 -07:00
abkslm 3b4f631dd8 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 02:30:26 -07:00
abkslm b2f15883d8 Nucleic: Gitea Runner macOS VM Support 2026-08-07 02:30:26 -07:00
abkslm d64b3f3e9b Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 01:40:57 -07:00
abkslm ab4ed9c8a1 Nucleic: Gitea Runner macOS VM Support 2026-08-07 01:40:57 -07:00
abkslm 697a4cdcd0 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 01:14:28 -07:00
abkslm 975cf8c6ea Nucleic: Gitea Runner macOS VM Support 2026-08-07 01:14:28 -07:00
abkslm 40697b8329 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 01:02:32 -07:00
abkslm 9edaa3e409 Nucleic: Gitea Runner macOS VM Support 2026-08-07 01:02:32 -07:00
abkslm 11019d498b Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 00:44:36 -07:00
abkslm 33f299396a Nucleic: Gitea Runner macOS VM Support 2026-08-07 00:44:36 -07:00
18 changed files with 1732 additions and 133 deletions
+74
View File
@@ -0,0 +1,74 @@
# Builds gitea-macos-runner on the macOS runners gitea-macos-runner itself
# provides. The repo is its own integration test: if this workflow goes green,
# a guest image really can check out a repo, run a Swift toolchain, and produce
# a signed .app.
name: build
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build:
# The bare label the daemon registers with. There is no container image
# here: jobs run in `:host` mode, directly in an ephemeral macOS 27 guest
# as the admin account, and the guest is destroyed afterwards.
runs-on: macos-arm64
# Well under the scheduler's 120-minute job ceiling. A clean release build
# plus the test suite is a few minutes; anything approaching an hour means
# something is wedged and the VM should be reclaimed rather than left
# holding one of the host's two guest slots.
timeout-minutes: 60
steps:
# Needs Node.js in the guest, which the base image provisions.
- uses: actions/checkout@v4
# First thing in the log, deliberately. Every plausible failure of this
# workflow that is not the code's fault is a toolchain that did not make
# it into the image — Command Line Tools missing, or present but not
# selected. Printing this up front turns "swift: command not found"
# forty lines down into a one-glance diagnosis.
- name: toolchain versions
run: |
sw_vers
swift --version
xcode-select -p
# RunnerCoreTests only. RunnerHost is compiled (it is a dependency) but
# never exercised: nothing here touches Virtualization.framework at
# runtime, which matters because the guest cannot nest VMs.
- name: unit tests
run: swift test
# Release build, .app assembly, ad-hoc signature. `codesign --sign -`
# needs no signing identity and no keychain, so it works unattended in a
# throwaway guest — which is the same property that makes it the
# project's shipping signature.
- name: build and sign the app bundle
run: make all
# Proves the two things a bare `swift build` cannot: that the entitlement
# survived signing, and that the runtime resources were copied in. An
# .app missing either compiles perfectly and then fails at the first
# `image build` — exactly the class of breakage worth catching in CI.
- name: verify the bundle
# `grep … && echo` would be a check that cannot fail: bash exempts
# every command in an `&&` list except the last one from `set -e`, so a
# bundle signed with no entitlements at all would sail through. The
# grep therefore stands alone, as the step's own pass/fail.
run: |
codesign -d --entitlements - --xml .build/GiteaMacosRunner.app | grep -q virtualization
echo "entitlement OK"
ls .build/GiteaMacosRunner.app/Contents/Resources/
# Deliberately absent: `doctor`, `vm`, `daemon`, and `image` steps.
#
# All four either start a VM or check the host's ability to start one, and this
# job is already running inside a guest. Virtualization.framework does not
# nest, so those steps would not be a stricter test — they would be a
# guaranteed failure that says nothing about the code. The host-side behaviour
# they cover is verified on a real host, not here.
+19
View File
@@ -105,6 +105,25 @@ jobs:
The daemon picks the job up within one poll interval, boots a VM, and tears it down when the job
finishes.
## CI
This repo builds itself. [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) runs on
`macos-arm64` — the very runners this project provides — and does a full `swift test`, `make all`,
and a check that the resulting `.app` carries the virtualization entitlement and its runtime
resources. A green run is also an end-to-end test of the runner: it means a guest image really can
check out a repo, run the Swift toolchain, and produce a signed bundle.
For it to run at all you need:
- the repo pushed to a Gitea **1.25 or newer** instance with Actions enabled
(`[actions] ENABLED = true`),
- `gitea-macos-runner daemon` running on an Apple silicon host and registered with that instance,
- a base image built and provisioned (`image build`) — `image list` should show `PROVISIONED: yes`.
The workflow deliberately runs no `doctor`, `vm`, `daemon`, or `image` steps. Those start a VM or
probe the host's ability to start one, and the job is already inside a guest; Virtualization
does not nest, so they would fail for reasons that say nothing about the code.
## Documentation
- [docs/setup.md](docs/setup.md) — full Gitea-side and host-side walkthrough, config reference,
+10 -2
View File
@@ -124,8 +124,16 @@ fi
# --------------------------------------------------------------------------
log "ensuring /usr/local/bin exists and is on PATH for non-login shells"
mkdir -p /usr/local/bin
chown root:wheel /usr/local /usr/local/bin
chmod 755 /usr/local /usr/local/bin
# Best-effort, deliberately: on a stock macOS 27 install /usr/local already
# exists, already is root:wheel 755, and is SIP-protected — so chown and chmod
# on it fail with "Operation not permitted" even as root. The directory is
# already exactly as we want it, so treating that refusal as a build failure
# would abort provisioning over a no-op. When /usr/local really was ours to
# create, these succeed.
chown root:wheel /usr/local /usr/local/bin 2>/dev/null \
|| warn "could not chown /usr/local (system-owned and SIP-protected; already correct)"
chmod 755 /usr/local /usr/local/bin 2>/dev/null \
|| warn "could not chmod /usr/local (system-owned and SIP-protected; already correct)"
ZSHENV_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH"
if [ ! -f /etc/zshenv ] || ! grep -qF "$ZSHENV_MARKER" /etc/zshenv 2>/dev/null; then
+187
View File
@@ -0,0 +1,187 @@
import Foundation
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
/// Resolves a path typed on the command line into a single concrete path.
///
/// The problem this exists for: an operator writes
/// `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`. Unquoted, the shell may expand
/// the glob before we ever run — or, in `zsh`, refuse to run the command at all
/// with `no matches found`. Quoted, we receive the pattern verbatim, tilde and
/// asterisk included, and a plain `expandingTildeInPath` leaves a `*` sitting in
/// the middle of a path that will never exist. Either way the operator sees a
/// tool that "only works with quotes" (or only without them).
///
/// So the CLI resolves the argument itself and stops depending on which shell
/// ran it. This is deliberately a **CLI-argument affordance only**: values read
/// out of `config.json` get tilde expansion (see
/// ``RunnerConfig/expandTilde(_:)``) and nothing more, because a config file is
/// not typed at a prompt and a stray `*` there is a mistake, not a pattern.
public enum PathResolution {
/// Characters that make a string a pattern rather than a path.
///
/// `~` is not one of them: it is expanded unconditionally, pattern or not.
private static let metacharacters: Set<Character> = ["*", "?", "["]
/// Whether `path` should be treated as a glob pattern.
public static func isPattern(_ path: String) -> Bool {
path.contains(where: metacharacters.contains)
}
/// Expands a leading `~`, then resolves any glob pattern to one path.
///
/// A string with no metacharacters passes through with only tilde
/// expansion — in particular it is *not* checked for existence, so the
/// caller's own error (which knows what the file was for) is what an
/// operator sees for an ordinary typo.
///
/// A string that does contain metacharacters is matched with `glob(3)`. If
/// it matches nothing but a file exists at that literal name, the literal
/// wins: `report[1].ipsw` is a legal filename, and only failing to match
/// tells us it was meant as one rather than as a character class.
///
/// - Parameters:
/// - path: The raw argument, as typed.
/// - label: What the argument names, for error messages (`"--ipsw"`).
/// - Returns: A single concrete path.
/// - Throws: ``CoreError/notFound(_:)`` when a pattern matches nothing, or
/// ``CoreError/configInvalid(_:)`` when it matches more than one file —
/// picking one arbitrarily would silently build the wrong image.
public static func resolve(_ path: String, label: String) throws -> String {
let expanded = RunnerConfig.expandTilde(path)
guard isPattern(expanded) else { return expanded }
let matches = glob(pattern: expanded)
switch matches.count {
case 1:
return matches[0]
case 0:
if FileManager.default.fileExists(atPath: expanded) { return expanded }
throw CoreError.notFound("\(label): no file matches \(expanded)")
default:
let listed = matches.map { " \($0)" }.joined(separator: "\n")
throw CoreError.configInvalid(
"\(label): \(matches.count) files match \(expanded):\n\(listed)\n"
+ "name exactly one of them"
)
}
}
/// All paths matching `pattern`, sorted.
///
/// Sorted explicitly rather than relying on `glob(3)`'s own ordering, which
/// is locale-dependent — the error message above lists these, and a listing
/// that reorders between runs is a poor thing to ask someone to read.
static func glob(pattern: String) -> [String] {
var result = glob_t()
defer { globfree(&result) }
guard Glibc_glob(pattern, &result) == 0 else { return [] }
guard let paths = result.gl_pathv else { return [] }
var found: [String] = []
for index in 0..<Int(result.gl_pathc) {
guard let entry = paths[index] else { continue }
found.append(String(cString: entry))
}
return found.sorted()
}
/// Thin shim so the call above reads the same on both platforms; `glob(3)`
/// is otherwise shadowed by the ``glob(pattern:)`` above.
private static func Glibc_glob(
_ pattern: String,
_ result: UnsafeMutablePointer<glob_t>
) -> Int32 {
#if canImport(Darwin)
return Darwin.glob(pattern, 0, nil, result)
#elseif canImport(Glibc)
return Glibc.glob(pattern, 0, nil, result)
#else
return -1
#endif
}
}
/// Cheap sanity checks on a `.ipsw` before Virtualization.framework sees it.
///
/// Lives beside ``PathResolution`` because it is the other half of the same
/// job — what the CLI does with a path an operator typed — and because
/// `RunnerCore` is the only target that unit-tests on both platforms.
///
/// The checks earn their place: `VZMacOSRestoreImage.image(from:)` reports no
/// progress at all while it works, and on a partial download it can sit for a
/// very long time rather than failing. Without these, "I pointed it at the
/// wrong file" and "my 21 GB download stopped at 4 GB" both present to the
/// operator as an unexplained hang.
public enum IPSWFile {
/// The floor a real macOS restore image clears by an order of magnitude —
/// they run ~15-22 GB. Anything under this is a truncated download, a
/// placeholder, or the wrong file entirely.
public static let minimumBytes: Int64 = 1_000_000_000
/// The first two bytes of every `.ipsw`: an IPSW is a zip archive.
static let zipMagic = Data([0x50, 0x4B]) // "PK"
/// Fails fast if `path` cannot be a usable restore image.
///
/// - Parameters:
/// - path: An already-resolved absolute path (see ``PathResolution``).
/// - label: What the file is, for error messages.
/// - Throws: ``CoreError/notFound(_:)`` if it is not there or unreadable,
/// ``CoreError/configInvalid(_:)`` if it is there but cannot be an IPSW.
public static func validate(path: String, label: String = "restore image") throws {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else {
throw CoreError.notFound("\(label) not found at \(path)")
}
guard !isDirectory.boolValue else {
throw CoreError.configInvalid(
"\(label) at \(path) is a directory, not an .ipsw file"
)
}
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
guard size >= minimumBytes else {
throw CoreError.configInvalid(
"\(label) at \(path) is only \(describeSize(size)) — a macOS restore image is "
+ "~15-22 GB. The download is most likely incomplete; delete the file and "
+ "fetch it again."
)
}
guard let handle = FileHandle(forReadingAtPath: path) else {
throw CoreError.notFound("\(label) at \(path) could not be opened for reading")
}
defer { try? handle.close() }
let magic = (try? handle.read(upToCount: zipMagic.count)) ?? Data()
guard magic == zipMagic else {
let found = magic.map { String(format: "%02x", $0) }.joined()
throw CoreError.configInvalid(
"\(label) at \(path) does not look like an .ipsw: expected a zip archive "
+ "(magic \"PK\", 504b) but the file starts with \(found.isEmpty ? "nothing" : found). "
+ "Check the path, or re-download the file."
)
}
}
/// A byte count an operator can compare against "~15 GB" at a glance.
static func describeSize(_ bytes: Int64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var value = Double(bytes)
var unit = 0
while value >= 1024, unit < units.count - 1 {
value /= 1024
unit += 1
}
return unit == 0 ? "\(Int(value)) B" : String(format: "%.1f %@", value, units[unit])
}
}
+48 -2
View File
@@ -313,11 +313,48 @@ enum SSHTransportError: Error {
var asCoreError: CoreError {
switch self {
case .connectFailed(let host, let port, let underlying):
return .sshFailed("cannot connect to \(host):\(port): \(underlying)")
return .sshFailed(
"cannot connect to \(host):\(port): \(underlying)"
+ Self.localNetworkHint(for: underlying)
)
case .authenticationFailed(let host, let username):
return .sshFailed("authentication failed for \(username)@\(host)")
}
}
/// Extra guidance for the one connect failure that is usually not a network
/// problem at all.
///
/// macOS 15 and newer filter local-network traffic per app, and a blocked
/// flow is not reported as "denied": the filter answers `EHOSTUNREACH`
/// (errno 65, "No route to host"), which is exactly what a guest that is
/// genuinely off the network looks like. Guests here sit on a host-private
/// NAT link that is reachable whenever the VM is up, so on this code path
/// that errno is more often the privacy filter than a routing failure —
/// worth naming rather than leaving the operator to guess.
///
/// It matters most right after a rebuild. Per
/// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
/// the grant "uses your main executable UUID", and the linker mints a fresh
/// `LC_UUID` on essentially every build — so `make install` can present a
/// program macOS has never seen, whose permission is undetermined again,
/// even though the previous binary worked minutes earlier.
static func localNetworkHint(for underlying: any Error) -> String {
let text = "\(underlying)".lowercased()
guard text.contains("errno: 65") || text.contains("no route to host")
|| text.contains("host is unreachable")
else { return "" }
return """
(on macOS 15+ this is also what Local Network privacy returns when \
it blocks an app — and the grant is keyed on the executable's UUID, \
so every rebuild withdraws it. Pre-authorize the guest subnet \
instead: sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" — same \
for AllowedWiFiLocalNetworkAddresses — then reboot. \
See docs/troubleshooting.md)
"""
}
}
/// Shared, thread-safe record of whether the server rejected our password.
@@ -584,6 +621,15 @@ public func waitForSSH(
guard ContinuousClock.now - started < timeout else { break }
}
let detail = lastError.map { "; last error: \($0)" } ?? ""
// Rendered through `asCoreError` rather than interpolated raw: a connect
// failure is where the Local Network privacy hint lives, and the timeout
// message is the *only* place most operators will ever see the last error.
let detail: String
if let lastError {
let rendered = (lastError as? SSHTransportError).map { "\($0.asCoreError)" } ?? "\(lastError)"
detail = "; last error: \(rendered)"
} else {
detail = ""
}
throw CoreError.timeout("ssh on \(host):\(port)\(detail)")
}
+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
"""
}
}
+68 -8
View File
@@ -525,19 +525,39 @@ public enum Doctor {
/// The macOS 15+ Local Network permission note.
///
/// Reports `.pass` when the host carries a subnet allowlist, because that
/// bypasses the prompt entirely. Otherwise it stays informational: we
/// cannot see the grant itself, since Local Network privacy is a Network
/// Extension packet filter rather than a TCC entry, so there is no
/// database to query and `tccutil` does not apply (Apple, TN3179).
/// Reports `.pass` when the host carries a subnet allowlist that actually
/// covers where guests turn up, because that bypasses the prompt entirely.
/// An allowlist that names some *other* subnet is worse than none, since it
/// looks configured while blocking every guest, so it warns rather than
/// passing. Without one this stays informational: we cannot see the grant
/// itself, since Local Network privacy is a Network Extension packet filter
/// rather than a TCC entry, so there is no database to query and `tccutil`
/// does not apply (Apple, TN3179).
public static func localNetworkNote() -> DoctorCheck {
let name = "local network access"
let allowed = localNetworkAllowlist()
if !allowed.isEmpty {
if allowed.contains(where: coversVMNetRange) {
return DoctorCheck(
name: name,
result: .pass,
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
)
}
return DoctorCheck(
name: name,
result: .pass,
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
result: .warn,
detail: "subnet allowlist set but does not cover the guest range: "
+ allowed.joined(separator: ", "),
remediation: """
Virtualization.framework's NAT does not stay on 192.168.64.0/24 — it moves \
to the next free /24 (192.168.65.x, .66.x, …) when one is taken, so \
an allowlist pinned to a single /24 stops working the day the subnet shifts \
and every guest connection then fails with "No route to host". Widen it to \
cover the whole span: sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
"""
)
}
@@ -554,11 +574,51 @@ public enum Doctor {
Terminal in the GUI session. On an unattended CI host prefer the subnet \
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \
"192.168.64.0/24" (then reboot). See docs/setup.md §2.6.
"192.168.64.0/18" (then reboot). See docs/setup.md §2.6.
"""
)
}
/// The span of addresses a vmnet NAT link can plausibly use.
///
/// `192.168.64.0/24` is only the *first* choice: the subnet is picked at
/// runtime and steps to the next free /24 when that one is already in use,
/// which is why a host that worked yesterday can hand out `192.168.65.x`
/// today. Everything from 192.168.64.0 to 192.168.127.255 — a /18 — is
/// treated as guest territory so the allowlist survives that drift.
static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0
static let vmNetLastAddress: UInt32 = 0xC0A8_7FFF // 192.168.127.255
/// Whether one allowlist entry covers the whole guest range.
///
/// Deliberately all-or-nothing: partial cover is the failure mode being
/// warned about, so an entry that contains today's subnet but not
/// tomorrow's is not treated as good enough.
static func coversVMNetRange(_ entry: String) -> Bool {
let parts = entry.split(separator: "/", maxSplits: 1)
guard let base = ipv4Value(String(parts[0])) else { return false }
let prefix = parts.count == 2 ? Int(parts[1]) : 32
guard let prefix, (0...32).contains(prefix) else { return false }
let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix)
let network = base & mask
let broadcast = network | ~mask
return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress
}
/// Packs dotted-quad IPv4 into a comparable integer; nil for anything else
/// (an IPv6 entry, a hostname, a typo).
static func ipv4Value(_ text: String) -> UInt32? {
let octets = text.split(separator: ".", omittingEmptySubsequences: false)
guard octets.count == 4 else { return nil }
var value: UInt32 = 0
for octet in octets {
guard let number = UInt32(octet), number <= 255 else { return nil }
value = value << 8 | number
}
return value
}
/// Subnets pre-authorized for local network access on this host, if any.
///
/// Best effort and never fatal: an unreadable or absent preferences file
+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.
+4 -6
View File
@@ -155,12 +155,10 @@ public struct IPSWProvider: Sendable {
// not a Swift error — when handed a non-file or missing path, and an
// ObjC exception cannot be caught here. So the existence check is not
// politeness; it is the only thing standing between a typo and a crash.
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
!isDirectory.boolValue
else {
throw CoreError.notFound("restore image not found at \(url.path)")
}
// The size and magic-byte checks alongside it cover the other way this
// call goes wrong: handed a partial download it neither fails nor
// reports progress, it simply stops responding.
try IPSWFile.validate(path: url.path)
do {
return try await VZMacOSRestoreImage.image(from: url)
+192 -22
View File
@@ -6,10 +6,16 @@ import Virtualization
public enum ImageBuildStage: Sendable, Equatable {
/// Downloading the IPSW.
case downloadingIPSW(fraction: Double)
/// Reading the restore image and deriving a hardware configuration.
/// Working out which file the operator meant and whether it is usable.
case preparing
/// Inside `VZMacOSRestoreImage.image(from:)`, which reports no progress of
/// its own and is the longest silent stretch of a local-IPSW build.
case loadingRestoreImage
/// Creating the disk, NVRAM, and bundle metadata.
case creatingBundle
case creatingBundle(diskGB: Int)
/// An out-of-band remark about the stage in flight — printed on its own
/// line rather than replacing the status line.
case note(String)
/// `VZMacOSInstaller` is writing macOS onto the disk.
case installing(fraction: Double)
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
@@ -73,9 +79,29 @@ public struct ImageBuilder: Sendable {
// Checked against the filesystem rather than `store.image(named:)`: a
// half-built bundle from a previous failed run is exactly the thing this
// needs to catch, and it would not read back as a valid image.
// needs to look at, and it would not read back as a valid image.
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
if FileManager.default.fileExists(atPath: bundleURL.path) {
let existing = VMBundle(rootURL: bundleURL)
let existingConfig = try? existing.loadConfig()
// Installed but never provisioned: resume rather than throw away an
// hour of installing. This is safe precisely because provisioning is
// what got skipped — the guest has never been booted, so its *true*
// first boot is still ahead of it and `VZMacGuestProvisioningOptions`
// will still be evaluated. (macOS only honours those options on the
// first boot after a restore; a guest that already booted once has
// consumed that chance, which is the ambiguous case handled by the
// SSH probe in `bootProvisionAndSeal`.)
if existing.isComplete(), let existingConfig, !existingConfig.provisioned {
progress?(
.note("image '\(name)' already installed — resuming first boot + provisioning"))
try await firstBootAndProvision(
bundle: existing, config: config, isResume: true, progress: progress)
progress?(.done)
return
}
throw CoreError.configInvalid(
"image '\(name)' already exists at \(bundleURL.path). "
+ "Delete it first (`image delete \(name)`), or build under a different --name."
@@ -89,7 +115,28 @@ public struct ImageBuilder: Sendable {
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
let restoreImage: VZMacOSRestoreImage
if let ipswPath {
progress?(.downloadingIPSW(fraction: 1.0))
progress?(.preparing)
progress?(.loadingRestoreImage)
// Reading a 21 GB archive's metadata takes a while and the
// framework says nothing while it does. A build that looks frozen
// is the single most-reported symptom of this command, so say out
// loud what the other likely explanation is rather than letting the
// operator guess.
let watchdog = Task {
try? await Task.sleep(for: .seconds(60))
// Cancelling is how a *fast* load ends this task, and a
// cancelled sleep returns rather than throwing past `try?` — so
// without this the note prints on every quick failure, which is
// precisely when it is misleading.
guard !Task.isCancelled else { return }
progress?(
.note(
"still loading — a truncated or partially downloaded .ipsw can block here; "
+ "verify the download completed"))
}
defer { watchdog.cancel() }
restoreImage = try await provider.load(localPath: ipswPath)
} else {
progress?(.downloadingIPSW(fraction: 0))
@@ -100,8 +147,7 @@ public struct ImageBuilder: Sendable {
}
// 2/3. Hardware model and bundle.
progress?(.preparing)
progress?(.creatingBundle)
progress?(.creatingBundle(diskGB: config.guest.diskGB))
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
// 4. Install.
@@ -344,19 +390,47 @@ public struct ImageBuilder: Sendable {
}
installer.install { result in
// `VZVirtualMachine` holds an exclusive lock on the bundle's
// auxiliary storage (nvram.bin) for its whole lifetime, and
// releases it in `dealloc`. The next thing the caller does is
// build a *second* VM over the same bundle for first boot, so
// if this one is still alive at that moment the new one fails
// validation with "Failed to lock auxiliary storage" — which
// is exactly what operators hit.
//
// Hence: drop every strong reference here, on the queue that
// owns these objects...
session.observation?.invalidate()
session.observation = nil
session.installer = nil
session.virtualMachine = nil
switch result {
case .success:
progress?(1.0)
continuation.resume()
case .failure(let error):
continuation.resume(throwing: VMInstance.mapVZError(error))
// ...and resume the caller only from a *later* block on that
// same serial queue. Returning from this handler is what lets
// the framework's own frame unwind and release its references,
// and a serial queue guarantees that has happened before the
// block below runs. Resuming inline instead would race the
// deallocation against the first-boot VM.
let boxedResult = UncheckedBox(result)
queue.async {
switch boxedResult.value {
case .success:
progress?(1.0)
continuation.resume()
case .failure(let error):
continuation.resume(throwing: VMInstance.mapVZError(error))
}
}
}
}
}
// One more hop to the back of the same queue: by the time an empty block
// gets to run, everything enqueued above it — including the release of
// the last reference to the VM — has finished.
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
queue.async { continuation.resume() }
}
}
/// Boots the freshly installed guest, gets it onto the network, and hands it
@@ -390,10 +464,14 @@ public struct ImageBuilder: Sendable {
/// - Parameters:
/// - bundle: The installed bundle.
/// - config: Guest credentials and timeouts.
/// - isResume: `true` when this is picking up a bundle that was installed
/// by an earlier run. Only affects the advice given if the guest never
/// answers on SSH — see ``bootProvisionAndSeal(bundle:config:startOptions:isFirstBoot:isResume:xcodeXIPPath:progress:)``.
/// - progress: Stage callback.
public func firstBootAndProvision(
bundle: VMBundle,
config: RunnerConfig,
isResume: Bool = false,
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
) async throws {
guard #available(macOS 27.0, *) else {
@@ -434,6 +512,7 @@ public struct ImageBuilder: Sendable {
config: config,
startOptions: startOptions,
isFirstBoot: true,
isResume: isResume,
xcodeXIPPath: nil,
progress: progress
)
@@ -478,6 +557,7 @@ public struct ImageBuilder: Sendable {
config: config,
startOptions: nil,
isFirstBoot: false,
isResume: false,
xcodeXIPPath: xcodeXIPPath,
progress: progress
)
@@ -493,6 +573,7 @@ public struct ImageBuilder: Sendable {
config: RunnerConfig,
startOptions: VZMacOSVirtualMachineStartOptions?,
isFirstBoot: Bool,
isResume: Bool,
xcodeXIPPath: String?,
progress: (@Sendable (ImageBuildStage) -> Void)?
) async throws {
@@ -501,15 +582,11 @@ public struct ImageBuilder: Sendable {
let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds))
progress?(.firstBoot)
let instance = try VMInstance(bundle: bundle, label: "image:\(bundle.name)", headless: true)
do {
try await instance.start(options: startOptions)
} catch {
throw CoreError.provisioningFailed(
"could not boot image '\(bundle.name)': \(error)"
)
}
let instance = try await Self.bootRetryingAuxStorageLock(
bundle: bundle,
startOptions: startOptions,
progress: progress
)
let address: String
do {
@@ -526,6 +603,37 @@ public struct ImageBuilder: Sendable {
} catch {
_ = await instance.requestStopThenForce()
if isFirstBoot {
// A resumed build has a second candidate cause, and it is
// unrecoverable rather than merely slow: macOS evaluates
// `VZMacGuestProvisioningOptions` only on the first boot after a
// restore. If the earlier run got far enough to boot the guest —
// which the bundle on disk cannot tell us — that chance is spent,
// and no amount of retrying will produce an account or sshd.
// Reaching SSH is the only way to distinguish the two, so this is
// said here, after the probe has failed, rather than refusing to
// resume in the first place.
if isResume {
throw CoreError.provisioningFailed(
"""
the resumed guest never became reachable over SSH within \
\(config.scheduler.bootTimeoutSeconds)s.
Either the guest is older than macOS 27 (see below), or an earlier run \
already consumed its first boot — macOS applies automated Setup Assistant \
provisioning only once, on the first boot after a restore, so a guest that \
has booted before can no longer be provisioned unattended.
There is no way to re-arm it: delete the image and build again with a \
macOS 27 or newer restore image.
gitea-macos-runner image delete \(bundle.name)
gitea-macos-runner image build --ipsw <path>
Underlying error: \(error)
"""
)
}
// The most likely cause by far, and the one with no diagnostic of
// its own: a pre-27 guest accepts the provisioning options and
// ignores them, so it sits at Setup Assistant with no account and
@@ -564,10 +672,14 @@ public struct ImageBuilder: Sendable {
if let xcodeXIPPath {
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(
executor: executor,
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
)
) { step in
progress?(.provisioning(step: step))
}
}
} catch {
await executor.close()
@@ -598,6 +710,64 @@ public struct ImageBuilder: Sendable {
/// Full name for the account Setup Assistant automation creates.
static let guestAccountFullName = "Gitea Runner"
/// Creates and starts the VM, tolerating a still-held auxiliary-storage lock.
///
/// `VZVirtualMachine` takes an exclusive lock on the bundle's `nvram.bin`
/// and gives it up only when the object deallocates. `install(bundle:…)`
/// now drains its queue before returning, so its installer VM is gone by
/// the time we get here — but "gone" is an ARC and Objective-C runtime
/// property, and a stray autorelease pool or a framework thread that has
/// not yet unwound can still be holding the last reference for a moment.
/// The failure that produces is not ambiguous and not persistent:
///
/// Invalid virtual machine configuration. Failed to lock auxiliary storage.
///
/// So it is retried, briefly and only for that message. Anything else fails
/// on the first attempt, because a genuinely invalid configuration does not
/// become valid by waiting.
static func bootRetryingAuxStorageLock(
bundle: VMBundle,
startOptions: VZMacOSVirtualMachineStartOptions?,
progress: (@Sendable (ImageBuildStage) -> Void)?,
timeout: Duration = .seconds(30),
pollInterval: Duration = .seconds(2)
) async throws -> VMInstance {
let started = ContinuousClock.now
var announced = false
while true {
do {
let instance = try VMInstance(
bundle: bundle, label: "image:\(bundle.name)", headless: true)
try await instance.start(options: startOptions)
return instance
} catch {
guard isAuxiliaryStorageLockFailure(error),
ContinuousClock.now - started < timeout
else {
throw CoreError.provisioningFailed(
"could not boot image '\(bundle.name)': \(error)"
)
}
if !announced {
announced = true
progress?(.note("waiting for installer to release the VM bundle…"))
}
try await Task.sleep(for: pollInterval)
}
}
}
/// Whether an error is the transient "someone else still has nvram.bin".
///
/// Matched on the message because the framework reports it as a generic
/// `VZError.invalidVirtualMachineConfiguration` with the detail only in the
/// description — there is no distinct code to switch on.
static func isAuxiliaryStorageLockFailure(_ error: any Error) -> Bool {
let text = "\(error)".lowercased()
return text.contains("auxiliary storage") && text.contains("lock")
}
/// Polls `/var/db/dhcpd_leases` until the guest's MAC appears.
///
/// - Parameters:
+59 -18
View File
@@ -110,9 +110,10 @@ struct DaemonCommand: AsyncParsableCommand {
// Expected on shutdown.
} catch {
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
// Not `MainActor.run`: the main actor is parked inside
// `app.run()` for the life of the process, so hopping onto
// it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
}
}
)
@@ -123,18 +124,40 @@ struct DaemonCommand: AsyncParsableCommand {
/// run loop it requires, while the real work runs in a `Task`.
///
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
@MainActor
enum VZAppRuntime {
/// Signal sources have to outlive the call that creates them or they are
/// cancelled on deinit and the signals go nowhere.
private static var signalSources: [DispatchSourceSignal] = []
private static var isTerminating = false
private nonisolated(unsafe) static var signalSources: [DispatchSourceSignal] = []
private nonisolated(unsafe) static var isTerminating = false
private static let stateLock = NSLock()
/// Signals land here rather than on `.main`. See ``run(onSignal:body:)``.
private static let signalQueue = DispatchQueue(
label: "xyz.blakeslee.gitea-macos-runner.signals")
/// Starts the run loop and runs `body` alongside it. Never returns.
///
/// ## Nothing here may touch the main queue
///
/// This is reached from Swift's async `main`, so the frame that calls
/// `app.run()` is *itself* a block executing on the main dispatch queue —
/// and it never returns. libdispatch will not re-enter a serial queue that
/// already has a block in flight, so from this moment the main queue is
/// closed for business: a plain `Task { }` inheriting a `@MainActor`
/// context, a `DispatchSource` handler on `.main`, or an
/// `await MainActor.run { … }` all enqueue work that can never be drained.
///
/// The symptom is exact and was reported as a hang in `image build`: a live
/// run loop, zero CPU, and no output past the last line printed before this
/// call — `body` had been enqueued behind `app.run()` and never got a first
/// tick. Hence `Task.detached`, a private signal queue, and ``exit(_:)``
/// called straight from whichever thread reaches it. A normal AppKit app
/// does not hit this because its `main()` is not a main-queue block.
///
/// - Parameters:
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
/// - body: The work to run. When it returns, the process exits zero.
@MainActor
static func run(
onSignal: @escaping @Sendable () async -> Void,
body: @escaping @Sendable () async -> Void
@@ -149,30 +172,48 @@ enum VZAppRuntime {
// DispatchSourceSignal only observes; the default disposition still
// kills the process unless it is ignored first.
signal(signalNumber, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: signalQueue)
source.setEventHandler {
Task { @MainActor in
guard !isTerminating else { return }
isTerminating = true
guard beginTerminating() else { return }
Task.detached {
CLI.note("received signal; shutting down…")
await onSignal()
NSApp.terminate(nil)
exit(0)
flushAndExit(0)
}
}
source.resume()
stateLock.lock()
signalSources.append(source)
stateLock.unlock()
}
Task {
// Detached on purpose: an inheriting `Task { }` would be queued behind
// the `app.run()` below and never start. See the note above.
Task.detached {
await body()
await MainActor.run {
NSApp.terminate(nil)
exit(0)
}
flushAndExit(0)
}
app.run()
exit(0)
flushAndExit(0)
}
/// Wins the race to shut down, exactly once.
private static func beginTerminating() -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
guard !isTerminating else { return false }
isTerminating = true
return true
}
/// Exits from any thread, without hopping to the unusable main actor.
///
/// `NSApp.terminate(nil)` is deliberately not called: it requires the main
/// actor, which is exactly what is not available here.
nonisolated static func flushAndExit(_ code: Int32) -> Never {
fflush(stdout)
fflush(stderr)
exit(code)
}
}
+101 -21
View File
@@ -35,7 +35,13 @@ struct ImageCommand: AsyncParsableCommand {
var name: String = "default"
/// A local `.ipsw`; omit to download the latest supported image.
@Option(name: .long, help: "Path to a local .ipsw (default: download the latest supported).")
///
/// Resolved through ``PathResolution`` so it works whether or not the
/// shell got to the glob first: `--ipsw '~/Downloads/UniversalMac_27*.ipsw'`
/// and the unquoted form both land on the same file.
@Option(
name: .long,
help: "Path to a local .ipsw; may be a glob (default: download the latest supported).")
var ipsw: String?
/// Nominal guest disk size, overriding `guest.diskGB`.
@@ -44,6 +50,12 @@ struct ImageCommand: AsyncParsableCommand {
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
// Before anything else touches the disk: an unresolvable --ipsw is
// an argument error, and an argument error should not first make the
// operator wait on a config load and a free-space check.
let resolvedIPSW = try ipsw.map { try PathResolution.resolve($0, label: "--ipsw") }
var config = try options.loadConfig()
if let diskGB {
config.guest.diskGB = diskGB
@@ -52,7 +64,12 @@ struct ImageCommand: AsyncParsableCommand {
let store = VMStore(config: config)
try store.ensureLayout()
if try store.image(named: name) != nil {
// Only a *finished* image blocks a rebuild. An installed but
// unprovisioned bundle is an hour of work that `ImageBuilder.build`
// knows how to resume, so it must get the chance to say so.
if let existing = try store.image(named: name),
(try? existing.loadConfig())?.provisioned == true
{
throw ValidationError(
"image '\(name)' already exists — delete it first with `image delete \(name)`"
)
@@ -64,7 +81,7 @@ struct ImageCommand: AsyncParsableCommand {
let printer = ProgressPrinter()
let builder = ImageBuilder(store: store)
let imageName = name
let ipswPath = ipsw
let ipswPath = resolvedIPSW
let frozenConfig = config
// `image build` runs `VZMacOSInstaller` and then boots the guest, so
@@ -79,16 +96,20 @@ struct ImageCommand: AsyncParsableCommand {
name: imageName,
ipswPath: ipswPath,
config: frozenConfig,
progress: { stage in printer.update(ImageCommand.describe(stage)) }
progress: { stage in ImageCommand.report(stage, to: printer) }
)
} catch {
printer.finish()
CLI.error("\(error)")
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
// Not `MainActor.run`: the main actor is parked inside
// `app.run()` for the life of the process, so hopping onto
// it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
}
printer.finish("done")
// Just seals the line: the builder's own `.done` stage has
// already printed it, and saying it twice down a pipe reads
// like something ran twice.
printer.finish()
print("built image '\(imageName)'")
print("next: gitea-macos-runner vm boot --image \(imageName)")
@@ -201,20 +222,28 @@ struct ImageCommand: AsyncParsableCommand {
/// Optional Xcode `.xip` to install into the guest. Adds tens of
/// gigabytes; omitted by default.
@Option(name: .customLong("xcode-xip"), help: "Path to an Xcode .xip to install into the guest.")
@Option(
name: .customLong("xcode-xip"),
help: "Path to an Xcode .xip to install into the guest; may be a glob.")
var xcodeXIP: String?
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
// Same treatment as `image build --ipsw`, and for the same reason:
// this is a long path to a big file that people reach for with a
// glob. Resolved first so a bad one costs nothing.
let resolvedXIP = try xcodeXIP.map { try PathResolution.resolve($0, label: "--xcode-xip") }
if let resolvedXIP, !FileManager.default.fileExists(atPath: resolvedXIP) {
throw ValidationError("no file at \(resolvedXIP)")
}
let config = try options.loadConfig()
let store = VMStore(config: config)
guard try store.image(named: name) != nil else {
throw ValidationError("no image named '\(name)'")
}
if let xcodeXIP, !FileManager.default.fileExists(atPath: RunnerConfig.expandTilde(xcodeXIP)) {
throw ValidationError("no file at \(RunnerConfig.expandTilde(xcodeXIP))")
}
CLI.note("provisioning base image '\(name)' in place — stop the daemon before doing this")
@@ -222,7 +251,7 @@ struct ImageCommand: AsyncParsableCommand {
let builder = ImageBuilder(store: store)
let imageName = name
let frozenConfig = config
let xipPath = xcodeXIP.map(RunnerConfig.expandTilde)
let xipPath = resolvedXIP
// Boots the image to run provision.sh in it, so it needs the run
// loop for exactly the reason `image build` does.
@@ -234,14 +263,15 @@ struct ImageCommand: AsyncParsableCommand {
name: imageName,
config: frozenConfig,
xcodeXIPPath: xipPath,
progress: { stage in printer.update(ImageCommand.describe(stage)) }
progress: { stage in ImageCommand.report(stage, to: printer) }
)
} catch {
printer.finish()
CLI.error("\(error)")
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
// Not `MainActor.run`: the main actor is parked inside
// `app.run()` for the life of the process, so hopping onto
// it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
}
printer.finish("done")
print("provisioned image '\(imageName)'")
@@ -250,19 +280,69 @@ struct ImageCommand: AsyncParsableCommand {
}
}
/// Routes a stage to the progress printer.
///
/// Notes get a line of their own: they are the reason the operator is still
/// watching, and a status line that is about to be overwritten is no place
/// to put "this may be a truncated download".
static func report(_ stage: ImageBuildStage, to printer: ProgressPrinter) {
if case .note(let text) = stage {
printer.line(text)
} else {
printer.update(describe(stage), group: group(of: stage))
}
}
/// The stage a status line belongs to, ignoring its varying payload.
///
/// Two lines share a group exactly when one is meant to overwrite the
/// other. Crossing a group boundary seals the previous line instead, which
/// is why `installing macOS … 100%` survives into scrollback rather than
/// being replaced by `first boot + guest provisioning…`.
static func group(of stage: ImageBuildStage) -> String {
switch stage {
case .downloadingIPSW: return "download"
case .preparing: return "preparing"
case .loadingRestoreImage: return "loading"
case .creatingBundle: return "bundle"
case .note: return "note"
case .installing: return "install"
case .firstBoot: return "firstBoot"
// Each provisioning step is its own headline — "installing Node.js"
// should not erase "downloading gitea-runner" — but a step that carries
// 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 .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.
static func describe(_ stage: ImageBuildStage) -> String {
switch stage {
case .downloadingIPSW(let fraction):
return "downloading IPSW " + CLI.progressBar(fraction)
case .preparing:
return "preparing"
case .creatingBundle:
return "creating bundle"
return "resolving restore image…"
case .loadingRestoreImage:
return "loading restore image metadata…"
case .creatingBundle(let diskGB):
return "creating VM bundle (disk \(diskGB) GB)…"
case .note(let text):
return text
case .installing(let fraction):
return "installing macOS " + CLI.progressBar(fraction)
case .firstBoot:
return "first boot (Setup Assistant)"
return "first boot + guest provisioning…"
case .provisioning(let step):
return "provisioning: \(step)"
case .finalizing:
+4 -3
View File
@@ -85,9 +85,10 @@ struct VMCommand: AsyncParsableCommand {
} catch {
CLI.error("\(error)")
await session.teardown()
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
// Not `MainActor.run`: the main actor is parked inside
// `app.run()` for the life of the process, so hopping onto
// it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
}
}
)
+56 -7
View File
@@ -136,15 +136,55 @@ final class OnceFlag: @unchecked Sendable {
final class ProgressPrinter: @unchecked Sendable {
private let lock = NSLock()
private var lastLine = ""
private var lastGroup: String?
/// Whether carriage-return rewriting means anything here.
///
/// Piped to a file or captured by `launchd`, `\r` produces one unreadable
/// mega-line, so each update becomes its own line instead. `FileHandle`
/// writes go straight to the descriptor either way — there is no buffer to
/// flush, which is what makes a stall attributable to the stage last
/// printed rather than to output sitting unwritten.
private let isInteractive = isatty(fileno(stderr)) == 1
/// Rewrites the current line.
func update(_ line: String) {
///
/// - Parameters:
/// - line: The text to show.
/// - group: Names the stage this line belongs to. When it changes, the
/// outgoing stage's final line is sealed with a newline rather than
/// overwritten — so `installing macOS [####] 100%` is still on screen
/// when the operator scrolls back to work out where the last hour went,
/// instead of being replaced by whatever came next.
func update(_ line: String, group: String? = nil) {
lock.lock()
defer { lock.unlock() }
if let group, let lastGroup, group != lastGroup, !lastLine.isEmpty, isInteractive {
emit("\n")
lastLine = ""
}
if let group { lastGroup = group }
guard line != lastLine else { return }
lastLine = line
let padding = String(repeating: " ", count: max(0, 78 - line.count))
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
guard isInteractive else {
emit(line + "\n")
return
}
emit("\r" + line + pad(line))
}
/// Emits a standalone line without losing the status line under it.
func line(_ text: String) {
lock.lock()
let carried = lastLine
lock.unlock()
finish(text)
if !carried.isEmpty {
update(carried)
}
}
/// Ends the line so subsequent output starts cleanly.
@@ -152,11 +192,20 @@ final class ProgressPrinter: @unchecked Sendable {
lock.lock()
defer { lock.unlock() }
if let line {
let padding = String(repeating: " ", count: max(0, 78 - line.count))
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8))
} else if !lastLine.isEmpty {
FileHandle.standardError.write(Data("\n".utf8))
emit((isInteractive ? "\r" : "") + line + (isInteractive ? pad(line) : "") + "\n")
} else if !lastLine.isEmpty, isInteractive {
emit("\n")
}
lastLine = ""
}
/// Trailing blanks that erase whatever the previous, longer line left behind.
private func pad(_ line: String) -> String {
String(repeating: " ", count: max(0, 78 - line.count))
}
/// Writes straight to the descriptor. Call with ``lock`` held.
private func emit(_ text: String) {
FileHandle.standardError.write(Data(text.utf8))
}
}
@@ -0,0 +1,254 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``PathResolution`` — the CLI's defence against shell quoting.
///
/// The bug these exist for: `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`
/// behaves differently depending on whether the shell expanded the glob, so the
/// tool has to resolve the argument itself and stop caring.
@Suite("PathResolution")
struct PathResolutionTests {
/// A scratch directory holding `names`, deleted when `body` returns.
private func withFiles(_ names: [String], _ body: (String) throws -> Void) throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("gmr-path-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
for name in names {
let path = dir.appendingPathComponent(name)
#expect(FileManager.default.createFile(atPath: path.path, contents: Data()))
}
try body(dir.path)
}
// MARK: - Pattern detection
@Test("only glob metacharacters make a string a pattern")
func patternDetection() {
#expect(!PathResolution.isPattern("/tmp/UniversalMac_27.0.ipsw"))
// A tilde is expanded either way; it does not make this a glob.
#expect(!PathResolution.isPattern("~/Downloads/x.ipsw"))
#expect(PathResolution.isPattern("/tmp/a_*.ipsw"))
#expect(PathResolution.isPattern("/tmp/a_?.ipsw"))
#expect(PathResolution.isPattern("/tmp/a_[12].ipsw"))
}
// MARK: - Passthrough
@Test("a plain path is returned untouched")
func plainPathPassesThrough() throws {
// Deliberately not checked for existence: the caller's own error knows
// what the file was for and says something more useful than we could.
#expect(try PathResolution.resolve("/tmp/nope.ipsw", label: "--ipsw") == "/tmp/nope.ipsw")
#expect(try PathResolution.resolve("relative/x.ipsw", label: "--ipsw") == "relative/x.ipsw")
}
@Test("a leading tilde is expanded")
func tildeIsExpanded() throws {
let home = NSHomeDirectory()
#expect(try PathResolution.resolve("~/Downloads/x.ipsw", label: "--ipsw") == home + "/Downloads/x.ipsw")
// Only leading: a tilde inside a path component is an ordinary character.
#expect(try PathResolution.resolve("/tmp/~x.ipsw", label: "--ipsw") == "/tmp/~x.ipsw")
}
// MARK: - Globbing
@Test("a pattern matching exactly one file resolves to it")
func singleMatchResolves() throws {
try withFiles(["UniversalMac_27.0_ABC.ipsw", "notes.txt"]) { dir in
let resolved = try PathResolution.resolve("\(dir)/UniversalMac_27.0_*.ipsw", label: "--ipsw")
#expect(resolved == "\(dir)/UniversalMac_27.0_ABC.ipsw")
}
}
@Test("a pattern matching nothing is a clear notFound")
func zeroMatchesThrows() throws {
try withFiles(["a_1.ipsw"]) { dir in
#expect(throws: CoreError.self) {
try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
}
do {
_ = try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .notFound(let message) = error else {
Issue.record("expected .notFound, got \(error)")
return
}
#expect(message.contains("--ipsw"))
#expect(message.contains("no file matches"))
#expect(message.contains("\(dir)/nope_*.ipsw"))
}
}
}
@Test("a pattern matching several files lists them, sorted, and refuses")
func multipleMatchesThrowsAndLists() throws {
// Created out of order: the message must not depend on creation order,
// because the operator is being asked to read it and pick one.
try withFiles(["a_2.ipsw", "a_10.ipsw", "a_1.ipsw"]) { dir in
do {
_ = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .configInvalid(let message) = error else {
Issue.record("expected .configInvalid, got \(error)")
return
}
#expect(message.contains("--ipsw"))
#expect(message.contains("3 files match"))
for name in ["a_1.ipsw", "a_2.ipsw", "a_10.ipsw"] {
#expect(message.contains("\(dir)/\(name)"))
}
// Sorted, so two runs read identically.
let one = message.range(of: "a_1.ipsw")!.lowerBound
let ten = message.range(of: "a_10.ipsw")!.lowerBound
let two = message.range(of: "a_2.ipsw")!.lowerBound
#expect(one < ten)
#expect(ten < two)
#expect(message.contains("name exactly one of them"))
}
}
}
@Test("brackets are a character class when they match, and a filename when they do not")
func bracketsAreGlobOnlyWhenTheyAreOne() throws {
// Used as a class: `[12]` selects the one file that exists.
try withFiles(["build_1.ipsw"]) { dir in
let asClass = try PathResolution.resolve("\(dir)/build_[12].ipsw", label: "--ipsw")
#expect(asClass == "\(dir)/build_1.ipsw")
}
// A real file whose name contains brackets. Read as a class it matches
// nothing, so the literal has to win — otherwise a legal filename is
// unreachable through this flag.
try withFiles(["report[1].ipsw"]) { dir in
let asLiteral = try PathResolution.resolve("\(dir)/report[1].ipsw", label: "--ipsw")
#expect(asLiteral == "\(dir)/report[1].ipsw")
}
}
@Test("a pattern that resolves is not confused by neighbours of another extension")
func matchingIsScopedToThePattern() throws {
try withFiles(["a_1.ipsw", "a_1.ipsw.part", "a_1.txt"]) { dir in
let resolved = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
#expect(resolved == "\(dir)/a_1.ipsw")
}
}
// MARK: - IPSW pre-validation
/// Writes `bytes` at the front of a sparse file of `size` bytes.
///
/// Sparse because a valid-size fixture is a gigabyte and nobody should wait
/// for a gigabyte of zeroes to be written to test a two-byte check.
private func withIPSW(bytes: [UInt8], size: UInt64, _ body: (String) throws -> Void) throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("gmr-ipsw-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let path = dir.appendingPathComponent("image.ipsw").path
#expect(FileManager.default.createFile(atPath: path, contents: Data(bytes)))
let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
try handle.truncate(atOffset: size)
try handle.close()
try body(path)
}
private let pk: [UInt8] = [0x50, 0x4B]
private let validSize: UInt64 = 2_000_000_000
@Test("a plausible restore image passes")
func healthyIPSWValidates() throws {
try withIPSW(bytes: pk, size: validSize) { path in
try IPSWFile.validate(path: path)
}
}
@Test("a missing file is notFound, not a hang")
func missingIPSWThrows() throws {
#expect(throws: CoreError.self) {
try IPSWFile.validate(path: "/tmp/gmr-definitely-absent.ipsw")
}
}
@Test("a directory is rejected before the framework sees it")
func directoryIsRejected() throws {
try withFiles([]) { dir in
do {
try IPSWFile.validate(path: dir)
Issue.record("expected a throw")
} catch let error as CoreError {
#expect("\(error)".contains("directory"))
}
}
}
@Test("a truncated download names its own size and says what happened")
func truncatedIPSWIsRejected() throws {
// 4 MB: the shape of a download that stopped early — right magic bytes,
// nowhere near the right length.
try withIPSW(bytes: pk, size: 4_000_000) { path in
do {
try IPSWFile.validate(path: path)
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .configInvalid(let message) = error else {
Issue.record("expected .configInvalid, got \(error)")
return
}
#expect(message.contains("3.8 MB"))
#expect(message.contains("incomplete"))
}
}
}
@Test("an empty file is rejected on size, before anything reads it")
func emptyIPSWIsRejected() throws {
try withIPSW(bytes: [], size: 0) { path in
#expect(throws: CoreError.self) { try IPSWFile.validate(path: path) }
}
}
@Test("a big file that is not a zip is rejected on its magic bytes")
func wrongMagicIsRejected() throws {
try withIPSW(bytes: [0x00, 0x01], size: validSize) { path in
do {
try IPSWFile.validate(path: path)
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .configInvalid(let message) = error else {
Issue.record("expected .configInvalid, got \(error)")
return
}
#expect(message.contains("PK"))
#expect(message.contains("0001"))
}
}
}
@Test("sizes are rendered the way the operator will compare them")
func sizeDescriptions() {
#expect(IPSWFile.describeSize(0) == "0 B")
#expect(IPSWFile.describeSize(512) == "512 B")
#expect(IPSWFile.describeSize(22_567_352_533) == "21.0 GB")
}
@Test("the label names the offending flag so the operator knows what to fix")
func labelAppearsInErrors() throws {
try withFiles([]) { dir in
do {
_ = try PathResolution.resolve("\(dir)/*.xip", label: "--xcode-xip")
Issue.record("expected a throw")
} catch let error as CoreError {
#expect("\(error)".contains("--xcode-xip"))
}
}
}
}
@@ -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")
}
}
+25 -8
View File
@@ -315,6 +315,17 @@ find it. `--ipsw` is optional: omit it and the latest supported restore image is
downloaded into `storeDir/ipsw/` first, which is most of the build's wall-clock
time. `--disk-gb` overrides `guest.diskGB` for this image only.
`--ipsw` (and `image provision --xcode-xip`) accept a `~` and a glob, quoted or not — these are
equivalent, and neither depends on what your shell did with the pattern first:
```sh
gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_*.ipsw
gitea-macos-runner image build --ipsw '~/Downloads/UniversalMac_27.0_*.ipsw'
```
A pattern must identify exactly one file. If it matches several, the build stops before doing any
work and lists them so you can name the one you meant; if it matches none, it says so.
This takes a long time — macOS installs from the IPSW, boots, and is then provisioned over SSH.
**Do not interrupt it during the install phase.** Stopping a VM mid-install leaves the disk image
in an undefined state; delete the image and start over rather than trying to resume.
@@ -390,16 +401,22 @@ session, and nothing to redo after a rebuild:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
```
Then **reboot** — these are read at boot, so restarting the service alone is not enough. Adjust the
range to match the subnet Virtualization.framework's NAT hands out on your host (check
`/var/db/dhcpd_leases` after a VM boots); if you would rather not pin it, the RFC 1918 set
`"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor` reports `local network access`
as a **pass** once it can see an allowlist. Both keys are documented by Apple in
Then **reboot** — these are read at boot, so restarting the service alone is not enough.
**Do not pin this to a single /24.** Virtualization.framework's NAT starts at `192.168.64.0/24` but
picks the subnet at runtime and steps to the next free one when that range is already in use, so the
same host can hand out `192.168.65.x` tomorrow. An allowlist naming only `192.168.64.0/24` then
looks configured while silently blocking every guest — the failure surfaces as `No route to host`
(errno 65) on the SSH connection, not as a permission error. The `/18` above spans
`192.168.64.0`–`192.168.127.255`, which covers the drift; if you would rather not think about
ranges at all, the RFC 1918 set `"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor`
reports `local network access` as a **pass** once it sees an allowlist that covers that span, and as
a **warning** when an allowlist exists but does not. Both keys are documented by Apple in
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
and are the workaround [Tart's FAQ](https://tart.run/faq/) recommends for the same problem.
@@ -453,7 +470,7 @@ The checks, in order:
| `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on |
| `runner download url` | The `gitea-runner` release asset is reachable |
| `token file permissions` | Warns — not fails — when a token file is group- or world-readable |
| `local network access` | Passes when a subnet allowlist is set; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
| `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
If the config file is missing or invalid, the host checks still run and the rest
are skipped — which is exactly the state a first-time operator is in. Resolve
+198 -7
View File
@@ -26,6 +26,8 @@ gitea-macos-runner service status
| VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; grant Local Network permission or pre-authorize the subnet |
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection; it cannot be pre-approved | Boot one VM by hand from a GUI Terminal to create the entry, or (better on CI) allowlist the subnet with `defaults write com.apple.network.local-network` |
| SSH times out on a freshly built image | Guest macOS < 27, so provisioning options were ignored and Setup Assistant is waiting | Rebuild the image from a macOS **27+** IPSW |
| `ssh failed: cannot connect … No route to host) (errno: 65)` part-way through provisioning | macOS 15+ Local Network privacy blocking the app — the grant is keyed on the executable's UUID, so `make install` withdraws it | Allowlist the subnet (`192.168.64.0/18`) and **reboot**; see [SSH fails with "No route to host" mid-run](#ssh-fails-with-no-route-to-host-errno-65-mid-run) |
| Allowlist is set but guests are still unreachable | It names `192.168.64.0/24` while the NAT has moved to `192.168.65.x` | Widen it to `192.168.64.0/18` and reboot; `doctor` now warns about too-narrow allowlists |
| `SecKeyCreateRandomKey` / "Interaction is not allowed" | `login.keychain` is locked — no GUI session | Run as a LaunchAgent in an unlocked GUI session; enable auto-login |
| Job stays queued forever | Label mismatch, or the daemon isn't running/reaching Gitea | Use bare label names in `runs-on`; match `runner.labels`; check daemon logs |
| `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision <name>` |
@@ -33,6 +35,10 @@ gitea-macos-runner service status
| `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing |
| Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` |
| `image build` appears to hang during install | Normal — macOS install is slow | Wait. **Do not stop the VM mid-install**; if you did, delete the image and rebuild |
| `image build` prints its banner and then nothing, at 0% CPU | Old build: the build task was queued behind the `NSApplication` run loop and never started | Upgrade — the task is now detached. Stage lines should appear within seconds |
| `image build` stuck at `loading restore image metadata…` | A truncated or partial `.ipsw` — the framework blocks rather than failing | Upgrade (the file is now size- and magic-checked first); re-download the IPSW |
| `provisioning failed: … Failed to lock auxiliary storage` after install | The installer's VM had not yet released `nvram.bin` when first boot started | Upgrade — the install now drains its queue and first boot retries for 30 s. Re-run `image build`; it resumes |
| `image build` says `image 'default' already exists` after a failed first boot | Old build: an installed-but-unprovisioned bundle was treated as a finished image | Upgrade — `image build` now resumes it instead of refusing |
---
@@ -101,13 +107,15 @@ prompt**: a LaunchAgent that was never granted permission (or was denied) cannot
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
```
Match the range to what your host's NAT actually hands out. `doctor` reports `local network
access` as a pass once it can see this. This is the deterministic fix for an unattended host —
The `/18` is deliberate: the NAT subnet is chosen at runtime and slides to the next free /24
(192.168.65.x, .66.x, …) when one is taken, so a pinned `192.168.64.0/24` breaks the day it
moves. `doctor` reports `local network access` as a pass once it sees an allowlist covering that
span. This is the deterministic fix for an unattended host —
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
for why the interactive grant is not.
@@ -147,9 +155,9 @@ on the network rather than on the app, so no prompt is involved and nothing need
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
```
Reboot afterwards. `doctor` then reports `local network access` as a **pass**. Both keys are
@@ -363,6 +371,72 @@ concurrent clones diverging.
---
## `image build` prints the banner and then nothing at all
**Symptom.** `image build` prints
```
building image 'default' (this takes a while; the IPSW alone is ~15 GB)
```
and then stops — no stage lines, no progress bar, no error. Activity Monitor shows the process
using no CPU and no VM services running. Ctrl-C does nothing.
**Cause.** A bug in releases before this fix. `image build` hosts an `NSApplication` run loop
(Virtualization.framework requires one), and the work was started with a `Task` that inherited the
main actor. Because `NSApplication.run()` is itself reached from Swift's async `main`, the main
dispatch queue already had a block in flight and would not re-enter — so the build task was queued
behind a run loop that never yields and never got a first tick. Nothing ran, including the code
that would have reported the error. `SIGINT`/`SIGTERM` handling was stuck the same way, which is
why Ctrl-C did not work either.
**Fix.** Upgrade. The build task is now detached and signals are handled off the main queue. You
should see stage lines within a second or two:
```
resolving restore image…
loading restore image metadata…
creating VM bundle (disk 64 GB)…
installing macOS [########----------------------] 27%
```
If a build still goes quiet, the line last printed tells you which stage owns the silence — see
below.
---
## `image build` sits at "loading restore image metadata…"
**Symptom.** The build reaches `loading restore image metadata…` and stays there. After a minute it
adds:
```
still loading — a truncated or partially downloaded .ipsw can block here; verify the download completed
```
**Cause.** `VZMacOSRestoreImage.image(from:)` reads the whole archive's metadata and reports no
progress while it does. On a healthy ~21 GB IPSW this takes seconds to a minute or two. On a
*partial* download it can block for a very long time instead of failing.
**Fix.** The obvious checks are now made before the framework is handed the file — it must exist, be
a regular file, be at least 1 GB, and start with the zip magic `PK` — so an incomplete download now
fails immediately with its actual size rather than hanging. If you are on an older build, check by
hand:
```sh
ls -la ~/Downloads/UniversalMac_*.ipsw # ~15-22 GB, no .download/.crdownload sibling
xxd -l 2 -p ~/Downloads/UniversalMac_*.ipsw # must print 504b
```
Anything smaller, or not starting `504b`, is an incomplete or wrong file: delete it and download it
again.
> A pattern that matches **more than one** IPSW is refused outright, listing the matches — for
> example `~/Downloads/UniversalMac_27.0_*.ipsw` when two 27.0 builds are sitting in `~/Downloads`.
> Name exactly one of them.
---
## `image build` hangs at install
**Symptom.** `image build` sits for a long time at the macOS install phase with little visible
@@ -373,7 +447,7 @@ minutes, longer on slower storage). The install phase is largely silent.
**Fix.** **Wait, and do not stop the VM mid-install.** Interrupting the installer leaves the disk
image in an undefined state; the resulting image may boot and then fail in confusing ways later.
There is no resume.
There is no resume from a *partial* install — only from a complete one (see the next section).
If you did interrupt it, or the build genuinely failed:
@@ -385,3 +459,120 @@ gitea-macos-runner image build --ipsw <path> --name <name>
Before rebuilding, verify the IPSW is complete and matches your host architecture (Apple Silicon)
and version requirement (macOS 27+ for unattended provisioning), and that you have enough free disk
for the IPSW plus the target disk size.
---
## `Failed to lock auxiliary storage` right after the install finishes
**Symptom.** The macOS install runs to 100%, then:
```
first boot + guest provisioning…
error: provisioning failed: could not boot image 'default': provisioning failed: Invalid
virtual machine configuration. Failed to lock auxiliary storage.
```
**Cause.** A `VZVirtualMachine` holds an exclusive lock on its bundle's `nvram.bin` for its entire
lifetime and releases it in `dealloc`. `VZMacOSInstaller` owns a VM of its own, and older builds
resumed the caller from inside the installer's completion handler — before the framework's frame
had unwound and dropped the last reference. First boot then constructed a *second* VM over the same
bundle and lost the race.
**Fix.** Upgrade. Two changes address it:
- The install now tears down its VM on its own serial queue and resumes the caller only from a
later block on that queue, so the installer's VM is deallocated before `install()` returns.
- First boot retries specifically on this failure for up to 30 s (2 s apart), printing
`waiting for installer to release the VM bundle…`. Any other configuration error still fails
immediately — an invalid configuration does not become valid by waiting.
Nothing is lost when it does happen: the install is complete, so re-running `image build` resumes.
---
## `image build` resumes an installed-but-unprovisioned image
**Symptom.** A previous `image build` finished installing macOS and then failed at first boot or
provisioning. Re-running it prints:
```
image default already installed — resuming first boot + provisioning
```
**This is intended.** An `image build` that fails after the install has left an hour of work on
disk, and throwing it away to redo an identical install is not a reasonable default. When the
bundle exists, is complete, and is not yet marked provisioned, the install phase is skipped and the
build goes straight to first boot.
It is safe because provisioning is exactly what did *not* happen: the guest has never been booted,
so its first boot is still ahead of it and `VZMacGuestProvisioningOptions` — which macOS evaluates
only on the first boot after a restore — still applies.
**The one ambiguous case.** The bundle on disk cannot say whether an earlier run got far enough to
*boot* the guest. If it did, that single chance at automated Setup Assistant is spent. Rather than
guess, the resume boots and waits for SSH: if the guest answers, provisioning was never applied and
the build continues normally. Only if SSH times out does it stop, and it then tells you so
explicitly — that state is unrecoverable, and the fix is `image delete` followed by a fresh build.
An image that is already `provisioned` is untouched; `image build` still refuses with
`image '<name>' already exists`. To re-run provisioning on a finished image, use
`gitea-macos-runner image provision <name>` instead.
---
## SSH fails with "No route to host" (errno 65) mid-run
**Symptom.** A command that had *just* talked to the guest successfully suddenly cannot reach it —
most visibly `image provision`, which waits for SSH, reports the first provisioning step, and then
dies on the upload:
```
first boot + guest provisioning…
provisioning: system configuration (provision.sh)
error: provisioning failed: could not upload provision.sh from …/provision.sh:
ssh failed: cannot connect to 192.168.65.232:22: … No route to host) (errno: 65)
```
**Cause.** On macOS 15 and newer, an app that has not been granted Local Network access does not get
a "permission denied": the packet filter answers **`EHOSTUNREACH` — errno 65, "No route to host"**,
which is indistinguishable from a guest that is genuinely off the network. Guests here live on a
host-private NAT link that is reachable whenever the VM is up, so on this path errno 65 is far more
often the privacy filter than a routing problem.
Two details make it look intermittent rather than like a permission problem:
- **The grant is keyed on the executable's UUID.** Per
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy),
Local Network privacy "uses your main executable UUID as part of its implementation", and the
linker mints a fresh `LC_UUID` on essentially every build. A `make install` after a code change
therefore presents a program macOS has never seen, whose permission is undetermined again — even
though the binary you ran ten minutes ago worked.
- **Processes started over SSH are exempt.** Running the same command through `ssh you@host …`
succeeds while running it from a GUI Terminal fails. A remote-shell success proves nothing about
the interactive path.
**Fix.** Allowlist the subnet — it is keyed on the network, not on the app, so no rebuild can
withdraw it and no prompt has to be answered:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
sudo reboot
```
The values are only read at boot, so **the reboot is not optional** — until it happens, `defaults
read com.apple.network.local-network` shows the new setting while the filter still behaves as
before.
Use `/18`, not `/24`. Virtualization.framework's NAT starts at `192.168.64.0/24` but chooses the
subnet at runtime and steps to the next free /24 when that one is in use, so hosts drift to
`192.168.65.x` and beyond. An allowlist naming a single /24 that the NAT has since moved off is the
worst case: it reads as configured, `doctor` used to call it a pass, and every guest connection
still fails with errno 65. `doctor` now warns instead when the allowlist does not cover
`192.168.64.0`–`192.168.127.255`.
**Verifying.** After the reboot, `gitea-macos-runner doctor` should show `local network access` as a
pass naming the range. Re-run the command that failed; nothing else needs redoing, and
`image provision` is idempotent, so a partially completed run is safe to repeat.