From b2f15883d88156e6eb5cbdb7c291f41cc3d6b7d3 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 02:30:26 -0700 Subject: [PATCH 1/3] Nucleic: Gitea Runner macOS VM Support --- Sources/RunnerCore/PathResolution.swift | 77 ++++++++++++++ Sources/RunnerHost/IPSW.swift | 10 +- Sources/RunnerHost/ImageBuilder.swift | 36 ++++++- .../gitea-macos-runner/CommandDaemon.swift | 77 ++++++++++---- Sources/gitea-macos-runner/CommandImage.swift | 43 +++++--- Sources/gitea-macos-runner/CommandVM.swift | 7 +- Sources/gitea-macos-runner/Main.swift | 32 +++++- .../RunnerCoreTests/PathResolutionTests.swift | 100 ++++++++++++++++++ docs/troubleshooting.md | 68 ++++++++++++ 9 files changed, 403 insertions(+), 47 deletions(-) diff --git a/Sources/RunnerCore/PathResolution.swift b/Sources/RunnerCore/PathResolution.swift index e230db7..c85e847 100644 --- a/Sources/RunnerCore/PathResolution.swift +++ b/Sources/RunnerCore/PathResolution.swift @@ -108,3 +108,80 @@ public enum PathResolution { #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]) + } +} diff --git a/Sources/RunnerHost/IPSW.swift b/Sources/RunnerHost/IPSW.swift index d272d83..58fafc1 100644 --- a/Sources/RunnerHost/IPSW.swift +++ b/Sources/RunnerHost/IPSW.swift @@ -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) diff --git a/Sources/RunnerHost/ImageBuilder.swift b/Sources/RunnerHost/ImageBuilder.swift index b2e3c61..8d5f521 100644 --- a/Sources/RunnerHost/ImageBuilder.swift +++ b/Sources/RunnerHost/ImageBuilder.swift @@ -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. @@ -89,7 +95,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 +127,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. diff --git a/Sources/gitea-macos-runner/CommandDaemon.swift b/Sources/gitea-macos-runner/CommandDaemon.swift index 04b0c48..40a0398 100644 --- a/Sources/gitea-macos-runner/CommandDaemon.swift +++ b/Sources/gitea-macos-runner/CommandDaemon.swift @@ -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) } } diff --git a/Sources/gitea-macos-runner/CommandImage.swift b/Sources/gitea-macos-runner/CommandImage.swift index b681fa8..a87538b 100644 --- a/Sources/gitea-macos-runner/CommandImage.swift +++ b/Sources/gitea-macos-runner/CommandImage.swift @@ -91,14 +91,15 @@ 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") @@ -254,14 +255,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)'") @@ -270,19 +272,36 @@ 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)) + } + } + /// 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: diff --git a/Sources/gitea-macos-runner/CommandVM.swift b/Sources/gitea-macos-runner/CommandVM.swift index 4616cc9..65e2dfb 100644 --- a/Sources/gitea-macos-runner/CommandVM.swift +++ b/Sources/gitea-macos-runner/CommandVM.swift @@ -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) } } ) diff --git a/Sources/gitea-macos-runner/Main.swift b/Sources/gitea-macos-runner/Main.swift index b251c79..3c853a9 100644 --- a/Sources/gitea-macos-runner/Main.swift +++ b/Sources/gitea-macos-runner/Main.swift @@ -137,24 +137,50 @@ final class ProgressPrinter: @unchecked Sendable { private let lock = NSLock() private var lastLine = "" + /// 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) { lock.lock() defer { lock.unlock() } guard line != lastLine else { return } lastLine = line + guard isInteractive else { + FileHandle.standardError.write(Data((line + "\n").utf8)) + return + } let padding = String(repeating: " ", count: max(0, 78 - line.count)) FileHandle.standardError.write(Data(("\r" + line + padding).utf8)) } + /// 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. func finish(_ line: String? = nil) { 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 { + let padding = isInteractive ? String(repeating: " ", count: max(0, 78 - line.count)) : "" + let prefix = isInteractive ? "\r" : "" + FileHandle.standardError.write(Data((prefix + line + padding + "\n").utf8)) + } else if !lastLine.isEmpty, isInteractive { FileHandle.standardError.write(Data("\n".utf8)) } lastLine = "" diff --git a/Tests/RunnerCoreTests/PathResolutionTests.swift b/Tests/RunnerCoreTests/PathResolutionTests.swift index 343ad2f..3ecad2b 100644 --- a/Tests/RunnerCoreTests/PathResolutionTests.swift +++ b/Tests/RunnerCoreTests/PathResolutionTests.swift @@ -140,6 +140,106 @@ struct PathResolutionTests { } } + // 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ee04d6..696e968 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -33,6 +33,8 @@ 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 | --- @@ -363,6 +365,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 From 748bc7e5e5aa85969c4834731bee18badf7487bd Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 03:12:18 -0700 Subject: [PATCH 2/3] Nucleic: Gitea Runner macOS VM Support --- Resources/provision.sh | 12 +- Sources/RunnerHost/ImageBuilder.swift | 172 ++++++++++++++++-- Sources/gitea-macos-runner/CommandImage.swift | 37 +++- Sources/gitea-macos-runner/Main.swift | 39 +++- docs/troubleshooting.md | 62 ++++++- 5 files changed, 292 insertions(+), 30 deletions(-) diff --git a/Resources/provision.sh b/Resources/provision.sh index 7604e30..f0824b8 100755 --- a/Resources/provision.sh +++ b/Resources/provision.sh @@ -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 diff --git a/Sources/RunnerHost/ImageBuilder.swift b/Sources/RunnerHost/ImageBuilder.swift index 8d5f521..3b09e1d 100644 --- a/Sources/RunnerHost/ImageBuilder.swift +++ b/Sources/RunnerHost/ImageBuilder.swift @@ -79,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." @@ -370,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) in + queue.async { continuation.resume() } + } } /// Boots the freshly installed guest, gets it onto the network, and hands it @@ -416,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 { @@ -460,6 +512,7 @@ public struct ImageBuilder: Sendable { config: config, startOptions: startOptions, isFirstBoot: true, + isResume: isResume, xcodeXIPPath: nil, progress: progress ) @@ -504,6 +557,7 @@ public struct ImageBuilder: Sendable { config: config, startOptions: nil, isFirstBoot: false, + isResume: false, xcodeXIPPath: xcodeXIPPath, progress: progress ) @@ -519,6 +573,7 @@ public struct ImageBuilder: Sendable { config: RunnerConfig, startOptions: VZMacOSVirtualMachineStartOptions?, isFirstBoot: Bool, + isResume: Bool, xcodeXIPPath: String?, progress: (@Sendable (ImageBuildStage) -> Void)? ) async throws { @@ -527,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 { @@ -552,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 + + 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 @@ -624,6 +706,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: diff --git a/Sources/gitea-macos-runner/CommandImage.swift b/Sources/gitea-macos-runner/CommandImage.swift index a87538b..96811d0 100644 --- a/Sources/gitea-macos-runner/CommandImage.swift +++ b/Sources/gitea-macos-runner/CommandImage.swift @@ -64,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)`" ) @@ -101,7 +106,10 @@ struct ImageCommand: AsyncParsableCommand { // 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)") @@ -281,7 +289,30 @@ struct ImageCommand: AsyncParsableCommand { if case .note(let text) = stage { printer.line(text) } else { - printer.update(describe(stage)) + 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". + case .provisioning(let step): return "provisioning:\(step)" + case .finalizing: return "finalizing" + case .done: return "done" } } diff --git a/Sources/gitea-macos-runner/Main.swift b/Sources/gitea-macos-runner/Main.swift index 3c853a9..de5d3ea 100644 --- a/Sources/gitea-macos-runner/Main.swift +++ b/Sources/gitea-macos-runner/Main.swift @@ -136,6 +136,7 @@ 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. /// @@ -147,17 +148,31 @@ final class ProgressPrinter: @unchecked Sendable { 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 guard isInteractive else { - FileHandle.standardError.write(Data((line + "\n").utf8)) + emit(line + "\n") return } - let padding = String(repeating: " ", count: max(0, 78 - line.count)) - FileHandle.standardError.write(Data(("\r" + line + padding).utf8)) + emit("\r" + line + pad(line)) } /// Emits a standalone line without losing the status line under it. @@ -177,12 +192,20 @@ final class ProgressPrinter: @unchecked Sendable { lock.lock() defer { lock.unlock() } if let line { - let padding = isInteractive ? String(repeating: " ", count: max(0, 78 - line.count)) : "" - let prefix = isInteractive ? "\r" : "" - FileHandle.standardError.write(Data((prefix + line + padding + "\n").utf8)) + emit((isInteractive ? "\r" : "") + line + (isInteractive ? pad(line) : "") + "\n") } else if !lastLine.isEmpty, isInteractive { - FileHandle.standardError.write(Data("\n".utf8)) + 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)) + } } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 696e968..3a97675 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -35,6 +35,8 @@ gitea-macos-runner service status | `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 | --- @@ -441,7 +443,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: @@ -453,3 +455,61 @@ gitea-macos-runner image build --ipsw --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 '' already exists`. To re-run provisioning on a finished image, use +`gitea-macos-runner image provision ` instead. From c71a457bcf038eb8e2ddde626bc9ea428db6f4af Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 03:30:44 -0700 Subject: [PATCH 3/3] Nucleic: Gitea Runner macOS VM Support --- .gitea/workflows/build.yml | 74 ++++++++++++++++++++++++++++++++++++++ README.md | 19 ++++++++++ 2 files changed, 93 insertions(+) create mode 100644 .gitea/workflows/build.yml diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..30e0e38 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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. diff --git a/README.md b/README.md index 702b98a..10410e1 100644 --- a/README.md +++ b/README.md @@ -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,