Before you start
This guide assumes:
- FlowDeck is installed and your trial is active. If not, follow the getting-started guide first.
- You have at least one existing
xcodebuildscript you'd like to migrate. Afastlanelane that shells out toxcodebuildworks too. - The script currently runs (even if grudgingly).
By the end you'll have a side-by-side translation of your existing commands, a smoke-tested replacement, and a switch-over plan that keeps xcodebuild available as a fallback the whole time.
Step 01
Audit your current xcodebuild commands
List every place your project shells out to xcodebuild, xcrun simctl, xcrun devicectl, or log stream. Common locations:
- A
scripts/ortools/directory in your repo. - Your CI config (
.github/workflows/,bitrise.yml,Jenkinsfile, etc.). - A
Fastfilewithsh "xcodebuild ..."calls. - Build phases inside Xcode that shell out.
- Makefiles,
justfiles, ornpmscripts in mixed-repo projects.
For each call site, write down what it does in plain English. "Build for CI release on iPhone 16 simulator." "Run the smoke test suite." "Boot a simulator and wait for it." The plain-English version is your migration target.
Step 02
Translate command by command
Use this table as a starting point. The patterns are deliberately mechanical so the migration can happen one line at a time.
| What you're doing | xcodebuild / xcrun |
FlowDeck |
|---|---|---|
| Discover schemes | xcodebuild -list -workspace App.xcworkspace |
flowdeck context --json |
| Build for a simulator | xcodebuild build -workspace ... -scheme ... -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' |
flowdeck build -S "iPhone 16" |
| Run a single test | -only-testing:MyAppTests/LoginTests/testValidLogin |
--only LoginTests/testValidLogin |
| Skip a slow test | -skip-testing:MyAppTests/SlowTests |
--skip SlowTests |
| Boot a simulator | UDID=$(xcrun simctl list ...); xcrun simctl boot "$UDID" |
flowdeck simulator boot "iPhone 16" |
| Stream logs from one app | xcrun simctl spawn booted log stream --predicate 'subsystem contains "com.myapp"' |
flowdeck logs <app-id> |
| Clean derived data | xcodebuild clean + rm -rf ~/Library/Developer/Xcode/DerivedData |
flowdeck clean --all |
| Parse output | Pipe through a regex, hope subsystem strings didn't change | Add --json, read typed events |
This is not a one-to-one feature map. xcodebuild archive, xcodebuild -exportArchive, and the signing-and-distribution pipeline don't have FlowDeck equivalents, those operations are out of scope. Keep xcodebuild for them.
Step 03
Run both in parallel and compare
Before switching anything, run the FlowDeck command alongside the existing xcodebuild call. This catches surprises while you still have a rollback.
# Existing script (unchanged)
xcodebuild build -workspace App.xcworkspace -scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16'
# New, side-by-side
flowdeck build -w App.xcworkspace -s App -S "iPhone 16" --json \
> /tmp/flowdeck-build.ndjson
Compare the exit code, the time taken, and the failure mode if anything goes wrong. The FlowDeck command should be at least as fast (often faster, because scheme discovery is native instead of a xcodebuild -list hit) and the exit code should match.
If output differs (FlowDeck reports a warning your CI ignored, or your CI flags an error FlowDeck rolled up), it's almost always FlowDeck being more precise. Investigate before assuming it's wrong.
Step 04
Save a project config to eliminate flag repetition
One of the wins of migrating is that you stop passing the same -workspace / -scheme / -destination on every command. FlowDeck reads from a per-project config:
flowdeck config set \
-w App.xcworkspace \
-s App \
-S "iPhone 16" \
-C Debug
After that, flowdeck build, flowdeck run, flowdeck test, and the rest pick up the saved values. The config is stored in .flowdeck/ in your project root. CI commits it (so jobs use the same configuration); developers either commit it or .gitignore it depending on whether your team standardizes on one scheme.
You can still pass flags on the command line; they override the saved config for that single call. Useful for "build the same scheme but on a different simulator just this once."
Step 05
Switch over piecemeal
The lowest-risk migration is one job at a time. A typical order:
- Local developer scripts first. The
build-and-run.shsomeone wrote three years ago. Lowest blast radius, easy to roll back. - PR validation builds next. The fastest, most-run CI job. Catches problems quickly because it runs on every push.
- Nightly regression runs after that. Once PR jobs are stable, move the nightly suite. Volume is lower; observation period is longer.
- Release builds last. Or not at all. If your release job uses
xcodebuild archiveand-exportArchive, leave it onxcodebuild. Releases are not the place to swap out tooling.
Keep the previous script in version control with a date in the filename (build.legacy.sh, workflow-2026-03.yml) until you've shipped through a full release cycle on the new tooling. The cost of keeping it is small. The cost of needing it and not having it is large.
Step 06
If something breaks, roll back fast
The migration is designed so any single job can revert to raw xcodebuild without disturbing the rest. Concrete recipe:
- Revert the one file. If a PR validation job fails after the swap, restore the
build.legacy.sh(orworkflow-2026-03.yml) you kept around. Jobs are independent; the local dev script and the nightly job keep using FlowDeck. - Capture the diff. Before reverting, save the FlowDeck output:
flowdeck build --json > /tmp/failed-build.ndjson. The structured events make it obvious where FlowDeck andxcodebuilddiverged. Almost always a different scheme, configuration, or simulator selection, not a real build difference. - File an issue with the NDJSON attached. FlowDeck issues with structured output attached are resolved faster than "the build failed."
Once the migrated job is back on FlowDeck and stable for a release cycle (PRs, nightly, release), the legacy script can be deleted. Not before.
Step 07
Convert text parsers to JSON consumers
If your CI has a script that parses xcodebuild output to detect failures or count warnings, replace it. The new version is shorter and won't break across Xcode versions:
# Before: a regex that breaks when xcodebuild changes its output
xcodebuild ... | grep "error:" | wc -l
# After: structured filtering with jq
flowdeck build --json \
| jq 'select(.type == "error") | .file + ":" + (.line|tostring)'
The same pattern works for tests:
flowdeck test --json \
| jq 'select(.type == "test_failed") | .name'
And for build summaries you can dump to a CI artifact:
flowdeck build --json > build-events.ndjson
# Attach build-events.ndjson as a CI artifact on failure
Stable schemas mean these parsers don't break the next time Apple changes a string in xcodebuild's output.
Patterns that come up during migration
- Keep
xcodebuildfor archive and export. - FlowDeck doesn't wrap
xcodebuild archiveor-exportArchive. Release pipelines that produce a signed.ipashould stay on rawxcodebuild. There's no benefit to migrating them. - Use
--verbosewhen debugging a migration. - FlowDeck summarizes output by default. If you're comparing against a
xcodebuildbaseline and the FlowDeck output looks suspiciously short, pass--verboseto see the underlyingxcodebuildstream. Useful for catching cases where your old script depended on a warning the summary suppressed. - Compose with
fastlane, don't replace it. - If you have a
Fastfilethat orchestrates lanes, the right move is usually to keep theFastfileand swap thesh "xcodebuild ..."calls inside lanes forsh "flowdeck ...". Fastlane keeps its orchestration role; FlowDeck replaces the manualxcodebuildplumbing. - Pin the FlowDeck version in CI.
- Same way you pin Xcode in CI. The install script accepts a
--versionargument, so you can pin to a known build for reproducibility:curl -sSL https://flowdeck.studio/install.sh | sh -s -- --version <tag>.
When things go wrong
- FlowDeck "succeeds" but produces no output where xcodebuild did
- FlowDeck summarizes by default. Add
--verboseto see the rawxcodebuildstream you're used to. Useful as a sanity check during migration, less useful as a permanent setting. - The build is slower than expected on CI
- Two common causes. First, the FlowDeck install step shouldn't repeat per job, install it on the runner image, or cache the install. Second, if you removed your DerivedData cache during migration, the first run rebuilds from scratch; subsequent runs benefit from the cache.
- A test that passed in xcodebuild fails in FlowDeck (or vice versa)
- Almost never the test. Usually a different scheme, configuration, or simulator. Diff your old
xcodebuildinvocation against yourflowdeck config getoutput: workspace, scheme, configuration, simulator. Mismatches surface here. - Some xcodebuild flag isn't supported
- FlowDeck deliberately doesn't expose every
xcodebuildflag (that surface is huge and most flags are rarely used). For uncommon flags, pass them through with--xcodebuild-options "-FLAG VALUE"on commands that accept it. If you can't find a way to pass a flag, you can always shell out to rawxcodebuildfor that one step. - The signing setup breaks under FlowDeck
- It doesn't, but the symptom can show up here first because FlowDeck makes the error message clearer. Signing identity, provisioning profile, and entitlement issues come from Xcode's signing pipeline, not from FlowDeck. The fix is the same one you'd apply to a failing
xcodebuildbuild: open Xcode, check the signing tab, make sure the profile is current.
Read next
Automate iOS builds for CI and local dev →
Further reading
- FlowDeck vs xcodebuild, same build system, different surface.
- Basic CLI commands, the day-to-day surface once the migration is done.
- iOS log streaming, the deep dive on per-app logging.
- Full CLI reference, in the FlowDeck docs.