Before you start
This guide assumes:
- FlowDeck is installed locally and your trial is active. If not, follow the getting-started guide first.
- Your project builds locally with
flowdeck build. - A CI service that runs on macOS. GitHub Actions
macos-15runners are the examples below, but the same pattern works on Bitrise, CircleCI, GitLab, or self-hosted Mac runners.
By the end you'll have a local script that builds and tests, a CI job that runs the same commands, log files attached on failure, and DerivedData cached between runs to keep CI times sane.
Step 01
Write the local script first
Before touching CI, write the exact script you want CI to run. Test it locally. Make it idempotent. CI is just a runner of scripts that already work.
# scripts/build-and-test.sh
#!/usr/bin/env bash
set -euo pipefail
flowdeck simulator boot "iPhone 16"
flowdeck build -S "iPhone 16" --configuration Debug
flowdeck test -S "iPhone 16" --only MyAppTests --json \
> artifacts/test-results.ndjson
Run the script locally a few times. Confirm it succeeds against a clean checkout, against an existing checkout with cached DerivedData, and after a flowdeck clean. If it works in all three states locally, it'll work in CI.
If you have a saved .flowdeck/config.json in the project, the -S "iPhone 16" and configuration flags are redundant locally. Pass them in CI anyway, where the config file may or may not be present depending on whether you commit it.
Step 02
Install FlowDeck on the runner
Two paths. Install at job start (simple, adds ~5-10 seconds per job) or bake into the runner image (faster, more setup).
Install at job start (recommended for getting started):
# .github/workflows/ios.yml
- name: Install FlowDeck
run: curl -sSL https://flowdeck.studio/install.sh | sh
- name: Verify FlowDeck
run: flowdeck --version
The installer is non-interactive and won't prompt. It puts the binary at ~/.local/bin/flowdeck; make sure that's on PATH (GitHub-hosted macOS runners have it by default; self-hosted runners may need a one-line export).
Pinning a specific FlowDeck version (for reproducible builds):
curl -sSL https://flowdeck.studio/install.sh | sh -s -- --version <tag>
Step 03
Activate a CI license
CI runners need a way to authenticate FlowDeck without an interactive trial flow. Put your license key in a CI secret and pass it via environment variable:
- name: Activate FlowDeck license
env:
FLOWDECK_LICENSE_KEY: $
run: flowdeck license activate "$FLOWDECK_LICENSE_KEY"
Or, if your license entitles you to it, set FLOWDECK_LICENSE_KEY as a job-level env var and FlowDeck will pick it up implicitly without a separate activate step. Check flowdeck license status in your first job run to confirm activation succeeded.
Don't commit license keys to the repo. CI secret managers (GitHub Actions secrets, Bitrise environment variables, etc.) are the right home.
The TUI's trial-activation flow doesn't apply to CI: there's no terminal to drive the email + OTP exchange. CI runners must use a real license key in a secret. If you're still on a trial, finish trial activation locally first, then issue or purchase a license key for the CI runners.
Step 04
Run the build
The build step is one command. Pass --json so failures produce structured output a CI parser can read:
- name: Build
run: |
flowdeck build \
-w App.xcworkspace \
-s MyApp \
-S "iPhone 16" \
--configuration Debug \
--json \
> build-events.ndjson
- name: Upload build events
if: failure()
uses: actions/upload-artifact@v4
with:
name: build-events
path: build-events.ndjson
On success the job moves on. On failure the NDJSON file is attached as an artifact, so when someone investigates a CI failure they're reading typed error events with file and line numbers, not 2,000 lines of xcodebuild stdout.
Step 05
Run the tests
Tests follow the same pattern. Boot a simulator first if you need to (FlowDeck will boot one automatically if not, but explicit is friendlier for debugging):
- name: Boot simulator
run: flowdeck simulator boot "iPhone 16"
- name: Run tests
run: |
flowdeck test \
-w App.xcworkspace \
-s MyApp \
-S "iPhone 16" \
--configuration Debug \
--json \
> test-results.ndjson
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.ndjson
If the runner doesn't have the iOS runtime you need, install it as a setup step: flowdeck simulator runtime install iOS 18.0. This is non-interactive and reports progress, which is what CI wants.
For filtering to a subset (smoke tests, a specific suite), use --only and --skip:
flowdeck test --only SmokeTests --skip SlowTests --json
Step 06
Capture app logs on failure
When a test fails, the most useful evidence is usually the app's runtime logs at the moment of failure. Capture them alongside the test run:
- name: Run app and tests with log capture
run: |
flowdeck run --log -S "iPhone 16" \
> app.log 2>&1 &
APP_PID=$!
flowdeck test --only IntegrationTests --json \
> test-results.ndjson
flowdeck stop --all
kill $APP_PID 2>/dev/null || true
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: app-logs
path: app.log
The streamed logs include only your app's output, so reviewers don't have to dig through system noise to find what the app actually did. flowdeck stop --all in the cleanup step makes sure the next job doesn't inherit a leftover app.
Step 07
Cache DerivedData
The single biggest CI time win for iOS jobs. FlowDeck respects Xcode's DerivedData layout, so caching is straightforward:
- name: Cache DerivedData
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData
key: derived-data-$-$
restore-keys: |
derived-data-$-
The cache key uses checksums of the files that meaningfully invalidate a build: SPM lockfile, workspace structure, project file. Source code changes don't bust the cache; dependency changes do.
If a job ends with a corrupted DerivedData cache (rare, but it happens), flowdeck clean as a recovery step takes you back to a known good state.
Step 08
Attach the full failure picture as artifacts
Steps 04, 05, and 06 each upload one artifact (build events, test results, app logs). The pattern works, but when a CI run fails, reviewers want all three at once. A single closing step that bundles every NDJSON file your job produced:
- name: Upload all FlowDeck artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: flowdeck-failure-$
path: |
build-events.ndjson
test-results.ndjson
app.log
if-no-files-found: ignore
The if-no-files-found: ignore matters: not every job produces every file (a build-only job has no test-results.ndjson). The bundle attaches whatever exists, named with the run ID so failed runs are easy to find in the artifact list later.
Reviewers download one zip, open one folder, see the build events, test pass/fail, and runtime logs side by side. No hunting across three separate artifact downloads to figure out what happened.
Common patterns from here
- Matrix builds across simulators.
- If you test against multiple iOS versions or device sizes, put the simulator name in a job matrix.
strategy.matrix.simulator: ["iPhone 16", "iPhone 16 Pro Max", "iPhone SE (3rd generation)"]. Each job runs the same script with a different-Sargument. - Pull request validation vs nightly.
- PR jobs should be fast: build + a smoke-test suite. Nightly jobs can be thorough: the full suite, multiple simulators, performance assertions. Same FlowDeck commands, different
--onlyfilters. - Self-hosted runners.
- Same pattern, with one tweak: install FlowDeck once on the runner image instead of per-job.
brew install flowdeckorcurl ... | shin the runner's setup. License activation can use an env var set on the runner, not a per-job secret. - Xcode Cloud.
- Xcode Cloud manages its own build pipeline; FlowDeck is overkill for the build step itself. The places FlowDeck still helps: pre-build scripts that need simulator setup, post-build scripts that parse output, custom test orchestration. Use the same install + activate pattern in
ci_pre_xcodebuild.shor equivalent. - Bitrise / CircleCI / GitLab.
- The shape is identical; only the YAML syntax changes. Install step, activate step, build step, test step, artifact upload. Caching has provider-specific syntax but the path is always
~/Library/Developer/Xcode/DerivedData.
When things go wrong
- "Simulator not found" on a runner that worked yesterday
- The runner image rotated its iOS simulator inventory.
flowdeck simulator list --json --available-onlyat the start of the job shows what's actually there. Switch to a simulator that exists, or install the runtime explicitly:flowdeck simulator runtime install iOS 18.0. - License activation fails in CI
- Most common cause: the secret isn't being passed (typo in the variable name, secret not set on the workflow). Run
flowdeck license statusas a diagnostic; the output reports the activation state and the reason if it failed. Treat license errors as fatal early; don't proceed to build with an unlicensed CLI. - Build is faster locally than in CI
- Almost always cold DerivedData. The cache-hit ratio is the metric to watch. If the cache key churns on every run, the cache isn't helping. Loosen the
hashFilesglob until you get hits on PR branches. - Tests pass locally but flake in CI
- Three patterns. First, animations: tests assume a fast animation duration that's slower on a CPU-constrained CI runner. Use FlowDeck's
waitprimitives with explicit timeouts instead of fixedsleepcalls. Second, simulator state: ensure the simulator is freshly erased between runs (flowdeck simulator erase <udid>at job start). Third, timezone or locale: CI runners might be UTC; force a locale for deterministic test data. - App logs are empty
- The build configuration in CI is Release, which suppresses the default logging filter. Force Debug:
--configuration Debug. If you genuinely need Release builds in CI, FlowDeck still captures the output but at warning/error level only; pass--jsonto see everything. - CI step hangs
- Almost always a streaming command (
flowdeck logsorflowdeck run --log) that wasn't backgrounded or stopped explicitly. Pattern: run it with&to background, thenflowdeck stop --allin cleanup to terminate. Don't rely on the CI step timeout, it'll leave the simulator in a weird state for the next job.
Annex
How this compares to a raw xcodebuild CI job
For reference, the same CI steps written against Apple's tools directly and against FlowDeck.
| CI step | Raw xcodebuild |
FlowDeck |
|---|---|---|
| Build for simulator | xcodebuild build -workspace ... -scheme ... -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' |
flowdeck build -S "iPhone 16" |
| Test output for the parser | Pipe through xcbeautify or regex; parse XCResult bundle |
flowdeck test --json emits NDJSON events live |
| Capture app logs on failure | Two terminals: simctl launch + simctl spawn log stream with a predicate |
flowdeck run --log > app.log 2>&1 & |
| Install a missing iOS runtime | xcrun simctl runtime add <image-path> (find the image first) |
flowdeck simulator runtime install iOS 18.0 |
| Cache DerivedData | Cache ~/Library/Developer/Xcode/DerivedData |
Same path; FlowDeck honors Xcode's layout |
Most of the CI surface looks identical because FlowDeck calls xcodebuild underneath. The wins are scheme/simulator targeting, structured output, and per-app logs. See FlowDeck vs xcodebuild for the full breakdown.
Read next
Further reading
- Wrap xcodebuild with structured output, if you still have raw xcodebuild scripts to translate.
- Basic CLI commands, every command available to a CI job.
- FlowDeck vs xcodebuild, why the wrapped CLI is more reliable in CI than raw xcodebuild.
- Full CLI reference, in the FlowDeck docs.