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.
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.
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.
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):
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):
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:
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.
Run the build
The build step is one command. Pass --json so failures produce structured output a CI parser can read:
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.
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):
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:
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:
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.
Cache DerivedData
The single biggest CI time win for iOS jobs. FlowDeck respects Xcode's DerivedData layout, so caching is straightforward:
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.
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:
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 -S argument.
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 --only filters.
Self-hosted runners.
Same pattern, with one tweak: install FlowDeck once on the runner image instead of per-job. brew install flowdeck or curl ... | sh in 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.sh or 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.
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 | FlowDeck |
|---|---|---|
Build for simulator |
|
|
Test output for the parser | Pipe through |
|
Capture app logs on failure | Two terminals: |
|
Install a missing iOS runtime |
|
|
Cache DerivedData | Cache | 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.
When things go wrong
“Simulator not found” on a runner that worked yesterday
License activation fails in CI
Build is faster locally than in CI
Tests pass locally but flake in CI
App logs are empty
CI step hangs
