CI/CD for iOS Apps with GitHub Actions
iOS builds need Xcode, and Xcode needs a macOS runner -- here is a clean pipeline that gets it right.
Continuous integration for an iOS app means compiling with Xcode, running your XCTest suite, and producing a build you can archive. Apple toolchains only run on macOS, so the build job must target a macOS runner. This recipe gives you a working workflow plus the cost trade-offs that matter for Apple builds.
What the pipeline does
- Checks out the repository on a macOS runner.
- Selects a specific Xcode version for reproducibility.
- Runs the unit-test suite with xcodebuild against a simulator.
- Builds the app to confirm a release configuration compiles.
The workflow
Pin the Xcode version explicitly so a runner image update does not silently change your toolchain.
name: iOS CI
on: [push, pull_request]
jobs:
build-test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Run unit tests
run: |
xcodebuild test \
-scheme MyApp \
-destination "platform=iOS Simulator,name=iPhone 15,OS=latest" \
CODE_SIGNING_ALLOWED=NO
- name: Build release
run: |
xcodebuild build \
-scheme MyApp \
-configuration Release \
-destination "generic/platform=iOS" \
CODE_SIGNING_ALLOWED=NONotes for this platform
macOS runners are the most expensive class on every CI provider, and GitHub-hosted macOS minutes bill at roughly ten times the Linux rate. Keep the macOS job tight -- only the steps that genuinely need Xcode -- and run linting, formatting, and dependency checks on a cheaper Linux job. On managed runners, transient simulator boot failures and flaky network installs are retried automatically instead of failing the whole run, which keeps Apple-toolchain pipelines from wasting costly macOS minutes on infrastructure noise.
Making this reliable in CI
- Pin every tool version. An unpinned toolchain turns a runner image update into a build break on unchanged code.
- Cache what is expensive to produce, and key the cache to the tool version so a restore across a version boundary cannot poison the build.
- Assert on the produced artifact rather than on the command succeeding.
- Set an explicit timeout so a hung step fails fast instead of consuming the whole job budget.
Key takeaways
- iOS builds require a macOS runner because Xcode does not run on Linux.
- Pin the Xcode version with xcode-select so runner image updates do not change your toolchain.
- Push non-Xcode work (lint, deps) to cheap Linux jobs to limit costly macOS minutes.