xcodebuild doesn’t fail loudly when it matters. It succeeds quietly when it shouldn’t.
That’s the dangerous part. A tool that crashes gets fixed. A tool that prints ** BUILD SUCCEEDED ** while doing something subtly different from what you asked gets trusted — right up until a release build ships without your fix in it, or your CI pipeline spends six months green-lighting tests against binaries that were never rebuilt.
I’ve hit every one of these in real pipelines. Some on personal projects like VinylCrate, some on airline-scale CI where a lie in the build log costs a lot more than an afternoon. This is the field guide I wish someone had handed me. Four traps: the lying command, what’s actually happening under the hood, and the invocation that tells the truth.
Trap 1: “BUILD SUCCEEDED” That Never Built Your App
Here’s the command that lied to me first, years ago, and still lies to people every day:
cd VinylCrate
xcodebuild build
** BUILD SUCCEEDED **. Exit code 0. CI goes green. And your app target may never have compiled.
When you give xcodebuild no -scheme and no -target, it doesn’t guess your intent. It builds the first target listed in the project file, with the default configuration. Target order is a property of the .xcodeproj itself (same for every user, and largely an accident of history). If someone once dragged a shared framework or a helper tool to the top of the target list, that’s what you just built. Your app, your tests, your script phases — untouched. BUILD SUCCEEDED is technically true. It’s just not about the thing you care about.
The workspace variant of this lie is nastier because it’s louder in the wrong direction: xcodebuild -workspace VinylCrate.xcworkspace build fails and demands a scheme — which trains you to think xcodebuild will always complain when it’s confused. It won’t. Point it at a bare project and it happily builds something.
The fix is to never let xcodebuild choose. First, ask what it can see:
xcodebuild -list -workspace VinylCrate.xcworkspace
Then say exactly what you mean, every time:
xcodebuild build \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-configuration Release \
-destination 'generic/platform=iOS'
And when you’re debugging a “why didn’t my change take effect” mystery, run -showBuildSettings with the same flags and check which target and configuration actually resolved.
Trap 2: Green Tests Against a Stale Build
This one lived in a pipeline I inherited, and it’s the most expensive lie on this list because it compounds silently.
The split invocation looks like best practice — build once, test in parallel:
xcodebuild build-for-testing \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate
xcodebuild test-without-building \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5'
Looks fine. Here’s the problem: test-without-building does exactly what it says: it runs whatever test bundles it finds in derived data. It does not check that they’re fresh. If those two commands run on different agents, or the default ~/Library/Developer/Xcode/DerivedData location has products from a previous run, or someone reorders the pipeline steps, you are testing last build’s binaries. Your fix isn’t in them. The tests pass because the bug you fixed isn’t the bug being exercised — or worse, the tests for the fix pass because the old code happened to survive them.
I found this the way everyone finds it: a test that should have failed, didn’t, and the log timestamp on the .app bundle was from the previous night.
The honest version pins both halves to the same explicit derived data path, and hands the test step the .xctestrun file the build step produced — an explicit contract instead of an implicit “whatever’s lying around”:
DERIVED=./build/DerivedData
xcodebuild build-for-testing \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' \
-derivedDataPath "$DERIVED"
xcodebuild test-without-building \
-xctestrun "$(ls "$DERIVED"/Build/Products/*.xctestrun | head -1)" \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5'
The .xctestrun file names the exact product paths that were just built. If the build step didn’t run, the file isn’t there, and the test step fails with a missing file instead of running whatever was left over. That’s the trade you want.
If you’re shipping the products between CI agents, ship $DERIVED/Build/Products as the artifact — the .xctestrun travels with it, and there’s no ambient derived data on the test agent to fall back to. And if you’ve sliced your suite into focused test plans that run only what matters, nothing here changes — the .xctestrun carries the plan’s configuration with it.
Trap 3: The Destination That Isn’t the One You Asked For
Every iOS team has this line in a CI script somewhere:
xcodebuild test \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-destination 'platform=iOS Simulator,name=iPhone 16'
Two ways this lies to you. The first: with multiple simulator runtimes installed (normal on any CI image that’s survived a couple of Xcode updates), that specifier is ambiguous. xcodebuild prints a warning (“Using the first of multiple matching destinations”) and picks one. The warning scrolls past in a few thousand lines of build output, and now your “iOS 18.5” test suite has been running on the 18.2 runtime for a month. Whether that matters depends on which OS-version-gated behavior you’re testing. The month I hit it, it mattered.
The second way is the inverse: the CI image updates, iPhone 16 becomes iPhone 17, and you get Unable to find a destination matching the provided destination specifier — an error that reads like a build system failure and sends people off to nuke derived data, when the actual problem is that the device name no longer exists.
Either way the fix starts the same: stop guessing what destinations exist and ask.
xcodebuild -showdestinations \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate
That prints every eligible destination with platform, name, OS, and — the part you want — the device id. Then fully specify. For humans, name plus OS removes the ambiguity:
xcodebuild test \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5'
For CI, go one step further and pin by UDID, resolved at runtime so image updates can’t drift under you:
UDID=$(xcrun simctl list devices available --json \
| jq -r '.devices["com.apple.CoreSimulator.SimRuntime.iOS-18-5"][0].udid')
xcodebuild test \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-destination "id=$UDID"
An id= destination matches exactly one device or fails. No first-of-multiple. No silent substitution.
Trap 4: The “Clean Build” That Was Never Cold
This is the lie I’ve watched derail the most engineering conversations, because it corrupts measurements, and then people make architecture decisions based on the corrupted numbers.
Someone wants to know the cold build time. So they run:
time xcodebuild clean build \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-destination 'generic/platform=iOS'
Four minutes twenty. Great, that’s our clean build time. Except it isn’t, because clean is not “cold.” xcodebuild clean removes build products. It does not touch the Clang module cache (ModuleCache.noindex, shared across everything in default derived data), the resolved Swift package checkouts in SourcePackages, precompiled headers, or the build system’s other warm state. Your “clean” build reuses hundreds of prebuilt module maps from every project you’ve compiled this week. The number is fiction — and it’s fiction in the flattering direction, so the modularization effort that would actually fix your CI times looks less necessary than it is.
The corrected approach: own the derived data location, and delete it yourself. Don’t ask xcodebuild to clean; make cleanliness a property of the filesystem.
DERIVED=./build/DerivedData
PACKAGES=./build/SourcePackages
rm -rf "$DERIVED"
time xcodebuild build \
-workspace VinylCrate.xcworkspace \
-scheme VinylCrate \
-destination 'generic/platform=iOS' \
-derivedDataPath "$DERIVED" \
-clonedSourcePackagesDirPath "$PACKAGES"
Two decisions worth making deliberately here. Scoping -derivedDataPath to the repo means the module cache lives inside it: rm -rf kills products, indexes, and module cache in one move, so the measurement is honest. And splitting -clonedSourcePackagesDirPath out separately lets you choose whether package checkouts are part of the test: keep them and you’re measuring compilation; delete them too and you’re measuring compilation plus dependency resolution plus the network. Those are different numbers that answer different questions. Know which one you’re publishing before someone builds a roadmap on it.
Trust, But Verify
None of these are bugs. Every one is xcodebuild doing exactly what it was told — the lie lives in the gap between what you told it and what you thought you told it. The tool’s defaults are optimized for a developer at a Mac with one project open, not for a pipeline that has to mean what it says.
The field checklist:
# 1. Never let xcodebuild pick the target
xcodebuild -list -workspace VinylCrate.xcworkspace
xcodebuild build -workspace VinylCrate.xcworkspace -scheme VinylCrate
# 2. Pin build and test to the same products via .xctestrun
xcodebuild build-for-testing ... -derivedDataPath ./build/DerivedData
xcodebuild test-without-building -xctestrun ./build/DerivedData/Build/Products/*.xctestrun ...
# 3. Fully specify destinations — or pin by id
xcodebuild -showdestinations -workspace VinylCrate.xcworkspace -scheme VinylCrate
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' # or id=$UDID
# 4. "Clean" means you deleted it yourself
rm -rf ./build/DerivedData && xcodebuild build ... -derivedDataPath ./build/DerivedData
When a build log says everything succeeded, believe it built something. Verifying it built your thing — right target, fresh products, the runtime and cache state you actually asked for — is still your job.
The log isn’t lying to you, exactly. It’s just answering a different question than the one you asked.