Write Xcode Automation Scripts In Swift With Subprocess 1.0
Most iOS teams eventually grow a folder full of little automation scripts.
One script runs xcodebuild. Another wraps swift test. Another tries to pull the failed test names out of a log. Another writes a pull request comment. At first, these scripts are usually Bash or a small Swift wrapper around Process. They work right up until output buffering gets weird, cancellation leaves a simulator test run behind, quoting breaks an argument with spaces, or CI shows you the least useful line in a 20,000-line log.
Swift Subprocess is a better fit for that kind of tool. Apple’s WWDC26 “What’s new in Swift” session calls out Subprocess 1.0 as part of the Swift 6.4 wave, with a refined execution model, better error handling, stream-friendly output APIs, and improved cross-platform behavior. The package is also open source, so you can check the exact API shape your toolchain sees instead of guessing.
That last sentence matters. The examples below follow the Swift 6.4 / Subprocess 1.0 direction Apple showed at WWDC26 and the package sources I checked on June 22, 2026. The public repository still had a 0.5 release preparing for 1.0, with mainline docs moving ahead of released tags. Treat the code as production-shaped, but let your package checkout, generated docs, and compiler be the source of truth for exact property names.
Why not just Process?
Process is still fine for small scripts:
- Launch one command.
- Collect a little output.
- Run only on macOS.
- Keep the code inside an existing Foundation-heavy tool.
- Avoid another package dependency.
The pain starts when the script becomes a workflow. Xcode automation usually needs to stream output while the process is still running, merge stderr with stdout in CI order, pass a controlled working directory and environment, handle non-zero exits without throwing away the useful lines, cancel cleanly, and produce a readable summary.
You can make Process do all of that, but you end up rebuilding async process management around pipes, readability handlers, termination handlers, and cleanup. Subprocess gives you a Swift Concurrency-shaped API for the thing you actually want to express:
Run this executable with these arguments, in this directory, with this environment, stream its output line by line, then tell me how it exited.
Add the package
In a SwiftPM command-line tool, add the package and product:
// Package.swift
let package = Package(
name: "XcodeAutomation",
platforms: [
.macOS(.v15)
],
products: [
.executable(name: "xcode-summary", targets: ["XcodeSummary"])
],
dependencies: [
// During the beta cycle, pin the exact tag or revision you validated.
// Move to a 1.0 version range after the 1.0 tag is available.
.package(
url: "https://github.com/swiftlang/swift-subprocess.git",
revision: "0.5"
)
],
targets: [
.executableTarget(
name: "XcodeSummary",
dependencies: [
.product(name: "Subprocess", package: "swift-subprocess")
]
)
]
)
Do not cargo-cult the version line. As of my source check, the repository’s latest public release was 0.5, and the release notes explicitly describe it as preparation for the 1.0 API. Pin the release, branch, or revision your team has compiled, then move to a normal 1.0 version range when the package and your Swift 6.4 toolchain agree.
The workflow
Let’s build a practical CLI named xcode-summary.
It will:
- Run
xcodebuild test. - Stream combined stdout and stderr as the build runs.
- Extract failed tests and important lines.
- Optionally run
swift testfor package tests. - Pass a working directory and environment.
- Preserve non-zero exits as structured results.
- Tear down the child process when the parent task is cancelled.
- Write a Markdown summary for CI or a pull request.
The example assumes an app workspace, but the shape works for projects too:
swift run xcode-summary \
--workspace WorkoutApp.xcworkspace \
--scheme WorkoutApp \
--destination "platform=iOS Simulator,name=iPhone 17" \
--summary .automation/summary.md \
--swift-test
In GitHub Actions, the summary path can be $GITHUB_STEP_SUMMARY. In another CI system, write the same Markdown to an artifact or PR-comment step.
Model the command
Start by making commands data, not strings. Subprocess does not need a shell, so each argument stays an argument. That removes a whole class of quoting problems.
import Foundation
import Subprocess
#if canImport(System)
import System
#else
import SystemPackage
#endif
struct CommandSpec: Sendable {
var executable: Executable
var arguments: [String]
var workingDirectory: FilePath?
var environmentUpdates: [Environment.Key: String?]
var displayName: String {
([executable.description] + arguments).joined(separator: " ")
}
}
For Xcode, I usually launch through /usr/bin/xcrun instead of relying on whatever xcodebuild is first on PATH:
func xcodebuildCommand(
workspace: String,
scheme: String,
destination: String,
projectRoot: FilePath,
resultBundlePath: String
) -> CommandSpec {
CommandSpec(
executable: .path("/usr/bin/xcrun"),
arguments: [
"xcodebuild",
"test",
"-workspace", workspace,
"-scheme", scheme,
"-destination", destination,
"-resultBundlePath", resultBundlePath,
"-derivedDataPath", ".automation/DerivedData",
"-parallel-testing-enabled", "YES"
],
workingDirectory: projectRoot,
environmentUpdates: [
"CI": "1",
"NSUnbufferedIO": "YES"
]
)
}
For package tests, .name("swift") is fine if your CI image puts the intended Swift toolchain on PATH:
func swiftTestCommand(projectRoot: FilePath) -> CommandSpec {
CommandSpec(
executable: .name("swift"),
arguments: ["test", "--parallel"],
workingDirectory: projectRoot,
environmentUpdates: [
"CI": "1"
]
)
}
That gives us both executable lookup styles: .path when we want one exact executable, and .name when PATH lookup is the right contract.
Stream xcodebuild output
The most important part of this script is not “run xcodebuild.” It is “keep useful information while xcodebuild is still running.”
Here is the Subprocess 1.0-shaped version:
struct CommandSummary: Sendable {
var command: String
var terminationStatus: TerminationStatus?
var failedTests: [String]
var importantLines: [String]
var outputTail: [String]
var succeeded: Bool {
terminationStatus?.isSuccess == true
}
}
func runStreaming(_ command: CommandSpec) async throws -> CommandSummary {
var platformOptions = PlatformOptions()
#if !os(Windows)
platformOptions.teardownSequence = [
.send(signal: .interrupt, allowedDurationToNextStep: .seconds(10)),
.gracefulShutDown(allowedDurationToNextStep: .seconds(5))
]
#else
platformOptions.teardownSequence = [
.gracefulShutDown(allowedDurationToNextStep: .seconds(10))
]
#endif
let configuration = Configuration(
executable: command.executable,
arguments: Arguments(command.arguments),
environment: .inherit.updating(command.environmentUpdates),
workingDirectory: command.workingDirectory,
platformOptions: platformOptions
)
var parser = XcodebuildOutputParser(command: command.displayName)
let outcome = try await run(
configuration,
input: .none,
output: .sequence,
error: .combinedWithOutput
) { execution in
for try await line in execution.standardOutput.strings() {
print(line)
parser.consume(line)
}
return parser.summary()
}
var summary = outcome.closureResult
summary.terminationStatus = outcome.terminationStatus
return summary
}
There are three details worth slowing down for.
First, error: .combinedWithOutput gives the script the CI-friendly version of 2>&1. Build systems often interleave meaningful messages across stdout and stderr. Handling one combined stream keeps ordering simple and makes the summary easier to explain.
Second, output: .sequence means the output is streamed to the closure instead of being collected only after the process exits. That is what lets us both print the live log and keep a compact summary.
Third, PlatformOptions.teardownSequence gives cancellation a policy. If the CI job is cancelled, Subprocess can try an interrupt, wait briefly, then continue toward termination. You should tune those durations for your team. Simulator test runs sometimes need a few seconds to write diagnostics, but a cancelled job should not hold an executor forever.
One beta-cycle spelling note: use the API your package checkout exposes. The 0.4 release used a separate output-sequence closure parameter and outcome.value. The 0.5 release moved streams onto execution.standardOutput, renamed .lines() to .strings(), and documented the closure return as closureOutput. The mainline README and source I checked expose execution.standardOutput.strings() and closureResult. The important design is stable: the closure returns your parsed value, and the result also carries the process termination status.
Keep the parser boring
Do not try to parse every possible xcodebuild line. The full structured truth belongs in the .xcresult bundle. The streaming parser’s job is to produce a useful first page for humans.
struct XcodebuildOutputParser {
private var command: String
private var failedTests: [String] = []
private var importantLines: [String] = []
private var tail = RingBuffer(limit: 80)
init(command: String) {
self.command = command
}
mutating func consume(_ rawLine: String) {
let line = rawLine.trimmingCharacters(in: .newlines)
tail.append(line)
if isFailedTestLine(line) {
appendLimited(line, to: &failedTests, limit: 50)
}
if isImportantLine(line) {
appendLimited(line, to: &importantLines, limit: 120)
}
}
func summary() -> CommandSummary {
CommandSummary(
command: command,
terminationStatus: nil,
failedTests: failedTests,
importantLines: importantLines,
outputTail: tail.values
)
}
private func isFailedTestLine(_ line: String) -> Bool {
line.contains("Test Case") && line.contains(" failed")
|| line.contains("Failing tests:")
|| line.contains(" failed at ")
}
private func isImportantLine(_ line: String) -> Bool {
line.contains("error:")
|| line.contains("warning:")
|| line.contains("fatal error:")
|| line.contains("** BUILD FAILED **")
|| line.contains("** TEST FAILED **")
|| isFailedTestLine(line)
}
private func appendLimited(
_ line: String,
to array: inout [String],
limit: Int
) {
guard !array.contains(line) else { return }
array.append(line)
if array.count > limit {
array.removeFirst(array.count - limit)
}
}
}
struct RingBuffer {
private let limit: Int
private var storage: [String] = []
init(limit: Int) {
self.limit = limit
}
var values: [String] {
storage
}
mutating func append(_ value: String) {
storage.append(value)
if storage.count > limit {
storage.removeFirst(storage.count - limit)
}
}
}
This parser is intentionally humble. It catches the common lines people scan for in CI, keeps a short tail for context, and leaves deep inspection to xcresulttool, Xcode, or a dedicated result-bundle parser.
If you want to improve it, do it from real logs. Save a few failed CI logs, add parser unit tests, and teach the parser only the patterns your team actually needs. That is much better than building a heroic parser from memory.
Handle non-zero exits without losing the summary
A non-zero xcodebuild exit is not an exceptional situation for this tool. It is one of the normal outcomes the tool exists to summarize.
So do not immediately throw when a child process exits with exited(65). Capture the termination status, write the Markdown summary, then make the CLI itself fail at the end.
struct WorkflowSummary {
var xcodebuild: CommandSummary
var swiftTest: CommandSummary?
var succeeded: Bool {
xcodebuild.succeeded && (swiftTest?.succeeded ?? true)
}
}
enum WorkflowFailed: Error {
case failed(summaryPath: String)
}
func runWorkflow(
xcodebuild: CommandSpec,
swiftTest: CommandSpec?
) async throws -> WorkflowSummary {
let xcodebuildSummary = try await runStreaming(xcodebuild)
let swiftTestSummary: CommandSummary?
if let swiftTest {
swiftTestSummary = try await runStreaming(swiftTest)
} else {
swiftTestSummary = nil
}
return WorkflowSummary(
xcodebuild: xcodebuildSummary,
swiftTest: swiftTestSummary
)
}
Then your main function can always write the report:
@main
struct XcodeSummaryTool {
static func main() async throws {
let options = try Options.parse(CommandLine.arguments)
let projectURL = URL(fileURLWithPath: options.projectRoot.string)
let automationURL = projectURL.appendingPathComponent(".automation")
try FileManager.default.createDirectory(
at: automationURL,
withIntermediateDirectories: true
)
let summaryURL = URL(fileURLWithPath: options.summaryPath)
try FileManager.default.createDirectory(
at: summaryURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let resultBundlePath = ".automation/TestResults-\(UUID().uuidString).xcresult"
let xcodebuild = xcodebuildCommand(
workspace: options.workspace,
scheme: options.scheme,
destination: options.destination,
projectRoot: options.projectRoot,
resultBundlePath: resultBundlePath
)
let packageTests = options.runSwiftTest
? swiftTestCommand(projectRoot: options.projectRoot)
: nil
let workflow = try await runWorkflow(
xcodebuild: xcodebuild,
swiftTest: packageTests
)
let markdown = workflow.markdown()
try markdown.write(
to: summaryURL,
atomically: true,
encoding: .utf8
)
guard workflow.succeeded else {
throw WorkflowFailed(summaryPath: options.summaryPath)
}
}
}
If main() throws, the tool exits non-zero. CI gets a failed step, but humans still get the summary file.
Write a Markdown CI summary
The summary should answer the question a reviewer actually has:
What failed, where should I look first, and is there enough context to reproduce it?
extension WorkflowSummary {
func markdown() -> String {
var sections: [String] = []
sections.append("""
## Xcode Automation Summary
**Status:** \(succeeded ? "Passed" : "Failed")
| Command | Result |
| --- | --- |
| `xcodebuild test` | `\(xcodebuild.terminationStatus?.description ?? "unknown")` |
\(swiftTest.map { "| `swift test` | `\($0.terminationStatus?.description ?? "unknown")` |" } ?? "")
""")
sections.append(markdownSection(
title: "Failed Tests",
lines: xcodebuild.failedTests + (swiftTest?.failedTests ?? []),
emptyText: "No failed test lines were detected in the live log."
))
sections.append(markdownSection(
title: "Important Lines",
lines: xcodebuild.importantLines + (swiftTest?.importantLines ?? []),
emptyText: "No important warning or error lines were detected."
))
sections.append(markdownSection(
title: "Recent Output",
lines: xcodebuild.outputTail,
emptyText: "No output was captured."
))
return sections.joined(separator: "\n\n") + "\n"
}
private func markdownSection(
title: String,
lines: [String],
emptyText: String
) -> String {
guard !lines.isEmpty else {
return "### \(title)\n\n\(emptyText)"
}
let body = lines
.map { "- `\($0.replacingOccurrences(of: "`", with: "'"))`" }
.joined(separator: "\n")
return "### \(title)\n\n\(body)"
}
}
That output is intentionally plain. CI summaries are not blog posts. They are triage surfaces. Put the failed tests first, keep the important compiler lines close behind, and include a tail so someone can see what happened near the end.
If you are on GitHub Actions, wire it up like this:
- name: Run Xcode workflow
run: |
swift run xcode-summary \
--workspace WorkoutApp.xcworkspace \
--scheme WorkoutApp \
--destination "platform=iOS Simulator,name=iPhone 17" \
--summary "$GITHUB_STEP_SUMMARY" \
--swift-test
The same Markdown can be uploaded as an artifact, posted to a pull request, or stored next to the .xcresult bundle.
Capture output when streaming is not needed
Not every command needs a streaming parser. For short helper commands, collected output is simpler.
For example, you may want to record which Xcode selected the job:
func selectedXcodeVersion() async throws -> String {
let record = try await run(
.path("/usr/bin/xcrun"),
arguments: ["xcodebuild", "-version"],
output: .string(limit: 16 * 1024),
error: .combinedWithOutput
)
guard record.terminationStatus.isSuccess else {
return "xcodebuild -version failed: \(record.terminationStatus)"
}
return record.standardOutput ?? "Unknown Xcode version"
}
Collected output is also useful for xcodebuild -showBuildSettings -json, xcodebuild -list -json, or tiny helper tools that produce bounded machine-readable output.
Use a limit on collected output. A build log is not a string you want to accidentally hold in memory. Stream long-running commands and collect short commands.
Cancellation is part of the product
CI jobs get cancelled. Developers hit Control-C. Xcode test processes hang. Simulators can take a moment to notice that the parent process is gone.
That means cancellation behavior is not polish. It is part of whether people trust the tool.
Subprocess runs teardown when the parent task is cancelled. The sequence you choose should match the child process:
var platformOptions = PlatformOptions()
#if !os(Windows)
platformOptions.teardownSequence = [
.send(signal: .interrupt, allowedDurationToNextStep: .seconds(10)),
.gracefulShutDown(allowedDurationToNextStep: .seconds(5))
]
#else
platformOptions.teardownSequence = [
.gracefulShutDown(allowedDurationToNextStep: .seconds(10))
]
#endif
For xcodebuild, I prefer trying interrupt first because it is close to what a developer does locally. Then give it a short grace period to unwind, flush output, and write any result artifacts it can. After that, let the teardown sequence keep moving. A cancelled CI job should not become a stuck machine.
One important caveat: default teardown may only target the immediate child process. xcodebuild can have simulator and helper descendants, so validate cancellation on your actual project. If your Subprocess version supports process-group teardown, use it deliberately and pair it with the package’s documented session or process-group isolation guidance before assuming descendants are covered.
For a long-running server command, the policy might be different. You might start with graceful shutdown, wait longer, then kill. The useful point is that the policy lives beside the command configuration instead of being scattered across signal handlers.
Where this belongs in a repo
For app teams, I like keeping this kind of tool in the repo as a SwiftPM executable:
Package.swift
Sources/
XcodeSummary/
main.swift
CommandSpec.swift
XcodebuildOutputParser.swift
WorkflowSummary.swift
Then make CI call the tool instead of duplicating the workflow in YAML. YAML is fine for orchestration. Swift is better for branching, parsing, testing, and preserving domain knowledge.
The script can grow gradually:
- Add an
--only-testingargument when a developer wants one suite. - Add a retry mode for known flaky test buckets.
- Attach the
.xcresultpath to the Markdown. - Parse
xcresulttooloutput after the run for structured failure details. - Emit JSON for another CI step.
- Run different destinations from a checked-in matrix.
Once the logic is in Swift, those changes can have tests.
A practical checklist
When you replace a Process wrapper with Subprocess, check these details:
- Use
.pathfor exact tools such as/usr/bin/xcrun. - Use
.namewhen PATH lookup is intentional. - Pass arguments as arrays, not shell-joined strings.
- Use
Configurationwhen working directory, environment, and platform options belong together. - Set
NSUnbufferedIO=YESfor friendlierxcodebuildstreaming. - Stream long logs with
output: .sequence. - Use
error: .combinedWithOutputwhen CI log ordering matters. - Keep output parsing small and test it against real failed logs.
- Check
terminationStatus.isSuccessafter every command. - Write summaries before throwing for workflow failure.
- Configure teardown, and validate whether your command’s child process tree needs process-group handling.
- Pin the Subprocess package revision you compiled during the beta cycle.
The bigger idea is not that every shell script must become Swift. It is that Xcode automation is app code adjacent. It has inputs, outputs, failure modes, cancellation behavior, and readers who need useful errors. Once a script becomes important to your release loop, giving it Swift’s types and concurrency model is a very reasonable trade.
Further reading: