iOS CI automation with GitHub Actions

iOS CI automation with GitHub Actions

iOS CI automation with GitHub Actions

In about 10 minutes you’ll install FlowDeck, launch the interactive TUI (which walks you through trial activation on first run), open or scaffold a project, build and run it, and wire up AI agent skills. The whole loop, driven from the keyboard. No Xcode UI.

In about 10 minutes you’ll install FlowDeck, launch the interactive TUI (which walks you through trial activation on first run), open or scaffold a project, build and run it, and wire up AI agent skills. The whole loop, driven from the keyboard. No Xcode UI.

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-15 runners 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.

# 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
# 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
# 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.

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
# .github/workflows/ios.yml
- name: Install FlowDeck
  run: curl -sSL https://flowdeck.studio/install.sh | sh

- name: Verify FlowDeck
  run: flowdeck --version
# .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>
curl -sSL https://flowdeck.studio/install.sh | sh -s -- --version <tag>
curl -sSL https://flowdeck.studio/install.sh | sh -s -- --version <tag>

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"
- name: Activate FlowDeck license
  env:
    FLOWDECK_LICENSE_KEY: $
  run: flowdeck license activate "$FLOWDECK_LICENSE_KEY"
- 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.

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
- 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
- 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.

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
- 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
- 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
flowdeck test --only SmokeTests --skip SlowTests --json
flowdeck test --only SmokeTests --skip SlowTests --json

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
- 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
- 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.

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-$

- name: Cache DerivedData
  uses: actions/cache@v4
  with:
    path: ~/Library/Developer/Xcode/DerivedData
    key: derived-data-$-$
    restore-keys: |
      derived-data-$

- 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.

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
- 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
- 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 -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 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.

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