A deep, hands-on guide to GitLab CI/CD — applied to iOS apps. We'll work through the system end to end: writing .gitlab-ci.yml files, running tests on simulators, dealing with code signing (the part everyone dreads), wiring up Fastlane and match, deploying to TestFlight and the App Store, organizing pipelines with rules and DAGs, managing secrets, and the patterns that hold up in production. iOS-specific examples throughout, with attention to the things that bite you in real apps.
iOS CI/CD has sharp edges that other platforms don't. You need macOS to build iOS apps — there's no escaping it. Code signing is its own dark art with certificates, provisioning profiles, and entitlements that all have to line up exactly. Xcode versions matter; a project that builds on Xcode 15.2 might fail on 15.3. Simulators are stateful and capricious. The whole pipeline is slower than web development pipelines because compilation is expensive and signing involves keychains.
GitLab CI/CD handles all of this competently — but you have to know what you're doing. This guide takes you from "I have a .gitlab-ci.yml file in my repo" to "I have a production pipeline that builds, tests, signs, and ships my app reliably."
If you've used GitHub Actions or CircleCI, much of this will feel familiar in shape. The iOS-specific concerns — code signing, simulator management, Xcode handling — apply equally to other CI systems. You can transfer most of what you learn here to other platforms.
If you've never set up CI/CD before, expect to feel slow at first. There's a lot of YAML, a lot of mysterious failures, a lot of "why isn't my certificate being recognized." That's normal. Once you have a pipeline that works, you understand it, and small changes become routine.
.gitlab-ci.ymlGitLab CI/CD is GitLab's built-in continuous integration and delivery system. You write a .gitlab-ci.yml file at the root of your repository describing what should happen when code is pushed — run tests, build artifacts, deploy. GitLab's runners pick up the work, execute it, and report results back through the GitLab UI. Push commit, see green check (or red X), repeat.
The mental model has three pieces:
.gitlab-ci.yml.When you push to GitLab, it parses your .gitlab-ci.yml, generates a pipeline, queues the jobs, and assigns them to runners. The runners execute, stream logs back to GitLab, and report success or failure.
You can build, test, and ship an iOS app entirely from your laptop. For solo work on small apps, that's fine. But teams hit pain quickly without CI:
A working CI/CD pipeline removes most of this friction. Tests run on every commit. Builds are reproducible. Deployments take one click (or are fully automatic). Code signing is centralized and scripted.
If you're already on GitLab (or considering it), the CI/CD is built in — no separate tool, no separate billing, configured in the same repo. The integration is tight: pipeline status appears next to commits, merge request pipelines block bad code from merging, environments track deployments.
Comparing to alternatives at a high level:
GitLab CI/CD is a good choice when:
It's less of a fit when:
For most iOS teams using GitLab, the built-in CI is the right answer.
iOS CI/CD has constraints other platforms don't:
These are facts of life for iOS CI/CD. Once you accept them, the patterns make sense.
By the end:
We'll start small — a "build and run tests" pipeline — and grow into something production-ready.
GitLab CI/CD is GitLab's built-in CI: pipelines defined by .gitlab-ci.yml, executed by runners. iOS specifically requires macOS runners. The tool is well-suited if you're already on GitLab and want one integrated system. iOS CI/CD has constraints — macOS hardware, code signing, simulator state, slow builds — that you'll work around throughout this guide.
.gitlab-ci.ymlThe file is YAML, lives at the root of your repository, and is named exactly .gitlab-ci.yml. GitLab parses it on every push, generates a pipeline, and runs jobs accordingly. Understanding its structure is the foundation for everything else.
A valid .gitlab-ci.yml has at least one job:
build:
script:
- echo "Hello from CI"
That's it. build is the job name; script is what runs. On any push, this pipeline executes one job that prints a message.
In practice, you'll always do more — define stages, declare dependencies, set tags. But the skeleton is small.
A few keys are global — defined at the top level, not inside a job:
stages:
- test
- build
- deploy
variables:
XCODE_VERSION: "15.2"
default:
tags:
- macos
before_script:
- echo "Starting job on $(hostname)"
build_ios:
stage: build
script:
- xcodebuild build -scheme MyApp
stages declares the sequential phases. Jobs in the same stage run in parallel; later stages wait for earlier ones to complete.variables defines environment variables available to all jobs.default sets defaults that all jobs inherit (tags, image, before_script, etc.).include brings in YAML from other files (covered in section 25).workflow controls when the pipeline runs at all.Then jobs are top-level keys whose values are job definitions.
Inside a job, common keys:
test_unit:
stage: test
tags:
- macos
variables:
SCHEME: "MyApp"
before_script:
- bundle install
script:
- bundle exec fastlane test
after_script:
- echo "Job complete"
artifacts:
when: always
paths:
- fastlane/test_output/
reports:
junit: fastlane/test_output/report.junit
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- vendor/bundle
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
needs:
- job: lint
optional: true
allow_failure: false
retry:
max: 2
when: runner_system_failure
Heavy. Most jobs don't use all of these. The keys you'll use most:
stage — which stage the job belongs to. If omitted, defaults to test.tags — which runners can pick up this job. For iOS, almost always macos or similar.script — the shell commands to run. The heart of the job.before_script / after_script — commands run before / after script. After-scripts run even if script fails.variables — variables specific to this job.artifacts — files to save, available for download or for later jobs.cache — files to cache between pipelines for speedup.rules — when this job runs (covered in section 19).needs — dependencies for DAG pipelines (covered in section 26).A few YAML quirks that catch people:
XCODE_VERSION: 15.2 parses as a number; XCODE_VERSION: "15.2" parses as a string. For version strings, quote them.true and false are booleans. yes, no, on, off are also booleans (legacy YAML). Quote when you mean strings.&anchor and *alias for reuse:.build_template: &build_template
stage: build
tags: [macos]
build_debug:
<<: *build_template
script:
- xcodebuild -configuration Debug
Useful but easily abused.script block. It's a list. Each entry is a shell command. Multi-line commands need explicit YAML block syntax:script:
- |
echo "First line"
echo "Second line"
echo "All in the same shell process"
GitLab has a CI Lint tool to validate your .gitlab-ci.yml syntax. In the GitLab UI: CI/CD → Pipelines → CI Lint. Paste your file, click "Validate" — get pass/fail and helpful error messages.
This catches typos, indentation issues, and structural errors without needing to push and wait.
For more thorough testing, see section 30 on debugging.
A modest starting point for an iOS app:
stages:
- lint
- test
- build
variables:
LC_ALL: "en_US.UTF-8"
LANG: "en_US.UTF-8"
default:
tags:
- macos
before_script:
- bundle install --path vendor/bundle
lint:
stage: lint
script:
- bundle exec fastlane lint
test:
stage: test
script:
- bundle exec fastlane test
artifacts:
when: always
reports:
junit: fastlane/test_output/report.junit
build:
stage: build
script:
- bundle exec fastlane build
artifacts:
paths:
- build/MyApp.ipa
Three stages, three jobs, all on macOS runners. Tests produce a JUnit report; build produces an IPA. We'll evolve from here.
.gitlab-ci.yml must be at the repository root. You can change the path in project settings if you have an unusual setup, but the default is what 99% of projects use.
If you need to break the file into pieces (it gets long), use include: to pull in other files (covered in section 25).
After a pipeline runs, the GitLab UI shows it visually:
This visualization is very useful for understanding what's happening. After your first pipeline, spend time clicking around the UI — you'll absorb the model faster than reading docs.
Forgetting to commit .gitlab-ci.yml. If it's not in the repo, GitLab doesn't run anything. Trivial to miss when copying example files.
Wrong indentation. YAML's whitespace sensitivity is unforgiving. CI Lint catches most issues; test before pushing.
Job names collide with reserved keys. Don't name a job stages, variables, default, etc. — these are top-level keys.
Shell quoting issues. Multi-line scripts can run into shell quoting nightmares. Test commands locally first.
Pipelines that depend on file paths from your local machine. Hardcoding /Users/yourname/... won't work on the runner. Use relative paths or pre-defined variables.
.gitlab-ci.yml lives at repo root. Top-level keys: stages, variables, default, include, workflow. Other top-level keys are job names. Each job has stage, script, tags, optionally artifacts, cache, rules, etc. YAML is whitespace-sensitive. Use CI Lint to validate before pushing. Build your understanding incrementally — start with a few jobs, watch them run, expand from there.
GitLab CI/CD runs your jobs on runners — machines (or containers) that pick up jobs and execute them. For iOS specifically, you need macOS runners because iOS only builds on macOS. You have two options: GitLab's SaaS macOS runners or self-hosted ones. The choice has cost, performance, and operational implications.
A runner is a process (the gitlab-runner binary) running on a machine, configured to talk to a GitLab instance. When jobs are queued, runners poll for work, pick up jobs that match their tags and capabilities, run them, and report back.
Runner types:
You'll mostly think in terms of "where does my macOS runner come from?" — GitLab's SaaS, a Mac in your office, a Mac in a data center, a CI service that rents Macs.
GitLab.com offers macOS runners as a managed service. You don't own hardware; you pay for build minutes.
The pitch:
The downsides:
Pricing and availability change. Check GitLab's current pricing page for SaaS macOS runners before committing — they've gone through several iterations of plans and quotas.
You provide the macOS hardware (a Mac mini, a Mac Studio, an iMac in the office) and install gitlab-runner on it. The runner registers with your GitLab instance and picks up jobs.
The pitch:
The downsides:
For most small-to-medium iOS teams, self-hosted is more economical and gives more control.
A common pattern: self-hosted for everyday CI (fast feedback on every push), SaaS for less frequent or burst loads (releases, parallel testing).
You configure jobs to prefer your self-hosted runner via tags, and use SaaS runners (with different tags) for specific jobs. Best of both: cheap baseline + scale when needed.
A middle ground: rent a dedicated Mac in a data center.
These give you self-hosted flexibility without owning hardware, but with a different cost model than buying a Mac.
For most teams, the choice comes down to: a Mac mini at the office (cheapest if you can manage it), or SaaS (simplest if you can pay).
Regardless of how you provision runners, you assign tags to control which runners pick up which jobs.
Common tags for iOS:
macos — generic "this needs macOS"xcode-15 — needs Xcode 15.x specificallym1 or apple-silicon — Apple Silicon-specificintel — Intel Macios-runner — your team's specific runnerIn your .gitlab-ci.yml:
build:
tags:
- macos
script:
- xcodebuild build
Only runners tagged macos will pick up this job. If no matching runner is available, the job sits in "pending" until one becomes available (or times out).
One runner can be configured to run multiple concurrent jobs. For a self-hosted Mac mini:
# /usr/local/etc/gitlab-runner/config.toml
concurrent = 2
[[runners]]
name = "my-mac-mini"
url = "https://gitlab.com/"
token = "..."
executor = "shell"
[runners.shell]
concurrent = 2 means this runner can run up to 2 jobs simultaneously. For iOS builds (CPU-intensive, lots of disk), concurrent = 1 or 2 is realistic for a single Mac. More than 2 starts thrashing.
You can install multiple runners on the same Mac with different configurations — for example, one for builds (concurrent = 1, lots of disk cache) and one for fast lint jobs (concurrent = 4, minimal state).
For a team starting out, my recommendation:
gitlab-runner installed. M2 or M4 silicon, 16-32 GB RAM, 1 TB disk. ~$1000-1500.macos and target it for all iOS jobs.Once you outgrow that (concurrent builds blocked, Mac is busy), add a second Mac or switch to SaaS for overflow.
For larger teams or organizations with stricter physical-security or scaling needs, the calculus shifts toward SaaS or cloud providers. But starting with a Mac mini is hard to beat economically.
Misconfigured tags. Jobs targeting macos won't run on a runner tagged mac-os-x. Tag carefully.
No matching runner available. Jobs hang in pending. Check that your runner is online and tag-matching.
Runner left in a weird state. Mac mini ran out of disk, simulator wedged, keychain locked. Self-hosted runners need care — schedule cleanup tasks.
SaaS minute exhaustion. You hit the monthly limit; pipelines stop until next month or upgrade. Monitor usage.
Different Xcode versions across runners. You have two Macs with Xcode 15.2 and 15.3. Builds pass on one, fail on the other. Pin Xcode version explicitly in jobs.
Runner version drift. Outdated gitlab-runner may have bugs or miss features. Update periodically.
Runners execute your jobs. iOS needs macOS runners — either GitLab's SaaS service (paid per minute) or self-hosted (you own the Mac). For most small teams, a Mac mini in the office is most economical. Tag runners and jobs to match. Configure concurrency carefully. Hybrid setups (self-hosted + SaaS overflow) work well. Cloud-hosted Macs are a middle ground.
If you've decided to run your own macOS runner, this section walks through the setup. It's straightforward but has a handful of steps that, if skipped, leave you with a runner that doesn't quite work.
You need:
xcode-select --install).gitlab-runner or ci user, but for getting started, your own account works.For a dedicated CI Mac, I recommend creating a runner user account with admin privileges. This separates CI activity from your daily work and limits blast radius if something goes wrong.
The simplest install is via Homebrew:
brew install gitlab-runner
This installs the binary at /usr/local/bin/gitlab-runner (Intel Macs) or /opt/homebrew/bin/gitlab-runner (Apple Silicon). Confirm:
gitlab-runner --version
You should see version info.
Get a registration token from GitLab:
macos, xcode-15), description, etc.Then on the Mac:
gitlab-runner register
You'll be prompted interactively:
Enter the GitLab instance URL: https://gitlab.com/
Enter the registration token: glrt-xxxxxxxxxxxx
Enter a description for the runner: Mac Mini Office CI
Enter tags for the runner: macos,xcode-15
Enter optional maintenance note: ""
Enter an executor: shell
Key choices:
shell for iOS. Other options (docker, docker-machine, kubernetes, parallels, virtualbox) don't work for iOS or are irrelevant. The shell executor runs jobs directly on the host..gitlab-ci.yml.After registration, gitlab-runner writes config to /usr/local/etc/gitlab-runner/config.toml (or /opt/homebrew/etc/gitlab-runner/config.toml).
config.toml looks like:
concurrent = 1
check_interval = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "Mac Mini Office CI"
url = "https://gitlab.com/"
id = 12345
token = "glrt-xxxxxxxxxxxx"
token_obtained_at = 2024-01-15T10:00:00Z
token_expires_at = 0001-01-01T00:00:00Z
executor = "shell"
[runners.cache]
MaxUploadedArchiveSize = 0
[runners.shell]
Useful tweaks:
concurrent — number of jobs this runner can run simultaneously. For iOS builds, start with 1; increase to 2 only if your Mac has sufficient cores, RAM, and disk.check_interval — how often the runner polls GitLab. Default 0 means GitLab decides; 3-5 seconds is reasonable for active runners.For a Mac mini with 32 GB RAM running iOS builds, concurrent = 2 is achievable. More than that risks running out of memory or thrashing disk.
For interactive testing:
gitlab-runner run
This runs in the foreground. Logs print to terminal. Press Ctrl-C to stop.
For production use, install as a service:
gitlab-runner install --user $(whoami)
gitlab-runner start
Wait — but Homebrew's gitlab-runner already integrates with launchd. The simpler approach with Homebrew:
brew services start gitlab-runner
This makes gitlab-runner start at boot and run as a launchd service.
To check status:
brew services list | grep gitlab-runner
To stop:
brew services stop gitlab-runner
To restart (after config changes):
brew services restart gitlab-runner
This matters. The runner needs access to:
If gitlab-runner runs as root, none of these things are in its environment. Builds fail with confusing errors.
The Homebrew install runs the launchd agent as the user who installed it. If you installed as runner, it runs as runner. Confirm with:
ps aux | grep gitlab-runner
You should see gitlab-runner run running as the expected user.
If it's running as the wrong user, unload and reload the launchd agent under the correct one. Apple's launchctl semantics are finicky; following Homebrew's documented service commands is the easiest path.
iOS builds, especially anything involving simulators, need the user to be logged in to a graphical session. If your Mac reboots and sits at the login screen, the runner can pick up jobs but simulators may fail to boot (no WindowServer, no graphics).
Configure auto-login:
caffeinate -dimsu to keep it awake.For a Mac mini that exists only to run CI, full auto-login + no sleep is appropriate. For a shared workstation, decide based on your security posture.
Xcode is huge. Builds produce derived data measured in gigabytes per project. Cached SPM packages, CocoaPods, Fastlane state — all add up. A 256 GB SSD fills up fast.
Recommendations:
rm -rf ~/Library/Developer/Xcode/DerivedData/* between major version changes.A simple cleanup script you can run weekly via cron or launchd:
#!/bin/bash
# Cleanup CI machine
# Remove old derived data (older than 7 days)
find ~/Library/Developer/Xcode/DerivedData -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -rf {} \;
# Remove old archive (older than 30 days)
find ~/Library/Developer/Xcode/Archives -mindepth 1 -maxdepth 1 -mtime +30 -exec rm -rf {} \;
# Reset old simulators (more than 30 days unused)
xcrun simctl delete unavailable
# Show disk usage
df -h /
brew upgrade gitlab-runner
brew services restart gitlab-runner
Update periodically. Versions of gitlab-runner and your GitLab instance should be compatible — usually any recent runner works with any recent GitLab, but check release notes for compatibility.
When you update Xcode, point command-line tools at the new version:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
xcodebuild -version # confirm
For multiple Xcode versions side-by-side, name them clearly (Xcode15.2.app, Xcode15.3.app) and switch via xcode-select per job (covered later).
After registration, push a trivial commit. In GitLab UI: CI/CD → Pipelines. You should see a pipeline kick off. If the runner picks it up, status changes to "running"; otherwise it sits "pending."
If pending, check:
If running but failing, check the job log for clues. Most early failures are about missing tools (bundle: command not found), wrong paths, or signing issues — covered in later sections.
Runner runs as root. Doesn't have access to user keychain, Xcode prefs, etc. Run as a regular user.
No auto-login. Simulators fail because no graphical session. Configure auto-login.
Disk fills up. Schedule cleanup; monitor.
Xcode not in expected location. If you have multiple Xcode versions, jobs may pick the wrong one. Use xcode-select or DEVELOPER_DIR env var.
Forgetting to restart the runner after config changes. config.toml changes don't take effect until restart.
No internet access from the Mac. Runner registers fine but can't pull dependencies. Check firewall, DNS, proxy settings.
Runner machine sleeps. Pipelines hang. Disable sleep, or use caffeinate.
Install gitlab-runner via Homebrew. Register with GitLab using a token. Use the shell executor for iOS. Run as a regular user (not root). Enable auto-login for simulator support. Have at least 1 TB disk. Monitor disk space; clean up periodically. Restart the runner after config changes.
With a runner ready, let's build the simplest useful pipeline: build the app, run unit tests, see results in GitLab. Everything that comes later builds on this foundation.
Assume an iOS app with this structure:
MyApp/
├── MyApp.xcworkspace
├── MyApp.xcodeproj
├── MyApp/
│ └── (Swift files)
├── MyAppTests/
│ └── (test files)
├── Podfile (if using CocoaPods)
└── Package.resolved (if using SPM)
The scheme is MyApp (matching the target).
Create .gitlab-ci.yml at the repo root:
stages:
- test
variables:
LC_ALL: "en_US.UTF-8"
LANG: "en_US.UTF-8"
default:
tags:
- macos
build_and_test:
stage: test
script:
- xcodebuild -version
- xcodebuild
-workspace MyApp.xcworkspace
-scheme MyApp
-destination 'platform=iOS Simulator,name=iPhone 15,OS=latest'
-configuration Debug
clean test
| xcpretty
Push it. GitLab picks up the file, queues the pipeline, the runner picks up the job. In the UI, watch the log stream as Xcode does its thing.
Breaking down the xcodebuild command:
-workspace MyApp.xcworkspace — use the workspace (always preferred over -project if you have one, especially with CocoaPods or local SPM packages).-scheme MyApp — which scheme to build/test.-destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' — the simulator to use. latest picks whatever OS is latest on the runner.-configuration Debug — build configuration. Tests usually run in Debug.clean test — clean first, then test.| xcpretty — pipe output through xcpretty for readable logs. Without this, xcodebuild output is overwhelming.xcpretty is a Ruby gem that formats Xcode output. xcbeautify is a Swift-based modern alternative — usually faster and more accurate.
For xcbeautify (recommended):
brew install xcbeautify
Then in your pipeline:
script:
- xcodebuild ... | xcbeautify
For xcpretty:
gem install xcpretty
Either works; xcbeautify is faster and handles Swift errors better.
Bash by default returns the exit code of the last command in a pipe. So:
xcodebuild ... | xcbeautify
If xcodebuild fails but xcbeautify succeeds, the pipeline reports success. Bug.
Fix by enabling pipefail:
build_and_test:
script:
- set -o pipefail && xcodebuild ... | xcbeautify
Or globally for the job:
build_and_test:
before_script:
- set -o pipefail
script:
- xcodebuild ... | xcbeautify
(Note: each script entry runs in a separate subshell by default in shell executor. Set pipefail per-line, or use bash -c for blocks.)
-destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' works if an iPhone 15 simulator exists. If not, xcodebuild errors:
Available destinations for the "MyApp" scheme:
...
xcodebuild: error: Unable to find a destination matching the provided destination specifier
To list available simulators:
xcrun simctl list devices
Or:
xcrun xctrace list devices
For more flexibility, pick a destination by ID:
xcodebuild -destination 'id=ABCDEF12-3456-7890' ...
But IDs are machine-specific. For portability, use name= and ensure the simulator exists on every runner.
You can also use a generic destination:
xcodebuild -destination 'generic/platform=iOS Simulator' ...
Useful for builds that don't run on a specific device, but for tests you usually want a specific simulator.
If a needed simulator doesn't exist:
xcrun simctl create "iPhone 15 CI" "iPhone 15" "com.apple.CoreSimulator.SimRuntime.iOS-17-2"
Or:
before_script:
- |
if ! xcrun simctl list devices | grep "iPhone 15 (CI)"; then
xcrun simctl create "iPhone 15 CI" "iPhone 15"
fi
For most CI workflows, you provision the runner with the simulators you need in advance. Doing it dynamically is more fragile.
A successful pipeline log might look like:
Running with gitlab-runner 16.7.0
Using Shell executor...
Running on Mac-mini-Office...
$ xcodebuild -version
Xcode 15.2
Build version 15C500b
$ xcodebuild -workspace MyApp.xcworkspace -scheme MyApp ... clean test | xcbeautify
▸ Resolving Package Graph
▸ Building MyApp
▸ Compiling MyApp.swift
...
▸ Test Suite 'MyAppTests' started
▸ Test Case 'testExample' passed (0.012 seconds)
...
▸ ** TEST SUCCEEDED **
Job succeeded
Green check in GitLab; pipeline complete.
If a test fails:
▸ Test Case 'testExample' failed (0.034 seconds)
✗ MyAppTests.swift:42: error: -[MyAppTests testExample] : XCTAssertEqual failed: ("foo") is not equal to ("bar")
▸ ** TEST FAILED **
Red X in GitLab; job marked failed; pipeline fails (unless that job has allow_failure: true).
For better integration, generate JUnit XML and tell GitLab:
build_and_test:
script:
- set -o pipefail && xcodebuild ... | xcbeautify --report junit --report-path build/reports/
artifacts:
when: always
reports:
junit: build/reports/junit.xml
(Some xcbeautify versions need --report junit and a path; check current docs.)
The reports.junit artifact is parsed by GitLab and shown in the merge request UI: number of tests, pass/fail, links to specific failures. Without it, you just have raw logs.
For CocoaPods or SPM projects, you may need to integrate xcresulttool or use a tool like xcresult-to-junit to extract reports from .xcresult bundles. Section 9 covers this in detail.
For everything iOS, you'll want some setup. A typical before_script:
default:
before_script:
- bundle install --path vendor/bundle
- bundle exec pod install --repo-update # if using CocoaPods
This runs before every job's script. It installs Ruby gems (Fastlane, xcpretty, etc.) and pods.
For SPM-only projects, no before_script needed for dependencies — xcodebuild resolves the package graph itself.
Without caching, every build:
With caching:
We'll cover caching in section 11. For now, accept that the first pipeline is slow; subsequent ones get faster.
After your first pipeline runs:
.gitlab-ci.yml. Push.Iteration is key — you'll learn the system by changing things and seeing what happens. The CI Lint tool catches syntax errors before pushing, which speeds the cycle.
Wrong scheme. "Scheme not found" — the scheme isn't shared. In Xcode, Product → Scheme → Manage Schemes → check "Shared" for your scheme. Commit the resulting .xcscheme file in MyApp.xcodeproj/xcshareddata/xcschemes/.
Wrong workspace vs project. If you use CocoaPods or local SPM packages, you must use -workspace (not -project). Otherwise pods/packages aren't included.
Simulator doesn't exist. Pick a simulator name that's actually present, or create it dynamically.
Tests pass locally, fail in CI. Often a path issue (CI's working dir is different) or environment difference (locale, timezone, default values). Match locally and CI environments closely.
Pipefail not set. Tests fail but pipeline shows green. Always pipe through xcbeautify with pipefail enabled.
Output too verbose. Without xcpretty/xcbeautify, logs are gigantic and CI may truncate. Always format.
A first pipeline: a single job that runs xcodebuild test on a simulator. Use -workspace + -scheme. Pipe through xcbeautify (or xcpretty). Set pipefail so failures propagate. Generate JUnit reports and surface them via GitLab's reports.junit artifact. Iterate quickly — small changes, push, observe. Get this working before adding complexity.
We've used stages casually. This section goes deeper: the rules of pipeline flow, when jobs run in parallel, how stages interact with needs for DAG-style pipelines, and the patterns for organizing a real iOS pipeline.
Without any DAG configuration, pipeline flow is simple:
stages:
- lint
- test
- build
- deploy
This says: first run lint, then test, then build, then deploy. Standard.
stages:
- lint
- test
- build
default:
tags:
- macos
before_script:
- bundle install --path vendor/bundle
lint:
stage: lint
script:
- bundle exec fastlane lint
unit_tests:
stage: test
script:
- bundle exec fastlane test_unit
ui_tests:
stage: test
script:
- bundle exec fastlane test_ui
build:
stage: build
script:
- bundle exec fastlane build_dev
Flow:
If any stage fails, subsequent stages are skipped (by default).
If unit tests and UI tests are independent, running them in parallel halves their wall-clock time. A 5-minute unit test suite + 10-minute UI test suite = 10 minutes (max), not 15.
This is the basic optimization. More aggressive parallelism (DAG, sharding) builds on it.
Jobs in a stage can be blocked by:
pending.unit_tests fails and you have a build job in the next stage, build is skipped (default).A stage is "complete" when all its jobs reach a terminal state — success, failed, skipped, or canceled. Then the next stage can begin.
needs — break out of strict stage orderingSometimes you want job A in stage 2 to start as soon as a specific job in stage 1 succeeds, without waiting for the rest of stage 1.
stages:
- lint
- test
- build
lint:
stage: lint
script:
- bundle exec fastlane lint
unit_tests:
stage: test
script:
- bundle exec fastlane test_unit
ui_tests:
stage: test
script:
- bundle exec fastlane test_ui
build:
stage: build
needs:
- unit_tests # build can start as soon as unit_tests succeeds
script:
- bundle exec fastlane build_dev
Now build starts as soon as unit_tests succeeds — without waiting for ui_tests. If ui_tests is slower, build runs in parallel with the tail of UI testing.
This is the "DAG" model — directed acyclic graph of dependencies. We'll cover this fully in section 26. For now, know it exists.
allow_failureSometimes a job is non-blocking — it should run, and if it fails it's a problem, but it shouldn't stop the pipeline.
lint:
stage: lint
script:
- bundle exec fastlane lint
allow_failure: true
If lint fails, the pipeline continues. The job is marked with a warning (orange triangle) instead of red X. Useful for quality checks that you want to surface but not block on.
For tests and builds, don't use allow_failure: true — those failures should block.
when — control execution timingBy default, jobs run if their stage runs and they're triggered. The when keyword changes this:
deploy_staging:
stage: deploy
when: manual
script:
- bundle exec fastlane deploy_staging
when: manual means the job is created but doesn't run automatically — a user clicks "Play" in the UI to start it. Useful for deploys that should require explicit approval.
Other when values:
on_success (default) — run if all earlier stages succeeded.on_failure — run only if an earlier stage failed.always — always run, even if earlier failed.manual — wait for user trigger.delayed — wait a specified time.when: on_failure is useful for cleanup or notification jobs:
notify_failure:
stage: deploy
when: on_failure
script:
- ./scripts/notify-slack.sh "Pipeline failed!"
when: always for cleanup that must happen regardless:
cleanup:
stage: cleanup
when: always
script:
- rm -rf ./tmp
A common pattern: a cleanup or notify stage at the end, with jobs that always run.
stages:
- test
- build
- cleanup
cleanup:
stage: cleanup
when: always
script:
- ./scripts/cleanup.sh
The cleanup stage runs even if previous stages failed (because the job has when: always).
A larger but still manageable example:
stages:
- lint
- test
- build
- deploy
lint:
stage: lint
script: [bundle exec fastlane lint]
allow_failure: true
unit_tests:
stage: test
script: [bundle exec fastlane test_unit]
ui_tests:
stage: test
script: [bundle exec fastlane test_ui]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
build_dev:
stage: build
needs: [unit_tests]
script: [bundle exec fastlane build_dev]
build_prod:
stage: build
needs: [unit_tests, ui_tests]
script: [bundle exec fastlane build_prod]
rules:
- if: '$CI_COMMIT_TAG'
deploy_testflight:
stage: deploy
needs: [build_prod]
script: [bundle exec fastlane deploy_testflight]
rules:
- if: '$CI_COMMIT_TAG'
deploy_appstore:
stage: deploy
needs: [build_prod]
when: manual
script: [bundle exec fastlane deploy_appstore]
rules:
- if: '$CI_COMMIT_TAG'
This pipeline:
The GitLab UI shows pipelines as a DAG. With this config, on a tag pipeline:
lint → unit_tests → build_dev → (nothing, dev-only)
↘ ↘
ui_tests → build_prod → deploy_testflight
↘
deploy_appstore (manual)
Each box is a job; arrows are dependencies (from needs and stage order).
To keep the pipeline understandable, group related work in one stage. Don't have 20 stages. A typical good shape:
lint (1-2 jobs, fast)test (2-5 jobs, medium duration)build (1-3 jobs, slow)deploy (manual or tag-triggered)If you find yourself wanting 8 stages, you might be over-engineering — DAG with needs is usually a better way to express complex dependencies.
Retry policies for flaky jobs:
ui_tests:
retry:
max: 2
when: runner_system_failure
Retries the job up to 2 times if it fails due to runner system failure (not test failure). For genuinely flaky tests, fix the tests — don't paper over with retries. But for environmental issues (simulator boot timeout, network blip), retries help.
Retry is per-job; not retrying a whole stage.
script runs sequentiallyWithin one job, the script: list runs in sequence. Each command must succeed (exit 0) for the next to run; any non-zero exit stops the job.
script:
- command_a # if this fails, job stops
- command_b
- command_c
If command_a exits non-zero, command_b and command_c don't run. Job is marked failed.
You can override with || true:
script:
- command_a || true # ignore failure of command_a
- command_b
Use sparingly — usually you want failures to propagate.
Too many stages. Hard to follow. Refactor with DAG instead.
Implicit serialization. If two jobs could run in parallel but you put them in different stages, you serialize them unnecessarily. Move both to the same stage or use needs.
Forgetting allow_failure: true for non-blocking jobs. A failed job in a critical stage blocks subsequent stages. If you don't want it to, mark it.
when: manual without protection. Anyone with project access can click the manual button. For production deploys, combine with protected branches/environments (covered later).
Pipelines that depend on artifacts from other stages but don't declare it. Use dependencies (or needs) to make this explicit. Without it, jobs may not have the files they expect.
Stages run sequentially; jobs in a stage run in parallel. needs breaks out of strict stage ordering for DAG flows. allow_failure: true lets non-blocking jobs fail without halting the pipeline. when: manual requires user trigger; when: on_failure / when: always for failure-handling and cleanup jobs. Group related work in 4-6 stages; use needs for fine-grained dependencies.
GitLab CI/CD makes heavy use of variables — for branch names, commit hashes, secret values, configuration. Understanding the variable system is critical: where they come from, how to scope them, when they're masked or protected, and the precedence rules.
GitLab provides many predefined variables to every job. The most useful for iOS:
CI_COMMIT_SHA — the commit hash being built. da39a3ee5e6b4b0d3255bfef95601890afd80709CI_COMMIT_SHORT_SHA — first 8 chars: da39a3eeCI_COMMIT_REF_NAME — branch or tag name: main, release/1.2.3CI_COMMIT_BRANCH — branch name (only set when on a branch, not a tag)CI_COMMIT_TAG — tag name (only set when on a tag)CI_COMMIT_TITLE — the commit message subjectCI_COMMIT_MESSAGE — full commit messageCI_PIPELINE_ID — globally unique pipeline IDCI_PIPELINE_IID — per-project incremental pipeline number (1, 2, 3, ...)CI_JOB_ID — globally unique job IDCI_PROJECT_DIR — checkout directory inside the runnerCI_PROJECT_NAME — repo nameCI_PROJECT_PATH — full path including namespaceCI_DEFAULT_BRANCH — usually main or masterCI_PIPELINE_SOURCE — push, merge_request_event, schedule, web, pipeline, trigger, api, tagUse them throughout your pipeline:
variables:
BUILD_NUMBER: "$CI_PIPELINE_IID"
VERSION_TAG: "$CI_COMMIT_TAG"
build:
script:
- echo "Building $CI_PROJECT_NAME @ $CI_COMMIT_SHORT_SHA"
- bundle exec fastlane build build_number:$CI_PIPELINE_IID
$CI_PIPELINE_IID is the natural choice for build numbers — it's monotonically increasing per project.
In .gitlab-ci.yml:
variables:
XCODE_VERSION: "15.2"
SCHEME: "MyApp"
WORKSPACE: "MyApp.xcworkspace"
CONFIGURATION: "Debug"
build:
script:
- xcodebuild -workspace $WORKSPACE -scheme $SCHEME -configuration $CONFIGURATION
Top-level variables: apply to all jobs. Job-level variables: apply to that job only and override top-level.
variables:
CONFIGURATION: "Debug"
build_release:
variables:
CONFIGURATION: "Release" # override
script:
- xcodebuild ... -configuration $CONFIGURATION
In build_release, CONFIGURATION is Release. Other jobs see Debug.
For secrets (API keys, certificates, passwords), you don't put them in .gitlab-ci.yml. Define them in the UI: Settings → CI/CD → Variables → "Add variable."
UI-defined variables are passed to jobs as environment variables, just like top-level variables:. They can be:
[MASKED]).$OTHER_VAR are expanded.Use these for:
Mark a variable "Masked" in the UI to hide it in logs. If a job runs echo $SECRET_KEY, the log shows echo [MASKED] instead of the real value.
Masking has rules: the value must be at least 8 characters, base64-style. Random hex strings work. Plain English doesn't (because masking would be unreliable on common substrings).
For complex secrets that don't meet masking rules (e.g., multi-line PEM keys), use file-type variables instead — they're never echoed to logs.
A "Protected" variable is only exposed to jobs running on a protected branch or tag. Configure protected branches in Settings → Repository → Protected branches.
Why this matters: feature branches can run pipelines, but the secrets aren't available in those pipelines. Only main, release/* (if protected), and protected tags can read protected variables.
This protects against:
Always mark production secrets (App Store keys, signing certs) as Protected and only on protected branches.
For multi-line values like PEM keys or .p12 certificate exports:
APPLE_API_KEY_FILE) is set to a path, not the content.cat $APPLE_API_KEY_FILE.Example:
deploy:
script:
- bundle exec fastlane deploy api_key_path:$APPLE_API_KEY_FILE
Or, if a tool needs the literal content:
APPLE_API_KEY=$(cat $APPLE_API_KEY_FILE)
File-type variables aren't echoed in logs — even unmasked. They're the safest way to handle multi-line secrets.
When the same variable is defined in multiple places, this is the precedence (highest wins):
variables: in .gitlab-ci.ymlvariables: in .gitlab-ci.ymldefault: block variablesSo a UI variable can override a .gitlab-ci.yml value. This is intentional — you set defaults in code, override per-environment in UI.
Predefined variables can also be overridden, but generally shouldn't be — that way lies confusion.
Variables are usable in rules: and only/except::
deploy:
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $DEPLOY == "true"'
The $DEPLOY could come from a manual pipeline trigger:
curl -X POST -F "token=..." -F "ref=main" -F "variables[DEPLOY]=true" \
https://gitlab.com/api/v4/projects/123/trigger/pipeline
Or from a scheduled pipeline configured to set DEPLOY=true when it runs.
Variables defined at the top level are inherited by all jobs unless overridden. default: block can also set variables. Job-level overrides take precedence within that job.
variables:
GLOBAL: "global"
TO_OVERRIDE: "from-top"
default:
variables:
DEFAULT: "default"
job_a:
script:
- echo "$GLOBAL $DEFAULT $TO_OVERRIDE"
# Output: global default from-top
job_b:
variables:
TO_OVERRIDE: "from-job"
script:
- echo "$TO_OVERRIDE"
# Output: from-job
A typical iOS project's variables:
variables:
# Code config
XCODE_VERSION: "15.2"
SCHEME: "MyApp"
WORKSPACE: "MyApp.xcworkspace"
# Locale (often needed)
LC_ALL: "en_US.UTF-8"
LANG: "en_US.UTF-8"
# Bundle / Ruby config
BUNDLE_PATH: "vendor/bundle"
BUNDLE_DEPLOYMENT: "true"
# Build settings
DERIVED_DATA_PATH: "$CI_PROJECT_DIR/DerivedData"
UI-defined (Protected, often Masked or File-type):
MATCH_PASSWORD — passphrase for match repo encryptionMATCH_GIT_URL — git URL for cert repo (often includes a deploy token)APPLE_API_KEY_FILE — App Store Connect API key (file)APPLE_KEY_ID — key IDAPPLE_ISSUER_ID — issuer IDKEYCHAIN_PASSWORD — temp keychain passwordFASTLANE_USER — Apple ID for fastlane (if not using API key)FASTLANE_PASSWORD — Apple ID passwordSLACK_WEBHOOK_URL — for notificationsHardcoded secrets in .gitlab-ci.yml. Anyone with read access to the repo sees them. Always UI-define.
Forgetting to mark protected. Secrets accessible from feature branches. Mark Protected and require protected branches/tags.
Variables not expanding. GitLab expands $VAR and ${VAR} in most contexts but not in script: lines until shell execution. The runner's shell handles it.
Nested variable references. $VAR_$OTHER may not expand as you expect. Use shell concatenation: ${VAR_${OTHER}}.
File-type variables expected as content. They're paths. Read with cat if you need the content.
Mismatched protection scope. Variable protected, but branch isn't, so variable not available. Or branch protected but variable not, so feature branches can also use it. Match them.
GitLab provides many predefined variables (CI_COMMIT_SHA, CI_PIPELINE_IID, etc.). Define your own in .gitlab-ci.yml (top-level or per-job). Define secrets in the UI as Protected/Masked. Use File-type variables for multi-line secrets like PEM keys. Variable precedence: UI > .gitlab-ci.yml job > top-level. Use $VAR syntax in script: and rules:. Always Protected for production secrets, paired with protected branches.
xcodebuild is the command-line tool that does what Xcode does — build, test, archive, export. Mastering it (or at least knowing the moves) is required for iOS CI. This section covers the essentials.
Build:
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug build
Test:
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \
-configuration Debug test
Archive (for distribution):
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive archive
Export (after archive):
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/ \
-exportOptionsPlist ExportOptions.plist
These four cover most of what CI does.
-workspace vs -projectIf your project has a .xcworkspace, use it. Workspaces include multiple projects, CocoaPods integration, and SPM dependencies as siblings.
# CORRECT for a project with CocoaPods or local SPM packages
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp ...
# WRONG — misses pods/local packages
xcodebuild -project MyApp.xcodeproj -scheme MyApp ...
When in doubt, use the workspace.
-schemeSchemes describe what to build/test/run. Each scheme references a target, configuration, test plan, etc.
For schemes to be visible to xcodebuild, they must be shared. In Xcode: Product → Scheme → Manage Schemes → check the "Shared" box. This commits an .xcscheme file to MyApp.xcodeproj/xcshareddata/xcschemes/.
If your scheme isn't shared, xcodebuild errors:
xcodebuild: error: The workspace named "MyApp" does not contain a scheme named "MyApp"
List available schemes:
xcodebuild -workspace MyApp.xcworkspace -list
Output:
Information about workspace "MyApp":
Schemes:
MyApp
MyApp-Staging
MyAppTests
-destinationCritical for tests. Tells xcodebuild where to run.
For simulator:
-destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2'
-destination 'platform=iOS Simulator,name=iPhone 15,OS=latest'
-destination 'platform=iOS Simulator,id=ABCDEF12-3456-7890'
For physical device (rare in CI):
-destination 'platform=iOS,id=00008110-001C0A2A0E32801E'
Generic (for builds without device-specific targeting):
-destination 'generic/platform=iOS'
-destination 'generic/platform=iOS Simulator'
For builds that produce an archive, use generic/platform=iOS — this builds for any iOS device.
For tests, use a specific simulator destination.
-configurationBuild configuration: Debug or Release (or custom configurations you've defined).
xcodebuild ... -configuration Debug
xcodebuild ... -configuration Release
Debug for tests; Release for distribution.
-derivedDataPathWhere xcodebuild puts intermediate build outputs. Default is ~/Library/Developer/Xcode/DerivedData.
xcodebuild ... -derivedDataPath ./DerivedData
For CI:
$CI_PROJECT_DIR/DerivedData) keeps it inside the project directory — easier for cleanup, archiving, etc.A common pattern is to set it explicitly:
variables:
DERIVED_DATA: "$CI_PROJECT_DIR/DerivedData"
build:
script:
- xcodebuild ... -derivedDataPath $DERIVED_DATA
cache:
paths:
- DerivedData/
-resultBundlePathSaves an .xcresult bundle (Xcode's structured test results format).
xcodebuild ... -resultBundlePath build/MyApp.xcresult test
Useful for:
Always specify this for test jobs.
If the runner has multiple Xcode versions installed:
sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer
xcodebuild -version
Or in a single command via DEVELOPER_DIR:
DEVELOPER_DIR=/Applications/Xcode_15.2.app/Contents/Developer xcodebuild ...
In CI, set the version explicitly per job:
variables:
DEVELOPER_DIR: "/Applications/Xcode_15.2.app/Contents/Developer"
build:
script:
- xcodebuild -version
- xcodebuild ...
This ensures every build uses the intended Xcode version, even if the system default changes.
OTHER_SWIFT_FLAGS and GCC_PREPROCESSOR_DEFINITIONSTo pass build settings or flags via command line:
xcodebuild ... \
OTHER_SWIFT_FLAGS="-D CI_BUILD" \
SWIFT_OBJC_BRIDGING_HEADER="MyApp/Bridging.h"
Build settings override what's in the Xcode project. Useful for:
CI_BUILD to enable CI-specific code paths.-quiet and output verbosityxcodebuild is verbose. -quiet suppresses informational output but keeps warnings and errors.
xcodebuild ... -quiet
Combined with xcbeautify, you get a clean build log:
xcodebuild ... -quiet | xcbeautify
(Some prefer not using -quiet because it occasionally hides useful info; xcbeautify alone is usually clean enough.)
"Scheme not found" — scheme isn't shared. Mark it shared in Xcode and commit .xcscheme.
"No such workspace" — wrong path. Workspace files are folders in macOS but tools see them as paths.
"Unable to find a destination" — simulator doesn't exist. Run xcrun simctl list devices to see what's available.
"No matching profiles found" — code signing issue. See section 13.
"The operation couldn't be completed. Permission denied" — keychain isn't accessible. See section 13.
"Could not delete the previous archive at path" — leftover state from a prior build. Clean before archive.
"warning: skipping target X because deployment target Y is too low" — your minimum iOS version is older than something requires. Update.
You can build without testing (faster):
xcodebuild ... build
Or build and test in one shot:
xcodebuild ... build test
Or test without rebuilding (if compiled artifacts are cached):
xcodebuild ... test-without-building
Useful if a previous job already built and saved artifacts:
build:
stage: build
script:
- xcodebuild ... build-for-testing
artifacts:
paths:
- DerivedData/Build/Products/
test:
stage: test
needs: [build]
script:
- xcodebuild ... test-without-building
The build job builds; the test job runs tests against the pre-built products. Saves time if you have multiple test jobs that share a build.
xcrunxcrun finds and runs Xcode tools. Often you don't call it directly — xcodebuild is itself an xcrun-style tool. But some tools require it:
xcrun simctl list # list simulators
xcrun simctl boot "iPhone 15" # boot a simulator
xcrun simctl shutdown all # shut down all simulators
xcrun simctl erase all # erase all simulators
xcrun xcresulttool get --path build.xcresult --format json
simctl is the simulator control tool. xcresulttool extracts data from .xcresult bundles.
xcbeautify:
brew install xcbeautify
xcodebuild ... | xcbeautify
Variants and reporting:
xcodebuild ... | xcbeautify --is-ci # CI-friendly, no color
xcodebuild ... | xcbeautify --report junit --report-path build/ # generate JUnit
Older alternative xcpretty:
gem install xcpretty
xcodebuild ... | xcpretty
xcodebuild ... | xcpretty --report junit # generates build/reports/junit.xml
For CI, prefer xcbeautify (faster, more accurate, no Ruby dependency). For Fastlane integration, both work.
Wrong -workspace. Using -project when you have a workspace omits dependencies.
Using -quiet and missing errors. -quiet hides info but xcbeautify's filter sometimes hides warnings. If a build mysteriously doesn't compile something you expected, retry without -quiet or formatting.
-destination not matching available simulators. Hardcoded names break when simulators are renamed.
Forgetting -resultBundlePath. No structured test data, no JUnit reports, no coverage.
Not pinning Xcode version. Builds break when runner updates Xcode.
Leaving DerivedData uncached. Every build is a clean build; slow.
xcodebuild does it all: build, test, archive, export. Always use -workspace + -scheme for projects with dependencies. -destination for simulator targeting. -configuration Debug for tests, Release for distribution. -derivedDataPath for explicit paths. -resultBundlePath for structured test output. Pin Xcode via DEVELOPER_DIR. Pipe through xcbeautify for readable logs. Generate JUnit XML for GitLab integration.
Unit tests are the fastest, most reliable kind of tests to run in CI. They don't need a graphical simulator, they don't depend on UI rendering, they're deterministic. Setting them up well makes the rest of your pipeline easier.
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \
-configuration Debug \
-resultBundlePath build/test-results.xcresult \
| xcbeautify
xcodebuild test runs whatever tests are configured in the scheme's "Test" action. By default, this includes all test targets in the scheme's "Test" action.
Xcode 11+ introduced test plans — a way to bundle test targets, configurations, and parameters. A .xctestplan file:
{
"configurations": [
{
"id": "1",
"name": "Configuration 1",
"options": {
"language": "en",
"region": "US"
}
}
],
"defaultOptions": {
"codeCoverage": true,
"diagnosticCollectionPolicy": "Never"
},
"testTargets": [
{
"target": {
"containerPath": "container:MyApp.xcodeproj",
"identifier": "...",
"name": "MyAppTests"
}
}
],
"version": 1
}
Test plans let you:
To use a test plan:
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-testPlan UnitTests \
-destination ...
To run only specific tests:
xcodebuild test -only-testing:MyAppTests/UserTests
xcodebuild test -only-testing:MyAppTests/UserTests/testValidation
To skip:
xcodebuild test -skip-testing:MyAppUITests
Useful for splitting unit and UI tests:
unit_tests:
script:
- xcodebuild test -only-testing:MyAppTests ...
ui_tests:
script:
- xcodebuild test -only-testing:MyAppUITests ...
Each runs only the relevant tests. Faster, more parallelizable.
GitLab understands JUnit XML. Several ways to produce it:
Via xcbeautify:
set -o pipefail && xcodebuild test ... | xcbeautify --report junit --report-path build/reports/
This writes build/reports/junit.xml.
Via xcresulttool (more reliable):
After tests, extract from the .xcresult bundle:
xcrun xcresulttool get --path build/test-results.xcresult --format json > build/results.json
# Then convert to JUnit (third-party tools or scripts)
There are tools like trainer that convert .xcresult to JUnit:
gem install trainer
trainer -p build/test-results.xcresult -o build/reports/
Via Fastlane:
# Fastfile
lane :test do
scan(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
output_types: "junit",
output_directory: "build/reports/"
)
end
scan (Fastlane's test action) handles JUnit generation automatically.
Pick whichever approach fits your stack. Fastlane is most consistent if you use Fastlane elsewhere.
Once you have the JUnit XML, tell GitLab:
unit_tests:
script:
- bundle exec fastlane test_unit
artifacts:
when: always
paths:
- build/
reports:
junit: build/reports/report.junit
expire_in: 1 week
reports.junit parses the XML and shows test results in:
when: always ensures artifacts are saved even if tests fail (so you can see what failed).
default_platform(:ios)
platform :ios do
desc "Run unit tests"
lane :test_unit do
scan(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Debug",
device: "iPhone 15",
only_testing: ["MyAppTests"],
output_directory: "build/reports/",
output_types: "junit",
output_files: "junit.xml",
result_bundle: true,
derived_data_path: "DerivedData"
)
end
end
In .gitlab-ci.yml:
unit_tests:
stage: test
script:
- bundle exec fastlane test_unit
artifacts:
when: always
reports:
junit: build/reports/junit.xml
paths:
- build/
expire_in: 1 week
Concise, with sane defaults.
Enable coverage in your scheme or test plan. Then extract:
xcrun xccov view --report --json build/test-results.xcresult > coverage.json
For the GitLab coverage display, use a regex:
unit_tests:
script:
- bundle exec fastlane test_unit
- xcrun xccov view --report build/test-results.xcresult
coverage: '/Test Coverage: \d+\.\d+%/'
GitLab parses the regex from log output and shows the coverage percentage on the job, pipeline, and merge request.
For the visualization (red/green file-by-file in MRs), generate a Cobertura-format XML and configure:
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.cobertura.xml
Section 24 covers coverage in depth.
For maximum reliability, boot the target simulator explicitly before xcodebuild test:
unit_tests:
before_script:
- xcrun simctl boot "iPhone 15" || true # ignore if already booted
script:
- xcodebuild test ...
after_script:
- xcrun simctl shutdown all
Not always necessary — xcodebuild can boot simulators itself — but useful for some CI setups where the simulator state is unpredictable.
Xcode supports running tests in parallel within a single test bundle:
In the scheme: Edit Scheme → Test → Tests tab → Options → "Execute in parallel on Simulator."
Or via test plan: enable parallel testing per target.
Parallel testing inside a single xcodebuild test invocation runs multiple test cases concurrently on the simulator. Faster, but watch for state-dependent tests that fail under parallelization.
For testing across iOS versions:
unit_tests_ios17:
script:
- xcodebuild test ... -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2'
unit_tests_ios16:
script:
- xcodebuild test ... -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.4'
Runs in parallel; catches regressions on older OS versions. Section 29 covers parallel/matrix patterns more fully.
By default, xcodebuild test runs all tests and reports at the end. To stop on first failure:
xcodebuild test ... -test-iterations 1 -only-testing:MyAppTests
There isn't a direct "fail fast" flag in xcodebuild. Workarounds: split tests into smaller targets, use test plans with smart ordering, run quickest tests first via -only-testing ordering.
For most projects, running all tests and seeing comprehensive failures is more useful than stopping at the first.
Tests pass locally, fail in CI. Common causes: locale (en_US vs system default), timezone (UTC in CI vs local), simulator state, missing fixture files (.gitignore-ed), file system case sensitivity (CI macOS is usually case-insensitive but containers may not be).
xcodebuild test exits 0 even with failures. This shouldn't happen but rarely does, especially when piping. Check with set -o pipefail.
Simulator wedged. Builds queue up but fail. xcrun simctl shutdown all && xcrun simctl erase all resets.
Tests take too long. Profile and split. Move slow tests to a separate suite that runs less often.
Flaky UI tests. Section 10 covers UI testing specifically.
Forgetting to commit .xcscheme. Schemes not shared; xcodebuild can't find them.
Code coverage off. Either not enabled in the scheme or test plan, or your coverage extraction is wrong. Verify with xcrun xccov view.
xcodebuild test runs unit tests. Use -resultBundlePath for structured output. Generate JUnit XML and surface via reports.junit artifact. Test plans (xctestplan) modernize test configuration. Fastlane scan is the most ergonomic test runner. Boot simulators explicitly if needed; reset between runs. Pin simulator versions for consistency.
UI tests are slow, flaky, and stateful. They're also valuable — they catch real-user issues that unit tests miss. Running them well in CI requires more care than unit tests.
Despite this, they're worth running. The trick is to do it in a way that's reliable enough not to be a permanent yellow flag in your CI.
Same shape as unit tests, but targeting the UI test scheme/plan:
ui_tests:
stage: test
script:
- bundle exec fastlane ui_tests
artifacts:
when: always
paths:
- build/
reports:
junit: build/reports/ui-junit.xml
expire_in: 1 week
Fastfile lane:
desc "Run UI tests"
lane :ui_tests do
scan(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Debug",
device: "iPhone 15",
only_testing: ["MyAppUITests"],
output_directory: "build/reports/",
output_types: "junit",
output_files: "ui-junit.xml",
result_bundle: true,
number_of_retries: 2 # retry flaky tests
)
end
number_of_retries: 2 reruns failed tests up to twice. If they pass on retry, the test is reported as passed. This masks flakiness rather than fixing it but is pragmatic.
UI tests need a graphical session (the simulator window opens, even if invisible to a user). If the runner Mac is at the login screen, UI tests fail.
Configure auto-login (covered in section 4). Verify by ssh-ing into the runner and seeing whether WindowServer is running:
ps aux | grep WindowServer
If not, the runner is at the login screen — auto-login isn't configured properly.
Reset simulator state before UI tests:
ui_tests:
before_script:
- xcrun simctl shutdown all || true
- xcrun simctl erase all || true
script:
- bundle exec fastlane ui_tests
Erasing simulators clears their state — important for tests that assume a fresh app install. Also frees up disk space (booted simulators accumulate state).
For tests that depend on specific simulator state (e.g., Photos library populated), you'd add it to a setup step:
before_script:
- xcrun simctl shutdown all || true
- xcrun simctl erase all || true
- xcrun simctl boot "iPhone 15"
- xcrun simctl addmedia "iPhone 15" ./test-fixtures/photos/*.jpg
iOS Simulator can run without a visible window via simctl. But there's no real "headless mode" — the simulator process runs the same. The window may be hidden but graphical resources are still used.
Don't assume "headless" in the way Selenium or Puppeteer means. Treat it as "the simulator window is open but you don't have to see it."
When a UI test fails, screenshots help debug. Test plans can record screenshots automatically:
In the test plan: Configurations → Screenshots: "Automatic" or "On Failure."
Screenshots end up in the .xcresult bundle. Extract for debugging:
xcrun xcresulttool get --path build/test-results.xcresult --format json | \
jq '.actions._values[].actionResult.testsRef.id._value' | \
xargs -I {} xcrun xcresulttool get --path build/test-results.xcresult --id {} --format json
(JSON parsing is awkward; tools like xcparse simplify extraction.)
For automated screenshot tests (Snapshot testing libraries like SnapshotTesting), see section 23.
simctl can record:
xcrun simctl io "iPhone 15" recordVideo --type=mp4 ui_test_recording.mp4
Run this in the background during UI tests:
ui_tests:
script:
- xcrun simctl boot "iPhone 15"
- xcrun simctl io "iPhone 15" recordVideo --type=mp4 video.mp4 &
- VIDEO_PID=$!
- bundle exec fastlane ui_tests
- kill -INT $VIDEO_PID
artifacts:
when: on_failure
paths:
- video.mp4
When tests fail, the video is uploaded as an artifact. Useful for debugging "why did this test fail?"
A common pattern:
scan(
...,
number_of_retries: 2,
fail_build: true # but still fail if all retries fail
)
Better: identify which tests are flaky, fix them, run others without retries. But for pragmatic CI on a real product, retries are an accepted compromise.
ui_tests_iphone:
script:
- bundle exec fastlane ui_tests device:"iPhone 15"
ui_tests_ipad:
script:
- bundle exec fastlane ui_tests device:"iPad Pro (12.9-inch)"
Catches device-specific layout bugs. Slow — running both means double the UI test time.
For most teams, running UI tests on one device per CI run is sufficient. Run multi-device tests on a schedule (nightly) rather than every commit.
If UI tests take 20 minutes total, splitting them across 4 parallel jobs gets you to 5 minutes (plus overhead).
ui_tests:
parallel: 4
variables:
TEST_BATCH: "$CI_NODE_INDEX"
TOTAL_BATCHES: "$CI_NODE_TOTAL"
script:
- bundle exec fastlane ui_tests batch:$TEST_BATCH total:$TOTAL_BATCHES
The parallel: 4 runs the job 4 times with CI_NODE_INDEX set to 1, 2, 3, 4. Your Fastlane lane uses these to select which subset of tests to run.
Section 29 covers sharding patterns in detail. For now, know that UI tests parallelize well if your fixture isolation is solid.
UI tests must be independent. If test A leaves the app in a logged-in state and test B assumes logged-out, they're coupled — running them in different orders or on different machines causes failures.
Reset app state between tests:
override func setUp() {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["--reset-state"] // your app handles this flag
app.launch()
}
The app reads launch arguments and resets to a known state. Each test starts fresh.
Or for more aggressive reset:
override func setUp() {
let app = XCUIApplication()
app.terminate()
// Reinstall? Reset simulator? Project-specific.
app.launch()
}
For full isolation, reset between runs takes more setup but eliminates coupling.
Sometimes UI tests are more trouble than they're worth. Honest signals:
If you hit this, narrow scope: one UI test per critical user flow, run only on main branch and release tags. Cut bait on lower-value tests.
For most teams, a small suite of well-maintained UI tests is more valuable than a large flaky suite. Quality > quantity.
Auto-login not configured. Tests fail because no graphical session. Verify WindowServer is running.
Simulator wedged or out of memory. Reset with simctl erase all. If repeated, the runner needs more RAM/disk.
Tests assume specific app state. Coupled tests fail in CI. Add launch argument or setup script to reset state.
Flakiness suppressed by retries. Tests are unreliable; retries hide it. Periodically audit flake rate and fix root causes.
UI tests in the same job as unit tests. Failures in UI tests block reporting of unit test results. Split.
Long test runs. A 30-minute UI test job with no parallelization is a problem. Split into batches.
UI tests need a booted simulator and graphical session — auto-login required. Reset simulator state between runs. Generate JUnit reports and surface via GitLab. Capture screenshots/video on failure. Use retries pragmatically. Split UI tests across parallel jobs for speed. Test isolation is critical — each test must be independent.
Without caching, every CI build is a clean build: download all dependencies, recompile everything. For an iOS app, that's 5-15 minutes wasted per build. With caching, you get 1-3 minutes — sometimes much less.
GitLab CI's cache: directive saves files between pipelines. Configured well, it transforms iOS CI from "slow but works" to "fast enough."
A cache:
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- vendor/bundle
- Pods/
In this example: a cache per branch. Each branch has its own cache for vendor/bundle (Bundler) and Pods/ (CocoaPods).
For iOS, the candidates:
vendor/bundle — Bundler-installed Ruby gems (fastlane, etc.).Pods/ — CocoaPods installed pods.Carthage/Build — if using Carthage.Each has different cache characteristics.
cache:
key:
files:
- Gemfile.lock
paths:
- vendor/bundle
before_script:
- bundle config set --local path vendor/bundle
- bundle install --jobs 4 --retry 3
Cache key based on Gemfile.lock — when gems change, cache key changes, so a fresh install runs. When gems don't change, the cache is reused.
cache:
key:
files:
- Podfile.lock
paths:
- Pods/
- vendor/bundle
before_script:
- bundle install --path vendor/bundle
- bundle exec pod install
pod install is fast when Pods are already cached. It just verifies and links.
For better key granularity, combine multiple files:
cache:
key:
files:
- Gemfile.lock
- Podfile.lock
paths:
- vendor/bundle
- Pods/
Now any change to Gemfile.lock or Podfile.lock invalidates the cache.
Swift Package Manager stores packages inside DerivedData by default. With a custom DerivedData path:
variables:
DERIVED_DATA: "$CI_PROJECT_DIR/DerivedData"
cache:
key:
files:
- Package.resolved
paths:
- DerivedData/SourcePackages/
before_script:
- xcodebuild -resolvePackageDependencies -workspace MyApp.xcworkspace -derivedDataPath $DERIVED_DATA
The SourcePackages directory contains downloaded packages. Caching it saves the network fetch. Computation (compilation) still happens — see DerivedData caching below.
For SPM-only projects, this is the main cache. CocoaPods isn't relevant.
The big one. DerivedData contains build products — modules, intermediates, the whole build tree. Caching it can save minutes per build.
But it's tricky:
A conservative approach:
cache:
key: derived-data-$CI_COMMIT_REF_SLUG
paths:
- DerivedData/
policy: pull-push
policy: pull-push (default) downloads at start, uploads at end. For jobs that just read (test runners using a previous build's products), use policy: pull to skip the upload.
Reset DerivedData periodically or when builds get weird. Adding a cache key based on Xcode version helps:
cache:
key: derived-data-$CI_COMMIT_REF_SLUG-xcode-$XCODE_VERSION
paths:
- DerivedData/
When Xcode updates, key changes, cache invalidates.
Per-branch:
cache:
key: $CI_COMMIT_REF_SLUG
Each branch has its own cache. Fast for active branches; cold start for new branches.
Shared across branches:
cache:
key: shared-cache
Single cache used by all jobs/branches. Fastest but more invalidation churn.
File-based:
cache:
key:
files:
- Podfile.lock
Cache invalidates when files change. Used with shared keys for stable dependency caching.
Combined:
cache:
key:
files:
- Gemfile.lock
- Podfile.lock
prefix: $CI_COMMIT_REF_SLUG
Per-branch cache, invalidated when dependency files change.
For iOS, I recommend:
Package.resolved.By default, caches are uploaded at job end and downloaded at job start. Multiple jobs can share caches (if they use the same key).
For passing build artifacts between jobs in the same pipeline, use artifacts: with dependencies: or needs: instead of cache. Caches are for between pipelines; artifacts are for within a pipeline.
If a cache for the exact key doesn't exist:
cache:
key: $CI_COMMIT_REF_SLUG
fallback_keys:
- $CI_DEFAULT_BRANCH
- shared-cache
paths:
- vendor/bundle
Try $CI_COMMIT_REF_SLUG first. If miss, try $CI_DEFAULT_BRANCH. If miss, try shared-cache. Useful for new branches that benefit from main's cache.
A job can have multiple caches:
build:
cache:
- key: bundler-$CI_COMMIT_REF_SLUG
paths:
- vendor/bundle
- key: pods-$CI_COMMIT_REF_SLUG
paths:
- Pods/
- key: derived-data-$CI_COMMIT_REF_SLUG
paths:
- DerivedData/
Each cache has independent key/paths. More flexible but more YAML.
On a self-hosted runner with the shell executor, GitLab cache uploads/downloads the cached files via GitLab's storage. For a Mac mini in your office, this means:
A shortcut: use the runner's local disk directly. With shell executor, you can store caches outside the project directory and have them persist between jobs (no upload/download):
variables:
PERSISTENT_CACHE: /Users/runner/ci-cache
build:
before_script:
- mkdir -p $PERSISTENT_CACHE
- ln -sf $PERSISTENT_CACHE/Pods Pods || true
- ln -sf $PERSISTENT_CACHE/vendor vendor || true
Symlinks to a persistent location. Each job uses the same shared cache, no upload/download. Fast.
The catch: this couples to one runner. If you have multiple Macs, the cache doesn't share between them. For small teams with one runner, this is fine.
Caches don't grow forever. GitLab caches expire (default 30 days for SaaS) and have size limits.
For self-hosted local caching, manage yourself. A weekly cleanup:
# Remove DerivedData older than 7 days
find ~/Library/Developer/Xcode/DerivedData -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -rf {} \;
# Remove old SPM clones
find ~/Library/Developer/Xcode/DerivedData/*/SourcePackages -mtime +14 -exec rm -rf {} \;
Caching node_modules. That's web. iOS doesn't have it. Cache iOS-relevant things.
Cache key too granular. If every commit invalidates cache, caching is useless. Use lockfile-based keys.
DerivedData cache produces stale modules. Add Xcode version to key; periodically reset.
Cache too large. Multi-GB caches slow down upload/download. Be selective about paths.
Mixing cache and artifacts. Use cache for between-pipeline reuse; artifacts for within-pipeline dependencies.
Forgetting cache:policy: pull for read-only jobs. Test jobs that only read DerivedData shouldn't upload changes. Use policy: pull.
On self-hosted runners, fighting GitLab's cache. When local disk is fast and network is slow, GitLab cache via network is a regression. Use local symlinks or persistent volumes.
GitLab cache saves files between pipelines via key-based lookup. For iOS: cache vendor/bundle (Bundler), Pods/ (CocoaPods), SPM packages, and (cautiously) DerivedData. Use lockfile-based keys for dependencies. Self-hosted runners can use local persistent caches via symlinks. Fallback keys help new branches inherit from main. Periodic cleanup prevents disk exhaustion.
Artifacts are files saved at the end of a job, available for download or for use by later jobs in the pipeline. For iOS, the typical artifacts are: test reports, build outputs (.app, .ipa), logs, and code coverage data. Configuring them well helps debugging and supports downstream jobs.
build:
script:
- xcodebuild ...
artifacts:
paths:
- build/MyApp.ipa
expire_in: 1 week
The IPA is saved. After the job, it's downloadable from the GitLab UI for 1 week.
artifacts:
paths:
- build/MyApp.ipa
- build/dSYMs/
- build/test-results.xcresult
Each path is included. Wildcards work:
paths:
- "build/*.ipa"
- "build/**/*.dSYM"
when: controlling artifact uploadBy default, artifacts are uploaded only on success. To always upload:
artifacts:
when: always
paths:
- build/
Useful for test results — you want them even (especially) when tests fail.
To upload only on failure:
artifacts:
when: on_failure
paths:
- build/logs/
Useful for capturing debug output that's only relevant on failure.
expire_in:Artifacts use storage. Set expiration:
artifacts:
expire_in: 1 week
paths:
- build/
Values: 1 hour, 7 days, 1 week, 1 month, never. After expiration, GitLab garbage-collects.
For test results: 1-2 weeks is plenty. For release IPAs: longer (months) so you can re-download specific releases.
To keep artifacts indefinitely:
artifacts:
expire_in: never
Use sparingly — costs storage.
For latest artifacts (always available, never expire), use keep_latest_artifact: true (or its equivalent setting in GitLab UI). The latest pipeline's artifacts on the default branch are kept.
Special artifact paths that GitLab integrates with the UI:
artifacts:
reports:
junit: build/reports/junit.xml
coverage_report:
coverage_format: cobertura
path: build/coverage.cobertura.xml
codequality: build/codequality.json
Available report types:
junit — test results (parsed and shown in UI).coverage_report — code coverage with file-by-file display in MRs.codequality — code quality issues (e.g., from SwiftLint with the right format).sast — static security analysis.dast — dynamic security analysis.dependency_scanning — known vulnerabilities in dependencies.license_scanning — license compliance.For iOS, junit and coverage_report are the main two. codequality if you set up SwiftLint reporting.
Important distinction:
You can have both:
build:
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- DerivedData/
artifacts:
paths:
- build/MyApp.ipa
Cache for reuse, artifacts for the build output.
By default, artifacts from earlier stages are downloaded automatically into later jobs (in the same pipeline).
stages:
- build
- test
build:
stage: build
script:
- xcodebuild build-for-testing ...
artifacts:
paths:
- DerivedData/Build/Products/
- DerivedData/Build/Intermediates.noindex/
test:
stage: test
script:
- xcodebuild test-without-building ...
The test job receives the DerivedData/Build/ from the build job — the build products and intermediates are present, so test-without-building can run.
You can be explicit about dependencies:
test:
stage: test
needs:
- job: build
artifacts: true # download artifacts from build (default if needs is set with artifacts)
script:
- xcodebuild test-without-building ...
Or selectively:
test:
dependencies:
- build # only get artifacts from `build`, not from earlier stages
dependencies: (legacy) and needs: (modern) both control which artifacts flow into the job.
Don't save:
Pods/. Recreatable from Podfile.lock.vendor/bundle. Recreatable from Gemfile.lock.Do save:
A good rule: artifact what a human or downstream job needs to consume.
Debug symbols (.dSYM) are crucial for symbolicating crash reports. After an archive build:
build:
script:
- xcodebuild archive ...
- xcodebuild -exportArchive ...
artifacts:
paths:
- build/MyApp.ipa
- build/dSYMs/
expire_in: 6 months
dSYMs should outlive the IPA's expiration — you might need to symbolicate a crash from a build long after.
For each release, archive dSYMs separately (e.g., upload to Crashlytics or another crash reporter). The CI artifact is the safety net.
artifacts:
paths:
- build/
exclude:
- build/**/*.swiftmodule
- build/**/*.swiftdoc
exclude: removes matching paths from what's saved. Useful for trimming noise.
Large artifacts slow down jobs (upload time) and use storage. GitLab has size limits per project (configurable; default 1 GB per job).
For a typical iOS app:
Total per pipeline: comfortably under 1 GB.
If you find yourself over limit, audit what you're saving. Often DerivedData snuck in or build intermediates aren't excluded.
After a job finishes, the GitLab UI shows artifacts. You can:
https://.../-/jobs/<job-id>/artifacts/file/<path>.For shareable IPAs (e.g., for QA), the direct-link pattern is useful — bookmark the URL, get the latest IPA from that job.
artifacts:publicMost artifacts are private to project members. To make them publicly readable (e.g., for open-source releases):
artifacts:
public: false # default; explicit
paths: [...]
Or true for public access. Be cautious — public artifacts could leak private code.
Forgetting when: always for test reports. Tests fail; report not saved; can't see what failed. Always-save test artifacts.
Saving DerivedData as artifact. Multi-GB upload, slow, runs into limits. Don't.
Expiration too short. Want to debug a 2-week-old failure but artifacts gone. Set 1+ months for reports and IPAs that matter.
Expiration too long. Storage adds up. Audit periodically.
Path typos. paths: build/ vs path: build/ (latter is wrong key). YAML is unforgiving.
Wildcards not matching. Test locally with find or ls to verify glob patterns.
Forgetting dSYMs. Crashes are unsymbolicatable later. Always artifact dSYMs.
Artifacts save files at job end for download or downstream use. Use paths: for explicit selection; when: to control upload conditions; expire_in: for storage management. Use reports: for GitLab UI integration (JUnit, coverage). Pass artifacts between jobs via needs: or dependencies:. Don't artifact DerivedData or Pods. Always artifact dSYMs and test reports.
Code signing is the hardest part of iOS CI. It's not conceptually complex — you have a certificate, a provisioning profile, and an app — but the moving parts and macOS keychain quirks make it painful in practice.
This section covers the manual approach so you understand what's happening. Sections 14-15 (Fastlane and match) automate it.
iOS requires every app installed on a device to be signed with a valid Apple-issued cryptographic identity. The signature proves the app comes from a known developer and hasn't been tampered with.
The pieces:
When you build a distribution app, Xcode (or xcodebuild):
The IPA can then be installed on devices listed in the profile (for development/ad-hoc) or submitted to Apple for distribution.
Apple issues several certificate types:
For CI on iOS, you typically need iOS Distribution (for TestFlight/App Store builds) and/or iOS App Development (for testing builds on physical devices).
Local development:
CI:
You must, in the CI pipeline:
Doing this manually is doable; doing it correctly is finicky. Hence Fastlane and match.
On a development Mac with the certs/profiles installed:
.p12. Set a strong password — this protects the private key.~/Library/MobileDevice/Provisioning Profiles/. Each is a .mobileprovision file. Copy the relevant ones.You now have:
distribution.p12 — encrypted with password.MyApp_AppStore.mobileprovision.Two options:
match does (covered in section 15).For UI variables: Settings → CI/CD → Variables → Add → Type "File" → paste the contents (or upload via the UI).
For .p12: it's binary. Base64-encode for variable storage:
base64 -i distribution.p12 -o distribution.p12.b64
Paste the base64 content as a regular variable, or upload the file directly as a file-type variable (some tools handle binary, but plain GitLab variables prefer text — base64 is safer).
build:
before_script:
- |
# Create a temporary keychain
KEYCHAIN_NAME="ci-keychain"
KEYCHAIN_PASSWORD="$RANDOM_KEYCHAIN_PASSWORD"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -t 3600 -l "$KEYCHAIN_NAME"
# Decode and import the .p12
echo "$DISTRIBUTION_P12_BASE64" | base64 -d > distribution.p12
security import distribution.p12 -k "$KEYCHAIN_NAME" -P "$P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
# Allow codesign to access without prompting
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Install provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles/
echo "$PROVISIONING_PROFILE_BASE64" | base64 -d > profile.mobileprovision
# Extract UUID and rename appropriately
UUID=$(/usr/libexec/PlistBuddy -c 'Print UUID' \
/dev/stdin <<< "$(security cms -D -i profile.mobileprovision)")
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
A lot. And we haven't even built yet. Each step exists for a reason:
codesign finds it.set-key-partition-list is a macOS Sierra+ requirement to allow codesign to use the imported key without GUI prompts.~/Library/MobileDevice/Provisioning Profiles/<UUID>.mobileprovision.xcodebuild archive \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
CODE_SIGN_IDENTITY="iPhone Distribution: My Company (TEAM12345)" \
PROVISIONING_PROFILE_SPECIFIER="MyApp_AppStore" \
CODE_SIGN_STYLE="Manual"
CODE_SIGN_STYLE="Manual" overrides the project's automatic signing. CODE_SIGN_IDENTITY matches the cert (find it via security find-identity -p codesigning). PROVISIONING_PROFILE_SPECIFIER is the profile name.
Then export:
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/ \
-exportOptionsPlist ExportOptions.plist
Where ExportOptions.plist contains:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>TEAM12345</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>com.example.MyApp</key>
<string>MyApp_AppStore</string>
</dict>
</dict>
</plist>
after_script:
- security delete-keychain ci-keychain || true
- rm -f distribution.p12 profile.mobileprovision
Reading the above: it's verbose, fragile, and changes when Apple updates anything. Also:
This is what Fastlane and match exist for — automate all of this. We'll cover them next. The manual approach is here so you understand what's happening underneath.
Xcode's automatic signing requires Apple ID login. In CI without GUI Apple ID, it won't work. Don't try.
Manual signing — explicit certificates and profiles — is the way for CI.
xcodebuild's -allowProvisioningUpdatesThere's an -allowProvisioningUpdates flag that lets xcodebuild fetch profiles from Apple if needed:
xcodebuild ... -allowProvisioningUpdates
Requires App Store Connect API key for authentication. If you provide that (covered in section 15), this can simplify things. But fetching profiles per-build adds API calls; cached profiles are faster.
Wrong cert type. Distribution cert for App Store; Development cert for ad-hoc on dev devices. Mixed up = signing fails.
Cert expired. Certs are valid 1 year. Renew before expiry.
Profile expired. Profiles match a cert; if cert renewed, profile invalidates. Regenerate.
Profile doesn't match bundle ID. Bundle ID must match exactly. Check the profile contents.
Keychain permission denied. set-key-partition-list is required on Sierra+. Without it, codesign prompts for password — which never resolves in CI.
Provisioning profile not in expected location. Must be at ~/Library/MobileDevice/Provisioning Profiles/<UUID>.mobileprovision.
Wrong team ID. Multi-team Apple accounts. Ensure the right team for your app.
Forgot to delete cert after CI run. Cert sits in keychain. Could be a security issue. Always cleanup.
Code signing requires a certificate (signing identity) and a matching provisioning profile. CI must import these into a keychain and place profiles in the right directory. Manual signing in CI is doable but fragile — better to use Fastlane match (next sections). Always use distribution cert + App Store profile for TestFlight/App Store builds. Always cleanup keychain after CI runs.
Fastlane is the de-facto automation toolkit for iOS (and Android) — a collection of Ruby tools that wrap xcodebuild, App Store Connect API, code signing, provisioning, screenshot generation, and more. Most iOS CI uses it. This section covers integrating Fastlane into GitLab CI/CD.
The alternatives are: hand-rolled shell scripts (verbose, fragile), or one-off CI jobs that call xcodebuild directly (works for simple cases). Fastlane provides:
build_app, scan (test), upload_to_testflight, etc. Each handles dozens of details.Fastfile. bundle exec fastlane test is one command for the whole test workflow.Once your team uses Fastlane locally, using it in CI is the same code paths. "Run fastlane test works on my Mac" extends to "Run fastlane test works in CI."
Fastlane is a Ruby gem. Install via Bundler in your project:
Gemfile:
source "https://rubygems.org"
gem "fastlane"
gem "cocoapods" # if using CocoaPods
gem "xcpretty" # optional, for output formatting
Gemfile.lock (after bundle install) pins versions.
In CI:
default:
before_script:
- bundle install --path vendor/bundle
Bundler installs the gems locally. Subsequent commands use bundle exec fastlane ....
Initialize Fastlane in your project:
cd path/to/MyApp
bundle exec fastlane init
Walks through a setup wizard. Creates a fastlane/ directory with:
Fastfile — your lanes (Ruby code).Appfile — app-specific config (bundle ID, team ID).Matchfile, Gymfile, etc. for tool-specific config.Commit these to the repo.
default_platform(:ios)
platform :ios do
before_all do
setup_ci if ENV["CI"]
end
desc "Run tests"
lane :test do
scan(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
device: "iPhone 15"
)
end
desc "Build for TestFlight"
lane :beta do
increment_build_number(build_number: ENV["CI_PIPELINE_IID"])
match(type: "appstore", readonly: true)
gym(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Release",
export_method: "app-store"
)
pilot(
skip_waiting_for_build_processing: true,
changelog: "Build from #{ENV['CI_COMMIT_SHORT_SHA']}"
)
end
desc "Lint"
lane :lint do
swiftlint(
mode: :lint,
output_file: "build/swiftlint.txt"
)
end
end
Lanes are Ruby methods. Each can call Fastlane "actions" (scan, match, gym, etc.). The before_all block runs before every lane.
setup_ciThe setup_ci action is critical. It:
LC_ALL and LANG.Without setup_ci, you'll fight keychain issues. With it, things mostly work.
It only runs in CI (gated by ENV["CI"] — set automatically by GitLab and most CI systems).
.gitlab-ci.ymlstages:
- lint
- test
- build
- deploy
variables:
LC_ALL: "en_US.UTF-8"
LANG: "en_US.UTF-8"
default:
tags: [macos]
before_script:
- bundle install --path vendor/bundle
lint:
stage: lint
script:
- bundle exec fastlane lint
test:
stage: test
script:
- bundle exec fastlane test
artifacts:
when: always
reports:
junit: fastlane/test_output/report.junit
paths:
- fastlane/test_output/
build:
stage: build
script:
- bundle exec fastlane beta
artifacts:
paths:
- "*.ipa"
- "*.app.dSYM.zip"
rules:
- if: '$CI_COMMIT_TAG'
bundle exec fastlane <lane> is the only command you need. All the complexity is in the Fastfile.
A reference of the most-used Fastlane actions for iOS CI:
scan (or run_tests) — runs tests via xcodebuild. Generates JUnit, coverage.gym (or build_app) — builds and exports an .ipa.pilot (or upload_to_testflight) — uploads to TestFlight.deliver (or upload_to_app_store) — uploads to App Store.match — fetches/installs certs and profiles.sigh (or get_provisioning_profile) — fetches a single profile.cert (or get_certificates) — fetches signing certs.increment_build_number — bumps the build number in the project.set_info_plist_value — modifies Info.plist values.swiftlint — runs SwiftLint.slack — sends a Slack notification.Each has many options; consult Fastlane's docs.
Fastlane supports per-tool config files to avoid passing options every time:
fastlane/Gymfile:
workspace "MyApp.xcworkspace"
scheme "MyApp"
configuration "Release"
output_directory "build"
output_name "MyApp"
fastlane/Scanfile:
workspace "MyApp.xcworkspace"
scheme "MyApp"
device "iPhone 15"
output_directory "fastlane/test_output"
output_types "junit"
Now gym and scan actions in Fastfile lanes use these defaults. Lane code stays clean.
For uploading to TestFlight/App Store and managing certs:
api_key = app_store_connect_api_key(
key_id: ENV["APPLE_KEY_ID"],
issuer_id: ENV["APPLE_ISSUER_ID"],
key_filepath: ENV["APPLE_API_KEY_FILE"]
)
pilot(api_key: api_key, ...)
In GitLab CI/CD variables:
APPLE_KEY_ID — short string like ABCD1234EF.APPLE_ISSUER_ID — UUID like 12345678-1234-1234-1234-123456789012.APPLE_API_KEY_FILE — File-type variable containing the .p8 private key.Get these from App Store Connect → Users and Access → Keys.
Without API key, you'd authenticate with Apple ID + password, which is brittle (2FA prompts in CI fail). API keys are the way.
script:
- bundle exec fastlane test --verbose
--verbose makes Fastlane log more. For debugging.
For quieter output:
script:
- bundle exec fastlane test
Default verbosity is reasonable.
You can pass parameters to lanes:
lane :test do |options|
scheme = options[:scheme] || "MyApp"
scan(scheme: scheme, ...)
end
Then:
script:
- bundle exec fastlane test scheme:MyApp-Staging
No setup_ci call. Keychain issues, match issues, locale issues. Always include setup_ci in before_all.
Outdated Fastlane. New Xcode/macOS may break old Fastlane versions. Update periodically.
Hardcoded paths. FASTLANE_HIDE_TIMESTAMP=1, FASTLANE_HIDE_GITHUB_ISSUES=1 reduce noise. Set as environment variables.
Missing bundle exec. Calling fastlane directly might use a system-installed gem (different version). Always bundle exec.
Lane errors silently. Fastlane's error handling can swallow some errors. If something seems wrong, run with --verbose.
Coupling production deploy lanes to CI vars. If a developer runs fastlane deploy locally, they'd hit missing CI variables. Guard with if ENV["CI"] or document expected env.
Fastlane is the standard automation toolkit for iOS. Install via Bundler. Define lanes in Fastfile. Use setup_ci for CI-specific configuration. Common lanes: lint, test, beta (TestFlight), release. Call from .gitlab-ci.yml via bundle exec fastlane <lane>. Use App Store Connect API key for authentication. Configure tool-specific defaults in Gymfile, Scanfile, etc.
Match is Fastlane's solution for iOS code signing in CI. It stores certificates and provisioning profiles in a private git repo (encrypted), and any machine — your laptop, CI runner, your colleague's Mac — can fetch and install them on demand.
This solves the "every developer has their own profile, CI has profiles, they're constantly out of sync" problem. With match, there's one source of truth.
certificates.match nuke deletes everything; match generate (or specifically match appstore/match development/etc.) fetches/regenerates as needed.match fetches and installs into the keychain.Encryption: match uses a passphrase you control. The certs/profiles in the repo are useless without it.
In GitLab, create a new private repository, e.g., myorg/ios-certificates. Initialize it empty.
In your iOS project:
bundle exec fastlane match init
You'll be prompted:
What type of storage to use? (1) git (2) google_cloud (3) s3 (4) gitlab_secure_files
Choose: 1
URL of the Git Repo: git@gitlab.com:myorg/ios-certificates.git
Match creates fastlane/Matchfile:
git_url("git@gitlab.com:myorg/ios-certificates.git")
storage_mode("git")
type("development")
app_identifier(["com.example.MyApp"])
username("you@example.com")
bundle exec fastlane match development
bundle exec fastlane match appstore
This:
Match prompts for a passphrase the first time. Save this passphrase securely — you'll need it everywhere.
After running, the certificates repo has a structure like:
certs/
development/
<UUID>.cer
<UUID>.p12
distribution/
<UUID>.cer
<UUID>.p12
profiles/
development/
Development_com.example.MyApp.mobileprovision
appstore/
AppStore_com.example.MyApp.mobileprovision
README.md
All .p12 and .mobileprovision files are encrypted (visible binary content if you peek).
In your Fastfile:
lane :beta do
setup_ci
match(
type: "appstore",
readonly: true,
api_key: app_store_connect_api_key(...)
)
gym(...)
pilot(...)
end
type: "appstore" fetches App Store distribution cert + profile. readonly: true is critical for CI — never let CI accidentally regenerate certs (which would invalidate everyone else's).
In GitLab UI, set:
MATCH_PASSWORD — the passphrase you chose. Protected, Masked.MATCH_GIT_URL — git URL of the certs repo. Often https://oauth2:<DEPLOY_TOKEN>@gitlab.com/myorg/ios-certificates.git (with a deploy token for read access).MATCH_GIT_BRANCH — usually main or master.The MATCH_GIT_URL with a deploy token allows CI to clone the certs repo without an SSH key.
To create a deploy token: in the certificates repo, Settings → Repository → Deploy tokens → Add (name, scope read_repository). Use the resulting token in the URL: https://oauth2:<token>@....
Match also needs to talk to App Store Connect (for verification, regeneration if profiles invalidated). Use the API key:
match(
type: "appstore",
readonly: true,
api_key: app_store_connect_api_key(
key_id: ENV["APPLE_KEY_ID"],
issuer_id: ENV["APPLE_ISSUER_ID"],
key_filepath: ENV["APPLE_API_KEY_FILE"]
)
)
development — Development cert + Development profile. For installing on dev devices.adhoc — Distribution cert + Ad Hoc profile. For internal testing on registered devices.appstore — Distribution cert + App Store profile. For TestFlight and App Store.enterprise — Distribution cert + In-House profile. For Apple Enterprise accounts.Per app, you might have all four (or just development + appstore for typical use).
Apps with extensions (notification service, share extension, widget) have multiple bundle IDs. List them in the Matchfile:
app_identifier([
"com.example.MyApp",
"com.example.MyApp.NotificationService",
"com.example.MyApp.ShareExtension"
])
Match generates profiles for each. In the build, all are installed.
match nukeTo wipe everything (cert + profiles for a type) and start fresh:
bundle exec fastlane match nuke development
bundle exec fastlane match nuke distribution
Useful when a cert is compromised or you want to reset. Be careful — this revokes the cert, which invalidates all builds signed with it. Notify your team.
Developers also use match locally:
bundle exec fastlane match development
Match clones the certs repo, decrypts, installs into their keychain. They build locally with the team's shared dev cert.
This is the killer feature: same certs everywhere.
Annually or so:
readonly: false. In CI, certs renew on a developer's machine; CI just fetches.For most teams, the maintenance is "developer regenerates once a year on their Mac, push to certs repo, everyone else syncs."
To rotate:
match change_password locally.readonly: false in CI. Bad. Means CI can regenerate certs, which can revoke everyone else's. Always readonly: true in CI.
Lost passphrase. Without it, the certs repo is encrypted gibberish. Recover via match nuke and regenerate everything.
Wrong git URL or unauthorized. Match clones fail. Test the URL with manual git clone.
Not setting MATCH_PASSWORD. Match prompts interactively, which CI can't answer; pipeline hangs/fails.
Match fetching fresh every time. Slow. Cache ~/Library/MobileDevice/Provisioning Profiles/ between jobs.
API key not provided for --readonly: false operations. Match falls back to Apple ID + password, which often fails 2FA in CI.
Match stores certificates and profiles in a private git repo, encrypted. Any machine with the passphrase fetches and installs. CI uses match in readonly: true mode. Set MATCH_PASSWORD, MATCH_GIT_URL (with deploy token), and App Store Connect API key as Protected variables. Match handles all the keychain dance for you. Annual cert rotation is one developer action; CI just keeps working.
Now we put it all together: a CI job that produces a signed .ipa ready for TestFlight. This is the moment everything we've covered — runners, Xcode, signing, fastlane, match — converges into a useful pipeline. By the end of this section, every push to main will produce a signed build artifact.
A TestFlight-ready build is:
#if DEBUG blocks excluded..xcarchive. Xcode's intermediate format containing the app, dSYMs, and metadata..ipa. A signed installable package, which is what App Store Connect accepts.Each of these is a thing that can go wrong. The code-signing pieces in particular need to match exactly: certificate, profile, bundle ID, capabilities, team — all aligned.
The core invocation:
xcodebuild archive \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
-destination 'generic/platform=iOS' \
-allowProvisioningUpdates \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="iPhone Distribution: Acme Inc. (ABCDE12345)" \
PROVISIONING_PROFILE_SPECIFIER="match AppStore com.acme.MyApp" \
DEVELOPMENT_TEAM="ABCDE12345"
Each flag matters:
-workspace MyApp.xcworkspace — point to your workspace, not the .xcodeproj. Required if you're using CocoaPods or have multi-project workspaces.-scheme MyApp — the scheme to archive. Must be marked "Shared" in Xcode (a setting you check in the manage-schemes dialog) for CI to see it.-configuration Release — Release build, with optimizations.-archivePath build/MyApp.xcarchive — where to write the archive. The .xcarchive extension matters; xcodebuild creates a directory bundle.-destination 'generic/platform=iOS' — build for "any iOS device" (not a specific simulator). For archives destined for distribution.-allowProvisioningUpdates — let Xcode automatically update profiles if needed. With manual signing (which we want in CI), this is sometimes unnecessary but doesn't hurt.CODE_SIGN_STYLE=Manual — explicit manual signing. We control the certificate and profile; Xcode shouldn't auto-generate.CODE_SIGN_IDENTITY=... — the exact name of the certificate as it appears in Keychain Access. Usually iPhone Distribution: Team Name (TEAMID).PROVISIONING_PROFILE_SPECIFIER=... — match-installed profiles are named match AppStore <bundle-id>. Use that name, not the UUID.DEVELOPMENT_TEAM=ABCDE12345 — your Apple Developer team ID.Archive on its own isn't an IPA. You need a separate export step:
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/ipa \
-exportOptionsPlist ExportOptions.plist
This produces build/ipa/MyApp.ipa.
The ExportOptions.plist controls the export. A typical version for App Store distribution:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>ABCDE12345</string>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>com.acme.MyApp</key>
<string>match AppStore com.acme.MyApp</string>
</dict>
</dict>
</plist>
Key options:
method: app-store — for App Store / TestFlight. Other values: ad-hoc, enterprise, development.teamID — your team ID.uploadBitcode: false — bitcode was deprecated in Xcode 14. Always false now.uploadSymbols: true — upload dSYMs for crash symbolication (you want this).signingStyle: manual — explicit, matching our archive command.provisioningProfiles — dictionary mapping bundle IDs to profile names. For each app target (and any extensions, frameworks), specify the profile.If your app has extensions (Today extension, Watch app, etc.), each needs its own bundle ID and profile in this dict.
Putting archive + export together, with match for signing and Fastlane to glue it:
# .gitlab-ci.yml
stages:
- lint
- test
- build
- distribute
variables:
LANG: "en_US.UTF-8"
build-testflight:
stage: build
tags:
- macos
- ios
variables:
DEVELOPER_DIR: "/Applications/Xcode-15.4.app/Contents/Developer"
before_script:
- bundle install --path vendor/bundle
- bundle exec fastlane match appstore --readonly --shallow_clone
script:
- bundle exec fastlane gym \
--workspace MyApp.xcworkspace \
--scheme MyApp \
--configuration Release \
--export_method app-store \
--output_directory build \
--output_name MyApp.ipa \
--silent
artifacts:
name: "MyApp-${CI_COMMIT_SHORT_SHA}"
paths:
- build/MyApp.ipa
- build/MyApp.app.dSYM.zip
expire_in: 30 days
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "schedule"
Walking through:
tags: [macos, ios] — runs on iOS-capable runners.DEVELOPER_DIR — pin Xcode version. Don't trust the runner's default.before_script — install gem deps (fastlane is Ruby-gem-based) and pull signing artifacts.script — call fastlane gym, which wraps archive + export with sane defaults.artifacts — preserve the IPA and dSYM. The IPA is what we ship; the dSYM is for crash symbolication later.rules — only build on main branch or scheduled pipelines. Don't waste runners on every feature branch.fastlane gym (alias build_app) does archive + export in one command, reading Gymfile or command-line args. It's smarter than raw xcodebuild — handles many edge cases automatically.
If you prefer not to use fastlane:
build-testflight:
stage: build
tags:
- macos
- ios
variables:
DEVELOPER_DIR: "/Applications/Xcode-15.4.app/Contents/Developer"
before_script:
- bundle install --path vendor/bundle
- bundle exec fastlane match appstore --readonly --shallow_clone
script:
- mkdir -p build
- |
xcodebuild archive \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
-destination 'generic/platform=iOS' \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="iPhone Distribution: Acme Inc. (ABCDE12345)" \
PROVISIONING_PROFILE_SPECIFIER="match AppStore com.acme.MyApp" \
DEVELOPMENT_TEAM="ABCDE12345" | xcbeautify
- |
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/ipa \
-exportOptionsPlist ExportOptions.plist | xcbeautify
- cp build/ipa/MyApp.ipa build/MyApp.ipa
- zip -r build/MyApp.app.dSYM.zip build/MyApp.xcarchive/dSYMs
artifacts:
paths:
- build/MyApp.ipa
- build/MyApp.app.dSYM.zip
expire_in: 30 days
xcbeautify is a tool (alternative to xcpretty) that formats xcodebuild output to be readable. Without it, you get firehose verbose output that's hard to skim.
fastlane gym does roughly all of this. The fastlane version is shorter; the raw version is more explicit. Pick by team preference.
Every TestFlight build needs a unique build number. Apple rejects uploads with build numbers that have been seen before. For CI, automate this:
build-testflight:
# ... before_script ...
script:
- bundle exec fastlane run increment_build_number build_number:$CI_PIPELINE_IID
- bundle exec fastlane gym ...
$CI_PIPELINE_IID is GitLab's per-project pipeline counter — monotonically increasing. Using it as build number guarantees uniqueness across pipelines.
For semantic versioning, the marketing version (CFBundleShortVersionString) usually comes from your tags or a manually-edited Info.plist. Build number (CFBundleVersion) is what auto-increments.
A common scheme:
MARKETING_VERSION (CFBundleShortVersionString): 1.5.2 — set manually or from tag.CURRENT_PROJECT_VERSION (CFBundleVersion): $CI_PIPELINE_IID — auto.For tag-driven releases:
script:
- |
if [ -n "$CI_COMMIT_TAG" ]; then
VERSION="${CI_COMMIT_TAG#v}" # "v1.5.2" -> "1.5.2"
bundle exec fastlane run increment_version_number version_number:"$VERSION"
fi
- bundle exec fastlane run increment_build_number build_number:$CI_PIPELINE_IID
- bundle exec fastlane gym ...
Before shipping, verify the IPA is correctly signed:
codesign -dv --verbose=4 build/ipa/MyApp.app
# or for the IPA contents:
unzip -q build/ipa/MyApp.ipa -d /tmp/ipa-check
codesign -dv --verbose=4 /tmp/ipa-check/Payload/MyApp.app
This shows the signing identity, team ID, entitlements, and other signing metadata. A successful verification looks like:
Authority=Apple iPhone OS Application Signing
Authority=Apple iPhone Certification Authority
Authority=Apple Root CA
Signed Time=Jan 15, 2024 at 10:23:45 AM
Info.plist entries=...
TeamIdentifier=ABCDE12345
If signing is broken, you'll see error messages. Add a verification step in CI to fail early on signing problems:
script:
# ... build step ...
- codesign -dv --verbose=4 build/ipa/MyApp.app 2>&1 | grep "TeamIdentifier=ABCDE12345" || (echo "Signing verification failed" && exit 1)
The dSYM files are debug symbols. App Store Connect uses them to symbolicate crashes (turn 0x102345abc into [ViewController buttonTapped]). Without them, crash reports are useless.
Two paths:
uploadSymbols: true in ExportOptions.plist (already shown).For both, you need access to the dSYMs. Save them as artifacts. Common location after archive:
build/MyApp.xcarchive/dSYMs/MyApp.app.dSYM
Zip them up:
zip -r build/dsyms.zip build/MyApp.xcarchive/dSYMs/
Save as artifact. Use in subsequent jobs.
A real pipeline often builds different distribution types based on context:
build:
stage: build
tags: [macos, ios]
script:
- |
if [ -n "$CI_COMMIT_TAG" ]; then
EXPORT_METHOD="app-store"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
EXPORT_METHOD="app-store" # TestFlight
elif [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
EXPORT_METHOD="ad-hoc" # internal preview builds
else
EXPORT_METHOD="development"
fi
- bundle exec fastlane match $EXPORT_METHOD --readonly --shallow_clone
- bundle exec fastlane gym --export_method $EXPORT_METHOD
Tag → App Store. Main → TestFlight (still app-store method). MR → ad-hoc (internal device install). Else → development build.
Each export method needs its own match-installed certificates and profiles. Provisioning a new project means running match development, match adhoc, and match appstore once locally to populate the certs repo for all three.
Signing identity name doesn't match. The exact string matters. iPhone Distribution vs Apple Distribution (newer style) — get the wrong one, signing fails. Check Keychain Access on the runner.
Profile name mismatch. match-installed profiles are named match AppStore com.acme.MyApp. Match the exact name in your build commands and ExportOptions.plist.
Bundle ID mismatch. App's bundle ID, profile's bundle ID, and signing identity must align. A typo in any one breaks the build.
Forgetting to share the scheme. Schemes default to "user-only." CI can't see them. In Xcode: Product → Scheme → Manage Schemes → check the "Shared" column. Commit xcshareddata/xcschemes/MyApp.xcscheme.
dSYMs not exported. uploadSymbols: false in ExportOptions.plist drops them. You'll regret it later.
Build number not incrementing. Apple rejects uploads with duplicate build numbers per app version. Always increment per CI run.
Hardcoded paths. /Users/jenkins/... won't work when CI runs as gitlab-runner. Use relative paths.
A TestFlight build is xcodebuild archive + xcodebuild -exportArchive. Use Release configuration, manual signing, App Store distribution. ExportOptions.plist controls the export. fastlane gym wraps it cleanly. Always increment build numbers per CI run (use $CI_PIPELINE_IID). Save IPA and dSYMs as artifacts. Verify signing in CI to fail fast. Match different export methods to context (tag → app-store, MR → ad-hoc).
Now we have an IPA. The next step is uploading it to App Store Connect, where it appears in TestFlight for testers within minutes (usually). Several tools can do the upload; we'll cover the common ones and the production-ready patterns.
When you upload an IPA to App Store Connect:
The CI's job is the upload + setting metadata (test notes, tester groups). Apple handles the rest.
Three common tools:
xcrun altool (deprecated) — Apple's old command-line uploader. Still works but Apple is moving toward Transporter.xcrun notarytool — Apple's modern notarization tool, with upload capabilities for the App Store. Replaces altool.fastlane pilot (alias upload_to_testflight) — fastlane's wrapper, wrapping the underlying Apple tooling. Most ergonomic for CI. Handles release notes, tester groups, build management.For pipelines, fastlane pilot is the de facto choice. It's well-maintained, supports App Store Connect API authentication, and has handy options for what to do after upload.
deploy-testflight:
stage: distribute
tags: [macos, ios]
needs:
- build-testflight
variables:
DEVELOPER_DIR: "/Applications/Xcode-15.4.app/Contents/Developer"
before_script:
- bundle install --path vendor/bundle
script:
- |
bundle exec fastlane pilot upload \
--ipa build/MyApp.ipa \
--skip_waiting_for_build_processing \
--skip_submission \
--apple_id 1234567890 \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
rules:
- if: $CI_COMMIT_BRANCH == "main"
Walking through:
stage: distribute — runs after build.needs: [build-testflight] — depends on the build job; pulls its artifacts (the IPA).fastlane pilot upload — uploads to TestFlight.--ipa build/MyApp.ipa — the artifact path.--skip_waiting_for_build_processing — don't wait for Apple's processing. Frees up the runner faster. (We can verify processing later.)--skip_submission — don't submit for Beta App Review. We just want the build available; reviewers/PMs trigger review when ready.--apple_id 1234567890 — your app's App Store Connect Apple ID (a numeric ID, not your account email). Find it in App Store Connect → My Apps → your app → App Information.--api_key_path — App Store Connect API key file (covered in section 15).The job pulls the IPA from the build job's artifacts (because needs:), uploads it, and exits.
Already covered in section 15. Briefly: store the key as a File-type CI/CD variable. fastlane pilot and other fastlane actions can use it via --api_key_path or the global APP_STORE_CONNECT_API_KEY_PATH env var.
If you set APP_STORE_CONNECT_API_KEY_PATH once in .env or as a CI variable, you don't need --api_key_path per command. Cleaner:
variables:
APP_STORE_CONNECT_API_KEY_PATH: "$APP_STORE_CONNECT_API_KEY" # File variable
script:
- bundle exec fastlane pilot upload --ipa build/MyApp.ipa
But explicit --api_key_path is also fine. Personal preference.
TestFlight shows release notes to testers. By default, fastlane uploads with no notes. Provide them:
script:
- |
NOTES=$(cat <<EOF
Build $CI_PIPELINE_IID
Branch: $CI_COMMIT_BRANCH
Commit: $CI_COMMIT_SHORT_SHA
Recent changes:
$(git log --pretty=format:"- %s" -10)
EOF
)
- |
bundle exec fastlane pilot upload \
--ipa build/MyApp.ipa \
--changelog "$NOTES" \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
Now testers see "Build 423 / Branch: main / Commit: abc123 / Recent changes: ...". For a more curated experience, generate from a CHANGELOG.md or commit-prefix conventions.
By default, an upload goes only to internal testers (your team). To distribute to external testers (beta groups):
script:
- |
bundle exec fastlane pilot upload \
--ipa build/MyApp.ipa \
--groups "QA,Beta,Stakeholders" \
--skip_submission \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
--groups "Name1,Name2" adds the build to the named external tester groups in App Store Connect. Each must already exist (created in App Store Connect UI).
--skip_submission (or remove to submit) controls whether to immediately submit for Beta App Review. For first deploy of a new version, you may want auto-submit; for routine builds, manual submission gives you a review point.
Alternative: split the upload and group-association into two pipeline jobs, manually-triggered for the group association. Nice for "auto-deploy to internal, manual to external."
If your repo has multiple apps (e.g., main app + a watch companion or a separate enterprise build), each needs its own deploy:
deploy-testflight-app:
extends: .deploy_testflight
variables:
APP_IPA: "build/MyApp.ipa"
APP_ID: "1234567890"
script:
- bundle exec fastlane pilot upload --ipa $APP_IPA --apple_id $APP_ID --api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
deploy-testflight-enterprise:
extends: .deploy_testflight
variables:
APP_IPA: "build/MyApp-Enterprise.ipa"
APP_ID: "9876543210"
script:
- bundle exec fastlane pilot upload --ipa $APP_IPA --apple_id $APP_ID --api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
.deploy_testflight:
stage: distribute
tags: [macos, ios]
rules:
- if: $CI_COMMIT_BRANCH == "main"
Each gets its own job, sharing common configuration via extends. Section 22 covers extends in depth.
TestFlight upload failures fall into a few buckets:
retry: 2 in the GitLab job.Add retries:
deploy-testflight:
# ...
retry:
max: 2
when:
- runner_system_failure
- script_failure
script_failure means anything the script reported as a failure. Be careful — retrying a failed upload that almost succeeded can create duplicates. Apple deduplicates by build number, so it's usually safe.
After a successful deploy, notify the team:
deploy-testflight:
# ... (script that uploads) ...
after_script:
- |
if [ "$CI_JOB_STATUS" = "success" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"📱 New TestFlight build available! Build $CI_PIPELINE_IID is on its way to testers.\"}" \
$SLACK_WEBHOOK_URL
fi
$CI_JOB_STATUS is set by GitLab so after_script knows whether the main script succeeded. Section 27 covers richer integrations.
If you need to wait for Apple to finish processing (e.g., before notifying that the build is "ready for testing"):
script:
- |
bundle exec fastlane pilot upload \
--ipa build/MyApp.ipa \
--skip_submission \
--wait_for_uploaded_build \
--wait_processing_interval 60 \
--apple_id 1234567890 \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
--wait_for_uploaded_build waits until processing is complete. --wait_processing_interval 60 polls every 60 seconds. Adds 5-30 minutes to the job; in exchange, downstream tasks know the build is ready.
For most CI, skip the wait — the build will process while CI moves on. Notify-on-completion is a separate concern.
For ad-hoc invitations (less common with App Store Connect API):
script:
- |
bundle exec fastlane pilot upload \
--ipa build/MyApp.ipa \
--distribute_external \
--notify_external_testers true \
--skip_submission \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
--distribute_external makes the build available externally; --notify_external_testers true sends them email notification.
A full TestFlight deploy job with everything:
deploy-testflight:
stage: distribute
tags: [macos, ios]
needs:
- job: build-testflight
artifacts: true
variables:
DEVELOPER_DIR: "/Applications/Xcode-15.4.app/Contents/Developer"
before_script:
- bundle install --path vendor/bundle
script:
- |
RECENT_COMMITS=$(git log --pretty=format:"- %s" -10)
- |
bundle exec fastlane pilot upload \
--ipa build/MyApp.ipa \
--changelog "Build $CI_PIPELINE_IID from $CI_COMMIT_BRANCH ($CI_COMMIT_SHORT_SHA)\n\nRecent changes:\n$RECENT_COMMITS" \
--groups "Internal,QA" \
--skip_submission \
--skip_waiting_for_build_processing \
--apple_id 1234567890 \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
after_script:
- |
if [ "$CI_JOB_STATUS" = "success" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"📱 TestFlight build $CI_PIPELINE_IID uploaded\"}" \
$SLACK_WEBHOOK_URL
fi
rules:
- if: $CI_COMMIT_BRANCH == "main"
retry:
max: 1
when:
- script_failure
Robust. Real. Use as a starting point.
Wrong apple_id. This is the App Store Connect Apple ID, a number, not your developer-account email. Wrong number → upload to wrong app or fail.
API key with wrong scope. Some keys have read-only or limited scopes. Make sure your key has "App Manager" or "Admin" role for upload.
Massive release notes. Apple has size limits on release notes (4000 chars for App Store, less for TestFlight). Truncate aggressively.
Group name typos. --groups "QA" vs --groups "qa" — case-sensitive, exact match. A typo silently does nothing.
Forgetting --skip_submission. Without it, fastlane may submit for Beta Review automatically — sometimes desired, sometimes not. Be explicit.
Network instability mid-upload. Large IPAs over flaky networks fail. Retry. Or upload to a CDN / S3 first as a workaround for very large apps.
fastlane pilot upload is the standard way to ship to TestFlight from CI. Use the App Store Connect API key for authentication. Skip submission and wait-for-processing in routine pipelines for speed. Add release notes from commit history. Distribute to specific groups via --groups. Increment build numbers per pipeline. Notify Slack on success. Add a single retry for network blips. Always verify the App Store Connect Apple ID is correct.
TestFlight builds are practice; App Store releases are the real thing. The mechanism is similar — upload an IPA to App Store Connect — but there's metadata, screenshots, review submission, and stricter verification. Many teams handle these manually in App Store Connect for the final step; some automate the whole pipeline.
This section covers what's involved and how to scope automation to your team's appetite for risk.
The full release flow:
The first three are scriptable. Approval is Apple's. Release timing is your call.
In production iOS pipelines, common levels of automation:
Most teams settle around Level 1 or 2. Level 4 is for very mature pipelines with strong testing and clear rollback plans.
upload_to_app_store)The fastlane action for App Store interactions is deliver, also accessible as upload_to_app_store. Roughly the App Store equivalent of pilot.
deploy-app-store:
stage: distribute
tags: [macos, ios]
needs:
- job: build-testflight
artifacts: true
variables:
DEVELOPER_DIR: "/Applications/Xcode-15.4.app/Contents/Developer"
before_script:
- bundle install --path vendor/bundle
script:
- |
bundle exec fastlane deliver \
--ipa build/MyApp.ipa \
--skip_metadata false \
--skip_screenshots true \
--submit_for_review false \
--automatic_release false \
--force \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
What's happening:
--ipa build/MyApp.ipa — the binary.--skip_metadata false — upload metadata (description, what's new, etc.). What metadata? Whatever's in fastlane/metadata/.--skip_screenshots true — don't touch screenshots. Screenshots are typically handled separately (they're large, manual, and rarely need updating per release).--submit_for_review false — upload but don't submit for review. (Level 1 pattern.)--automatic_release false — don't auto-release on approval. You'll manually release.--force — don't prompt for confirmation. CI doesn't have a person to confirm.--api_key_path — auth.when: manual — must be manually triggered in the GitLab UI. App Store releases shouldn't be auto.Combined with rules: if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/, this job exists only on tag pipelines, and only when manually triggered. Tag a release v1.5.2, push the tag, see the pipeline, manually click "Run" on the deploy-app-store job.
Fastlane stores metadata in fastlane/metadata/:
fastlane/
metadata/
en-US/
name.txt
subtitle.txt
description.txt
keywords.txt
release_notes.txt
promotional_text.txt
privacy_url.txt
support_url.txt
fr-FR/
...
One folder per locale. Each text file is the corresponding App Store Connect field.
Initialize from current App Store metadata:
bundle exec fastlane deliver download_metadata
This fetches existing metadata and writes the files. Edit them in your repo; CI uploads on each release.
For release notes specifically:
# fastlane/metadata/en-US/release_notes.txt
- New: Photo backup with iCloud sync
- Improved: Search results 50% faster
- Fixed: Crash on iPad rotation
You can generate release notes from CHANGELOG.md or git logs and write to this file before invoking deliver. Or maintain manually.
Screenshots are different — usually one set per device size (iPhone 6.7", iPhone 6.5", iPad 12.9", etc.). Generated from your design tool and committed to:
fastlane/
screenshots/
en-US/
iPhone 6.5/
01-home.png
02-detail.png
iPad 12.9/
...
Some teams generate screenshots automatically with fastlane snapshot (UI-test based). For most apps, screenshots are manually crafted and updated rarely; CI uploads but doesn't generate.
To upload screenshots:
bundle exec fastlane deliver --skip_screenshots false
Most teams skip screenshots in CI (--skip_screenshots true) and update them via dedicated, manual workflows when designs change.
Fastlane will auto-create the next version in App Store Connect if needed. Your IPA's CFBundleShortVersionString (the marketing version) determines which App Store Connect version to target.
If 1.5.2 doesn't exist in App Store Connect, deliver creates it. If it exists, deliver updates it. Either way, the version-uploaded-to-Apple matches the IPA.
When you're ready to submit:
submit-for-review:
stage: distribute
tags: [macos, ios]
variables:
DEVELOPER_DIR: "/Applications/Xcode-15.4.app/Contents/Developer"
before_script:
- bundle install --path vendor/bundle
script:
- |
bundle exec fastlane deliver \
--skip_binary_upload \
--skip_metadata \
--skip_screenshots \
--submit_for_review \
--automatic_release false \
--force \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
This skips uploads (you've already uploaded in the prior job) and submits the existing version for review.
Splitting into two manual stages — upload, then submit — gives you a chance to verify the App Store Connect listing before submitting.
After approval, you can:
Phased release is excellent for catching post-release bugs in early percentages. Configure in App Store Connect or via fastlane:
script:
- |
bundle exec fastlane deliver \
--phased_release true \
--automatic_release false \
--force \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
Phased release: 1% on day 1, 2% on day 2, ... 100% on day 7. You can pause if you spot crashes.
For reviews, App Store Connect needs reviewer information — demo account, contact info, notes. Configure in fastlane/Deliverfile:
# fastlane/Deliverfile
app_review_information(
first_name: "John",
last_name: "Reviewer",
phone_number: "+1-555-555-5555",
email_address: "review@example.com",
demo_user: "demo@example.com",
demo_password: "DemoPass123",
notes: "The app requires login. Use demo credentials above. The Settings tab contains a 'Reset' button to wipe demo data."
)
This is uploaded with metadata. Don't put real demo passwords in CI logs — use a CI variable:
demo_password: ENV["APP_REVIEW_DEMO_PASSWORD"]
And in GitLab CI/CD variables, store APP_REVIEW_DEMO_PASSWORD as masked and protected.
If you've uploaded multiple builds via TestFlight, deliver picks the latest by default. To pick a specific build:
script:
- |
bundle exec fastlane deliver \
--build_number $CI_PIPELINE_IID \
--app_version "1.5.2" \
--skip_binary_upload \
--submit_for_review \
--force \
--api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
--build_number selects the exact TestFlight build. --app_version specifies which App Store version to associate. Useful if your team uses TestFlight as the staging area and promotes specific builds to App Store.
Hotfix release. Tag v1.5.3 after fixing a critical bug. Tag pipeline runs build + manual submit. You verify TestFlight, click submit-for-review, wait 24h, release.
Feature release. Tag v1.6.0 after merging features. Same flow.
Coordinated release. Tag, build, submit, but hold release for marketing. Set --automatic_release false; manually release at coordinated time.
Gradual rollout. Submit with --phased_release true. Monitor crashes/feedback. Pause if necessary.
Auto-submit on every tag. Most teams want a manual gate. Use when: manual.
Forgetting metadata for a new locale. If the app supports French and you forgot fr-FR/release_notes.txt, deliver fails. Maintain locale parity.
Outdated reviewer notes. Reviewers test with the credentials you provide. Stale credentials → review fails → "we couldn't sign in."
Releasing when you didn't mean to. Always --automatic_release false unless you specifically want auto. The default behavior of submit_for_review: true + automatic_release: true releases the second Apple approves — at 4 AM, on a Saturday, on whatever day they happen to approve.
Screenshot uploads with mismatched dimensions. Apple is strict on image sizes per device. Verify before uploading.
force: true skipping useful prompts. In CI, you must use force: true to avoid prompts. But it also skips confirmations like "this will overwrite metadata, are you sure?" Be intentional.
Wrong app ID in fastlane config. App Store Connect targeting the wrong app means uploading to the wrong place. Triple-check.
App Store releases are the same upload mechanism as TestFlight + metadata + submission. Most teams keep this manually-triggered (when: manual) on tag pipelines. Use fastlane deliver to upload IPAs and metadata. Maintain fastlane/metadata/ in your repo. Skip screenshots in routine pipelines. Submit-for-review and release are separate operations; split into manual jobs for safety. Phased rollout is your friend for catching regressions. Always have demo credentials current.
When does a pipeline run? When does a specific job run? GitLab gives you several mechanisms to control this — historically only/except, modernly rules. Getting them right is the difference between "CI runs efficiently on the right events" and "CI burns runner minutes on every accidental push."
For iOS specifically, you don't want full archive-and-deploy pipelines on every feature branch push. You want fast feedback (lint, test) on MRs and full deploys on main / tags. Rules are how you express that.
only/except — the legacy wayOlder pipelines used only and except:
build:
script:
- xcodebuild build ...
only:
- main
- develop
except:
- tags
This runs on main or develop branches but not on tags.
only and except still work but are considered deprecated. New code should use rules. Mixing the two in one job is forbidden.
rules — the modern wayrules is more powerful and more explicit:
build:
script:
- xcodebuild build ...
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Each rule is evaluated in order. The first match determines the job's behavior. If no rule matches, the job is skipped.
A rule has these keys:
if: — a condition (string evaluating GitLab CI variables). Like a shell condition.changes: — list of file paths; rule matches if any of those files changed.exists: — list of file paths; rule matches if any exist.when: — when to run if the rule matches: on_success (default), on_failure, always, manual, never.allow_failure: — whether the job's failure should fail the pipeline.variables: — additional variables to set when the rule matches.Fast feedback on MRs only:
lint:
script:
- swiftlint
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Build and test on MR or main:
build-and-test:
script:
- xcodebuild test ...
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
TestFlight deploy on main only:
deploy-testflight:
script:
- bundle exec fastlane pilot upload ...
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
App Store deploy on tag, manually:
deploy-app-store:
script:
- bundle exec fastlane deliver ...
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
The pipeline appears (because the rule matches the tag), but the job is manually triggered (won't run until you click "play").
Skip CI on [skip ci] commits:
GitLab respects [skip ci] automatically — pipelines don't trigger for commits with that in the message. No rule needed.
Build only when iOS files changed:
build:
script:
- xcodebuild ...
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- "**/*.swift"
- "**/*.m"
- "Podfile.lock"
- "Package.resolved"
- "*.xcodeproj/**"
- "*.xcworkspace/**"
Useful for monorepos where iOS code changes are infrequent. Don't run iOS pipelines for backend-only PRs.
Different jobs per branch type:
build-debug:
script:
- xcodebuild build -configuration Debug
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
build-release:
script:
- xcodebuild archive -configuration Release
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
Debug builds for MRs (faster, less work); Release builds for main/tags (real distribution).
Variables you'll use heavily in if::
$CI_COMMIT_BRANCH — set when running on a branch (not tag, not MR).$CI_DEFAULT_BRANCH — usually main. Useful as $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH.$CI_COMMIT_TAG — set when running on a tag.$CI_PIPELINE_SOURCE — what triggered the pipeline: push, merge_request_event, schedule, web, api, pipeline.$CI_MERGE_REQUEST_TARGET_BRANCH_NAME — when in MR pipeline, the MR's target branch.$CI_PIPELINE_IID — monotonically increasing pipeline counter.$CI_COMMIT_MESSAGE — the commit message.A few comparison patterns:
rules:
- if: '$CI_COMMIT_BRANCH == "main"' # exact match
- if: '$CI_COMMIT_BRANCH =~ /^feature\//' # regex
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+(-rc\d+)?$/' # complex regex
- if: '$CI_COMMIT_BRANCH != null' # not in MR/tag
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
The single-quote-wrapped form is safer (avoids YAML escaping issues with special characters).
when — execution controlWithin a rule, when controls how the job runs:
on_success (default) — run if previous stages succeeded.on_failure — run if a previous stage failed. Useful for cleanup.always — run regardless of previous stages.manual — wait for someone to click "play" in the UI.never — explicit "don't run." Useful in compound rules.delayed with start_in: — run after a delay (e.g., start_in: 30 minutes).A "build always, test on MR" pattern with when: never:
expensive-test:
script:
- bundle exec fastlane test
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: on_success
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: on_success
- when: never # default: don't run
Without that final when: never, the job would still match nothing, which means it's skipped. The explicit never is for clarity.
allow_failure — non-blocking jobsFor optional checks:
flaky-ui-test:
script:
- xcodebuild test -only-testing:UITests/FlakyTest ...
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
The job runs, but if it fails, the pipeline is still "successful" (the job appears with a yellow warning rather than red). Use sparingly — flaky tests should be fixed, not allowed.
For broader use:
publish-docs:
script:
- jazzy
rules:
- if: $CI_COMMIT_BRANCH == "main"
allow_failure: true
Documentation publishing fails? Pipeline still passes. Logical for non-critical secondary jobs.
workflow:rules controls whether the entire pipeline runs. rules per job controls whether that job runs.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
build:
script:
- xcodebuild ...
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- "**/*.swift"
workflow:rules says: only run pipelines on MRs, main, or tags. (No "every push to feature branches" pipelines.) Within an allowed pipeline, the build job runs only on MRs with Swift changes.
This combination — workflow rules to gate the entire pipeline + per-job rules for fine control — is the modern best practice.
A common annoyance: pushing a feature branch and immediately opening an MR creates two pipelines — one branch pipeline, one MR pipeline. workflow:rules handles this:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_PIPELINE_SOURCE == "push" && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
Translation: run MR pipelines; never run branch pipelines for branches that have an open MR; run branch pipelines for branches without MRs (rare — main, tags).
Variations exist; check current GitLab docs for exact recommendations as the platform evolves.
For jobs that should be conditionally renamed or parallelized:
test:
script:
- bundle exec fastlane scan --device "iPhone 15"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
PARALLEL_DEVICES: "1"
- if: $CI_COMMIT_BRANCH == "main"
variables:
PARALLEL_DEVICES: "3"
parallel:
matrix:
- DEVICE: ["iPhone 15", "iPhone 15 Pro", "iPad Pro"]
(Section 24 covers parallel matrix in depth.)
GitLab's scheduled pipelines run on cron-like schedules. Configure in the GitLab UI: project → CI/CD → Schedules.
In your pipeline, use $CI_PIPELINE_SOURCE == "schedule" to gate work:
nightly-test:
script:
- bundle exec fastlane scan
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
Useful for:
dependencies and needsTwo different concepts often confused:
dependencies: — what artifacts a job needs from other jobs. Default: all from previous stages.needs: — explicit job-level dependencies; allows skipping stages or running out of order.build:
stage: build
script:
- xcodebuild ...
artifacts:
paths:
- build/
deploy:
stage: deploy
needs:
- job: build
artifacts: true
script:
- upload build/MyApp.ipa
needs lets deploy start as soon as build finishes, ignoring other jobs in the build stage. Useful for fast pipelines.
If you only dependencies: [], the job has no artifact dependencies. By default, jobs inherit artifacts from all previous-stage jobs — sometimes more than needed.
Quoted vs unquoted variables. $CI_COMMIT_BRANCH == main (unquoted main) is a YAML keyword to GitLab, not the string. Use $CI_COMMIT_BRANCH == "main" or '$CI_COMMIT_BRANCH == "main"'.
Forgetting workflow:rules. Without it, every push to every branch runs the pipeline. Wasted minutes.
only: and rules: mixed. Forbidden in the same job. Convert all-or-nothing.
Order matters. rules evaluates top-down; first match wins. A broad rule before a narrow one means the narrow one never matches.
when: manual without confirmation. Manual jobs can be triggered by anyone with developer access — including by mistake. For production deploys, also consider protected: true on the variables they need.
Regex anchoring. =~ /v\d+/ matches "this is v1 of the document"! Anchor with ^ and $: =~ /^v\d+\.\d+\.\d+$/.
Use rules (not only/except). Each rule has if, optionally changes, when, allow_failure. Use workflow:rules to gate the whole pipeline; per-job rules for individual jobs. Common variables: $CI_COMMIT_BRANCH, $CI_COMMIT_TAG, $CI_PIPELINE_SOURCE, $CI_MERGE_REQUEST_TARGET_BRANCH_NAME. when: manual for dangerous deploys. allow_failure: true for non-blocking checks. Avoid double pipelines via workflow rules. Schedules trigger via $CI_PIPELINE_SOURCE == "schedule".
Merge request pipelines are how you enforce quality before code lands. Open an MR → pipeline runs → MR can't merge until pipeline passes (if you've configured that). For iOS teams, this means lint, test, and possibly preview builds run automatically on every MR.
There's nuance: branch pipelines vs MR pipelines, "merged result" pipelines, and merge trains. Knowing the differences avoids unexpected behavior.
When you push a commit to a branch:
These are distinct. Their $CI_PIPELINE_SOURCE differs:
pushmerge_request_eventThe MR pipeline's $CI_COMMIT_REF_NAME is the source branch (your feature branch). Other variables like $CI_MERGE_REQUEST_TARGET_BRANCH_NAME are populated.
For MR pipelines to run, jobs must include the MR rule:
test:
script:
- bundle exec fastlane scan
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Without that first rule, the test job won't run on MR pipelines. (It would run on branch pipelines, but if you've disabled those via workflow rules, you need the MR rule.)
By default, MR pipelines build the source branch's code as-is. But what if main has moved on? Your branch might be 5 commits behind; tests pass against your branch but not against main + your changes.
Merged result pipelines test the result of merging your MR — i.e., what main would look like after the merge. Configure in project Settings → Merge requests → "Pipelines for merged results."
Once enabled, MR pipelines run against an ephemeral merge commit. If main has new commits since you branched, those are included.
$CI_MERGE_REQUEST_EVENT_TYPE is merge_train or merged_result accordingly.
Merge trains take it further: they queue up multiple MRs and run them as a serial train. Each MR's pipeline includes the previous MR's changes. This catches conflicts where two independently-passing MRs together break things.
Merge trains are powerful for high-velocity teams but add complexity. For most iOS teams, plain merged result pipelines are sufficient.
To enforce "MR can't merge until pipeline passes":
Project Settings → Merge requests → "Pipelines must succeed" — toggle on. Now the merge button is disabled until the latest MR pipeline succeeds.
For more granular control, GitLab Premium has "merge request approvals from CODEOWNERS" and "external status checks." For most teams, the basic gate is enough.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
stages:
- lint
- test
- build
lint:
stage: lint
tags: [macos, ios]
script:
- swiftlint --strict
test-unit:
stage: test
tags: [macos, ios]
script:
- bundle exec fastlane scan --scheme MyApp --testplan UnitTests --output_directory test_output
artifacts:
when: always
paths:
- test_output/
reports:
junit: test_output/report.junit
test-snapshot:
stage: test
tags: [macos, ios]
script:
- bundle exec fastlane scan --scheme MyApp --testplan SnapshotTests
artifacts:
when: on_failure
paths:
- "**/__Snapshots__/__Failures__/"
expire_in: 7 days
build-preview:
stage: build
tags: [macos, ios]
needs: [lint, test-unit]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- bundle exec fastlane match adhoc --readonly
- bundle exec fastlane gym --export_method ad-hoc
artifacts:
paths:
- build/MyApp.ipa
This pipeline:
The build-preview job creates an installable build that anyone with an Ad Hoc-provisioned device can run. Useful for design reviews, QA, stakeholder previews.
The IPA from build-preview is artifacted but unwieldy to install. Make it easier:
bundle exec fastlane firebase_app_distribution uploads to Firebase, which sends installation invitations to testers.chmod on Mac, install via Xcode. Slowest but free.For each MR, the preview build URL appears as an MR comment via integration. Fastlane can post automatically:
build-preview:
# ... build script ...
script:
- bundle exec fastlane gym --export_method ad-hoc
- bundle exec fastlane firebase_app_distribution \
--ipa_path build/MyApp.ipa \
--app "$FIREBASE_APP_ID" \
--groups "qa-testers" \
--release_notes "MR !$CI_MERGE_REQUEST_IID by $CI_MERGE_REQUEST_AUTHOR"
GitLab shows pipeline status next to each MR:
allow_failure jobs failed).Hover over the icon for details. Click into the pipeline for full job logs.
When an MR pipeline fails:
Common iOS-specific failures:
For flaky tests, retry: 1 on the test job auto-retries once before failing. For genuinely intermittent issues, this is fine; for systematically flaky tests, fix them.
Draft MRs (formerly WIP) signal "not ready for review." You can skip CI for them:
workflow:
rules:
- if: $CI_MERGE_REQUEST_TITLE =~ /^Draft:/
when: never
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Now drafts skip CI. When the author marks ready, the next push triggers CI normally.
This saves runner time during early MR work but means the MR isn't continually validated. Trade-off.
The MR pipeline is "detached" — the pipeline page shows it's associated with the MR rather than a specific commit on the branch. Detached pipelines exist independently; they're not part of the branch's pipeline history.
If you cancel a detached MR pipeline, the branch's most recent push still has its own pipeline (if you have branch pipelines enabled). The MR pipeline being canceled doesn't affect the branch pipeline.
MR pipelines should be fast. Target: 10-15 minutes max for a feedback-loop pipeline. Slower than that, and developers context-switch and forget what they were doing.
Tactics for fast MR pipelines:
needs: to skip stages. Don't make build wait for lint if they're independent.changes: to run only when relevant files changed.A typical iOS MR pipeline targets:
MR pipelines not running. Check workflow:rules — does it include MRs? Check job rules. Common issue: jobs only have branch rules, not MR rules.
Two pipelines per MR push (branch + MR). Wasted minutes. Set workflow rules to skip branch pipelines when an MR is open (covered in section 19).
Test failures only on CI. Locally tests pass; CI fails. Often: simulator state, env vars, file path differences. Reproduce with the exact CI command line.
Merged result pipeline failing on conflicts. If your branch is far behind main, the merged result might have conflicts. Rebase your branch.
Preview build doesn't install on tester's device. Ad Hoc requires the device's UDID in the provisioning profile. Add the device, regenerate the profile, rebuild.
Pipeline runs but takes 30 minutes. Optimize. Section 28 covers this. The key insights: parallelize, cache, skip irrelevant work.
MR pipelines run on $CI_PIPELINE_SOURCE == "merge_request_event". Enable "merged result pipelines" for testing the merge-against-main outcome. Gate merges on pipeline success in project settings. Build preview IPAs for MRs; distribute via Firebase or similar. Skip drafts. Fast feedback is paramount — target sub-15-minute MR pipelines via parallelism, caching, and skipping irrelevant work. Branch pipelines and MR pipelines are distinct; configure workflow rules to avoid duplication.
Some pipeline steps are too dangerous for automatic execution. Releasing to the App Store. Pushing to production. Cleaning up artifacts. Manual jobs gate these on a human click — someone must explicitly trigger the work in the GitLab UI.
This section covers manual jobs, approvals, and patterns for human-in-the-loop pipelines.
The simplest form:
deploy-app-store:
stage: distribute
tags: [macos, ios]
script:
- bundle exec fastlane deliver --submit_for_review --automatic_release false
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
In a tag pipeline, this job appears but doesn't auto-run. A "play" button in the GitLab pipeline UI lets someone trigger it.
Manual jobs are tracked in the pipeline state. They block the pipeline only if allow_failure is false (the default), meaning a never-clicked manual job leaves the pipeline in "manual" state — neither passed nor failed.
Common scenarios for manual jobs:
A typical iOS App Store flow with multiple manual gates:
stages:
- lint
- test
- build
- upload
- submit
- release
# ... lint, test, build jobs ...
upload-app-store:
stage: upload
tags: [macos, ios]
needs: [build-release]
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- bundle exec fastlane deliver --submit_for_review false --skip_metadata --skip_screenshots --force --api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
submit-for-review:
stage: submit
tags: [macos, ios]
needs: [upload-app-store]
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
script:
- bundle exec fastlane deliver --skip_binary_upload --submit_for_review true --automatic_release false --force --api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
release-to-users:
stage: release
tags: [macos, ios]
needs: [submit-for-review]
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
script:
- bundle exec fastlane deliver --skip_binary_upload --release_to_app_store --force --api_key_path "$APP_STORE_CONNECT_API_KEY_PATH"
Three manual gates:
This gives clear human checkpoints. CI does the work; humans make the decisions.
allow_failure: true on manual jobsIf you want manual jobs to be optional — pipeline succeeds whether or not they're triggered — use allow_failure:
deploy-staging:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
Without allow_failure, the manual job leaves the pipeline as "blocked" indefinitely. With it, the pipeline resolves to "passed" with a manual job pending.
This is for jobs that are nice-to-have but not required.
protected: true for sensitive manual jobsTo restrict who can trigger a manual job, use protected: true on the variables it depends on (e.g., the App Store Connect API key). Combined with branch protection on main and the protected tag pattern, this means:
In the GitLab UI:
main as protected, choose who can push and merge.For "no one but maintainers can deploy to App Store":
release-to-users:
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
script:
- bundle exec fastlane deliver ...
variables:
APP_STORE_CONNECT_API_KEY_PATH: "$APP_STORE_CONNECT_API_KEY" # protected var
When a Developer triggers this, GitLab notices the variable is protected and the user lacks privilege; the job fails before running.
GitLab Premium has Approval Rules for MRs (require N approvers, specific code owners, etc.). For free-tier teams who want gated deployments, use this pattern:
For more formal approvals, integrate with external systems via webhooks or external status checks.
start_in: for delayed manual jobsSometimes you want "auto-trigger but with a window to cancel":
deploy-staging:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: delayed
start_in: 5 minutes
when: delayed plus start_in: 5 minutes schedules the job to run in 5 minutes. During those 5 minutes, you can cancel from the UI. After that, it runs automatically.
Useful for "I want to deploy unless someone yells stop." Less useful in iOS-specific pipelines but a good tool to know.
A pipeline with manual jobs can be in states:
Manual jobs that haven't been triggered don't fail the pipeline (unless they have allow_failure: false, which is the default — but in practice, they wait indefinitely without "failing").
For automation that needs "all is done" status — like notifying Slack the release is complete — wait for the last manual job's success, not just pipeline status:
notify-released:
stage: notify
needs: [release-to-users]
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: on_success # only after release-to-users completes
script:
- curl -X POST $SLACK_WEBHOOK_URL ...
when: on_success means "after the needed jobs succeed." release-to-users only runs when manually triggered, so notify-released waits for that manual click.
Forgetting when: manual on dangerous jobs. Auto-running an App Store release on every tag is terrifying. Tag pipelines should always require manual confirmation for production deploys.
Relying on manual jobs as security. Anyone with developer access can trigger a manual job. Use protected: true on variables for true security.
Pipeline showing "manual" when you expected "passed." Some teams find this confusing — the pipeline isn't done until someone clicks. Either accept the manual state or use allow_failure: true for non-blocking optionalities.
Manual job that times out. GitLab has a max pipeline duration. If you forget about a manual job for a week, eventually the pipeline expires.
when: manual jobs with needs: not in the dependency graph. A manual job's needs: must be from prior stages or other jobs that have run. If those don't run (because of rules), the manual job won't be triggerable.
when: manual makes a job require human action via a play button. Use for production deploys, App Store releases, dangerous operations. Combine with protected: true variables to restrict who can trigger. allow_failure: true on manual jobs makes them optional. Multiple manual gates (upload → submit → release) give clear human checkpoints. when: delayed + start_in: for "auto-deploy with a cancel window."
GitLab's "environments" feature tracks where deployments go and what version is live. For iOS, this maps to: Development builds (internal previews), Staging (TestFlight beta tracks), Production (App Store releases). Environments give you visibility — at a glance, you can see "what version is on TestFlight right now?" — and rollback capabilities.
In GitLab, an environment represents a deployment target. Examples:
production — the App Store.staging — TestFlight.internal — Firebase App Distribution to internal team.review/feature-x — preview build for a specific MR.Each environment tracks deployments — every time a job marked with that environment succeeds, GitLab records the deployment with the commit, build artifacts, and timestamp. You can see environment history, see which version is currently deployed, and trigger redeploys.
Add environment: to a deploy job:
deploy-testflight:
stage: distribute
tags: [macos, ios]
needs: [build-release]
script:
- bundle exec fastlane pilot upload --ipa build/MyApp.ipa
environment:
name: staging
url: https://appstoreconnect.apple.com/apps/1234567890
rules:
- if: $CI_COMMIT_BRANCH == "main"
Now every successful run creates a deployment record in the staging environment. The url is shown in the GitLab UI as a clickable link — pointing to App Store Connect for this deployment.
For App Store:
release-app-store:
stage: distribute
tags: [macos, ios]
script:
- bundle exec fastlane deliver --release_to_app_store --force
environment:
name: production
url: https://apps.apple.com/app/id1234567890
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
The production environment URL points to the App Store listing.
Project → Operate → Environments. Lists all environments, current deployment, history, links.
For each, you see:
For preview builds, each MR gets its own environment:
deploy-preview:
stage: distribute
tags: [macos, ios]
needs: [build-preview]
script:
- bundle exec fastlane firebase_app_distribution \
--ipa_path build/MyApp.ipa \
--release_notes "MR !$CI_MERGE_REQUEST_IID"
environment:
name: review/$CI_MERGE_REQUEST_IID
url: https://firebase.example.com/builds/$CI_PIPELINE_ID
on_stop: stop-preview
auto_stop_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop-preview:
stage: distribute
script:
- echo "Preview environment stopped"
environment:
name: review/$CI_MERGE_REQUEST_IID
action: stop
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
The interesting bits:
name: review/$CI_MERGE_REQUEST_IID — environment name uniquely tied to the MR. Each MR gets its own.url: — link to the build (Firebase URL or wherever).on_stop: stop-preview — the job that "stops" this environment.auto_stop_in: 1 week — auto-call the stop job after 1 week.stop-preview is a when: manual job that does any cleanup (you might revoke Firebase distribution, remove from a beta program, etc.).
The result: each MR shows up as its own environment in GitLab. Closing the MR auto-stops the environment after the configured time.
GitLab tracks not just "did the deploy job succeed" but specifically what was deployed. The deployment is associated with:
tier: (production / staging / development / testing / other).deploy-production:
environment:
name: production
deployment_tier: production
url: https://apps.apple.com/app/id1234567890
deployment_tier helps GitLab classify environments for analytics like "MTTR" (mean time to recover) and other reports.
By default, two pipelines can deploy to the same environment simultaneously. For App Store, you don't want that.
deploy-production:
resource_group: production
environment:
name: production
resource_group: production ensures only one job with that resource_group runs at a time. Multiple pipelines fighting for the same group serialize.
For iOS specifically:
resource_group: testflight — TestFlight deploys serialize. Sensible because Apple doesn't like rapid concurrent uploads.resource_group: app-store — App Store deploys serialize.To rollback:
production.This re-runs the original deploy job, which re-builds (with the old commit's code) and re-deploys. For App Store, this isn't perfect — you can't actually rollback an approved App Store release, but you can prepare a rollback build and submit it as a hotfix.
For TestFlight, the previous build remains available; rollback isn't strictly needed.
For Firebase or internal distribution, redeploying the old version effectively rolls back.
For environments that should be manually triggered:
deploy-staging:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
environment: staging
script:
- bundle exec fastlane pilot upload ...
deploy-production:
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
environment: production
script:
- bundle exec fastlane deliver --release_to_app_store ...
Each is manually triggered. The pipeline shows them as "manual" jobs; clicking play deploys.
CI/CD variables can be scoped to specific environments. Configure in Settings → CI/CD → Variables → "Environment scope."
Examples:
APP_STORE_CONNECT_API_KEY — scoped to production only.FIREBASE_APP_ID_STAGING — scoped to staging only.FIREBASE_APP_ID_PROD — scoped to production.A job with environment: staging only sees the staging-scoped variables. A job with environment: production only sees production-scoped variables. Prevents accidentally using staging credentials in production or vice versa.
Inside a job, the environment URL is in $CI_ENVIRONMENT_URL:
deploy-testflight:
environment:
name: staging
url: https://appstoreconnect.apple.com/apps/1234567890
script:
- bundle exec fastlane pilot upload --ipa build/MyApp.ipa
- echo "Deployed to $CI_ENVIRONMENT_NAME"
- echo "View at $CI_ENVIRONMENT_URL"
Useful for notifications:
script:
- bundle exec fastlane pilot upload --ipa build/MyApp.ipa
- |
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Deployed to $CI_ENVIRONMENT_NAME — $CI_ENVIRONMENT_URL\"}" \
$SLACK_WEBHOOK_URL
Environments don't auto-delete. Old preview environments accumulate. Configure auto-stop:
deploy-preview:
environment:
name: review/$CI_MERGE_REQUEST_IID
auto_stop_in: 2 weeks
After 2 weeks of inactivity, GitLab calls the on_stop job (or marks the environment stopped if no on_stop).
For static environments like staging and production, no auto-stop — they persist until you manually delete.
To delete an environment: GitLab UI → Environments → click the trash icon. Be careful; this loses deployment history.
Without environments, your pipeline runs the deploy job and that's it. With environments:
resource_group.For small teams, environments feel like overhead. For larger or longer-lived projects, they pay back in operational visibility.
Forgetting to set environment:. Deploy jobs work but GitLab doesn't track them. You lose the dashboard and rollback.
Hardcoding URLs that change. A staging URL points to your TestFlight; if you change accounts or app IDs, the URL is stale. Use variables or skip the URL.
Resource groups too narrow. If resource_group: production covers many separate apps, they all serialize unnecessarily. Scope per-app.
Auto-stop on production environments. You don't want production auto-stopping. Configure auto-stop only for ephemeral environments like review apps.
Environment-scoped variables not loading. Check the scope is exact (production, not production/*). Wildcards work but are tricky.
Environments track deployments. Each deploy job declares an environment: with a name and optional URL. GitLab records the deployment, shows history, allows rollback. Use staging for TestFlight, production for App Store, review/* for preview builds. resource_group prevents concurrent deploys. auto_stop_in cleans up ephemeral environments. Environment-scoped CI variables protect production credentials. The dashboard is where teams see "what's where."
Quality gates are CI's way of saying "this MR can't merge unless X." SwiftLint catches style issues. Tests catch logic bugs. Coverage thresholds catch untested code. Set up these gates correctly, and your MR queue is filtered to actually-good code.
SwiftLint analyzes Swift files for style and convention violations. Configure with .swiftlint.yml:
# .swiftlint.yml
disabled_rules:
- line_length # we set our own below
opt_in_rules:
- empty_count
- explicit_init
- first_where
- sorted_imports
line_length:
warning: 120
error: 200
identifier_name:
excluded:
- id
- to
excluded:
- Carthage
- Pods
- .build
- "*.generated.swift"
In CI:
lint:
stage: lint
tags: [macos, ios]
script:
- swiftlint --strict --reporter junit > swiftlint-report.xml
artifacts:
when: always
reports:
junit: swiftlint-report.xml
Notes:
--strict means warnings become errors. Your CI fails if lint produces any output. For mature codebases, this is the right setting.--reporter junit outputs JUnit-format XML. GitLab ingests this and shows lint errors in the MR's "Tests" tab.reports: junit: is how GitLab displays the report in the MR.Now lint failures show up nicely in the MR. Each violation links back to the file and line.
Most teams install SwiftLint via Homebrew on the runner:
brew install swiftlint
Or pin to a specific version via Mintfile (with mint package manager) or via a Brewfile:
# Brewfile
brew "swiftlint"
brew "swiftformat"
brew "git"
brew bundle # installs everything in Brewfile
For exact version pinning, use mint:
lint:
before_script:
- mint install realm/SwiftLint@0.55.1
script:
- mint run swiftlint --strict
Pinning matters — SwiftLint releases sometimes change rule behavior.
SwiftFormat reformats files instead of just reporting:
format-check:
stage: lint
tags: [macos, ios]
script:
- swiftformat --lint .
--lint mode reports diffs without modifying files; CI fails if any file would change. Encourages developers to run swiftformat locally before pushing.
For the strictest form, both linters run as gates:
quality:
stage: lint
tags: [macos, ios]
parallel:
matrix:
- TOOL: ["swiftlint", "swiftformat"]
script:
- |
if [ "$TOOL" = "swiftlint" ]; then
swiftlint --strict
else
swiftformat --lint .
fi
(parallel:matrix covered in section 24.)
Test failures should block merges. The default behavior — test job fails → pipeline fails → MR can't merge — is exactly right. Just make sure project Settings → Merge requests → "Pipelines must succeed" is on.
For test reports:
test:
stage: test
tags: [macos, ios]
script:
- bundle exec fastlane scan --output_directory test_output
artifacts:
when: always
paths:
- test_output/
reports:
junit: test_output/report.junit
scan (fastlane) generates a JUnit report. GitLab ingests it; failures appear in the MR's Tests tab with stacktraces.
For broader test reporting (xcresult parsing), tools like xcresultparser or xcparse can convert xcresult bundles to JUnit:
script:
- bundle exec fastlane scan --output_directory test_output
- xcparse junit test_output/MyApp.xcresult > test_output/junit.xml
artifacts:
reports:
junit: test_output/junit.xml
Code coverage gates require:
Section 24 covers the parsing in detail. Briefly:
test:
script:
- bundle exec fastlane scan --code_coverage --output_directory test_output
- xcrun xccov view --report --json test_output/MyApp.xcresult > coverage.json
- python3 .ci/parse-coverage.py coverage.json
coverage: '/Total coverage: (\d+\.\d+)%/'
The coverage: regex extracts the percentage from job output. GitLab stores it, displays it on the MR ("Coverage 78.5%, +2.3%"), and graphs it over time.
For threshold gates, fail the job if coverage drops:
# .ci/parse-coverage.py
import json, sys
with open(sys.argv[1]) as f:
data = json.load(f)
coverage = data['lineCoverage'] * 100
threshold = 70.0
print(f"Total coverage: {coverage:.1f}%")
if coverage < threshold:
print(f"FAIL: Coverage {coverage:.1f}% below threshold {threshold}%")
sys.exit(1)
Now if coverage drops below 70%, the job fails, and the pipeline fails, and the MR can't merge.
Some quality checks should block merges (lint, tests). Others should warn but not block (e.g., a slow integration test):
lint:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# No allow_failure — must pass
slow-integration-test:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
# Failures don't block; pipeline shows warning
The choice depends on your team's tolerance for false positives and the cost of fixes.
For a forgiving onboarding period (when introducing SwiftLint to a legacy codebase), don't strict-fail immediately:
# Phase 1: warnings only
lint:
script:
- swiftlint # no --strict; warnings allowed
allow_failure: true
# Phase 2: enforce
lint:
script:
- swiftlint --strict
Migrate from phase 1 to phase 2 over a few weeks: fix existing violations, then enable strict mode. Pulling the lint trigger immediately on a legacy codebase = thousands of failures = team frustration.
Beyond lint and test:
truffleHog, gitleaks to catch committed secrets..strings files for all supported languages.A bundle size check:
size-check:
stage: build
tags: [macos, ios]
needs: [build-release]
script:
- |
SIZE=$(stat -f%z build/MyApp.ipa)
THRESHOLD=$((50 * 1024 * 1024)) # 50 MB
echo "IPA size: $((SIZE / 1024 / 1024)) MB"
if [ $SIZE -gt $THRESHOLD ]; then
echo "ERROR: IPA exceeds 50 MB threshold"
exit 1
fi
If the app grows unexpectedly (e.g., someone bundled a huge asset), CI catches it.
Quality gates are tools, not solutions. They work when:
They fail when:
Investing in fast local feedback (pre-commit hooks, IDE integration) makes CI gates feel like a safety net rather than a punishment.
Lint output buried in logs. Without JUnit reporter, errors are in raw output. Hard to find. Use --reporter junit.
Coverage regex broken. GitLab won't extract the number. Test the regex against actual job output.
Strict lint on day 1 of legacy codebase. Hundreds of failures. Team revolts. Phase in over weeks.
Tests pass locally, fail on CI. Often: simulator differences, env vars, or paths. Reproduce on CI's simulator with CI's exact command.
Coverage threshold without warning. The day coverage drops by 0.1%, an MR fails. Set threshold conservatively (5-10% margin from current) so micro-fluctuations don't break flow.
Quality gates without local equivalents. Developers should be able to run lint/tests/format checks locally. Provide make lint, make test, make format shortcuts so the loop is fast.
Lint with SwiftLint and SwiftFormat. Use --strict and --reporter junit for CI. Tests fail the pipeline; configure GitLab to require pipeline success for merging. Coverage gates via threshold-checking scripts. Phase in strictness on legacy codebases. Quality gates work when teams agree on the standard and have fast local feedback. Don't over-gate; allow some non-blocking warnings for nuanced cases.
Coverage tells you what code your tests touch. iOS coverage via Xcode's built-in instrumentation produces an xcresult bundle; CI parses it and surfaces metrics. With GitLab integration, you see coverage per MR, trend over time, and per-file breakdowns.
Coverage is per-scheme. In Xcode: Product → Scheme → Edit Scheme → Test → Options → check "Code Coverage" with the targets you care about.
This setting is in the .xcscheme file, which lives in xcshareddata/xcschemes/. Commit it.
For multiple schemes (e.g., a unit-test scheme and a UI-test scheme), enable coverage on each.
In xcodebuild:
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=17.5' \
-enableCodeCoverage YES \
-resultBundlePath test_output/MyApp.xcresult \
| xcbeautify
-enableCodeCoverage YES overrides the scheme setting (so even if the scheme has coverage off, this turns it on).
-resultBundlePath specifies where to write the xcresult bundle. We need this for parsing.
In fastlane:
test:
script:
- bundle exec fastlane scan \
--scheme MyApp \
--code_coverage \
--output_directory test_output \
--result_bundle true
--code_coverage enables coverage. --result_bundle true keeps the xcresult.
Apple's xcrun xccov extracts coverage data:
# Total line coverage as text
xcrun xccov view --report test_output/MyApp.xcresult
# JSON format
xcrun xccov view --report --json test_output/MyApp.xcresult > coverage.json
# Summary only
xcrun xccov view --report --only-targets test_output/MyApp.xcresult
The --report flag is for the post-Xcode-13 format. Older xcresult bundles use different syntax.
JSON output structure (simplified):
{
"lineCoverage": 0.785,
"executableLines": 12500,
"coveredLines": 9813,
"targets": [
{
"name": "MyApp",
"lineCoverage": 0.812,
"files": [...]
}
]
}
lineCoverage is the fraction (0.785 = 78.5%).
GitLab reads coverage from job output via a regex. Set:
test:
script:
- bundle exec fastlane scan --code_coverage --output_directory test_output
- |
COVERAGE=$(xcrun xccov view --report test_output/MyApp.xcresult | grep -E 'MyApp\.app' | head -1 | awk '{print $2}' | tr -d '%')
echo "Coverage: $COVERAGE%"
coverage: '/Coverage: (\d+\.\d+)%/'
The script extracts the coverage number and echoes it in a known format. The coverage: regex on the job spec pulls the number out of the log.
GitLab now displays:
For per-line coverage info in MRs (clicking on a changed file shows which lines are covered/uncovered), use Cobertura format:
test:
script:
- bundle exec fastlane scan --code_coverage --output_directory test_output
- bundle exec slather coverage --cobertura-xml --output-directory coverage --scheme MyApp MyApp.xcodeproj
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura.xml
slather is a Ruby gem that parses xcresult and generates Cobertura XML. GitLab ingests it; line-level coverage shows in MR diffs.
Slather config in .slather.yml:
coverage_service: cobertura_xml
xcodeproj: MyApp.xcodeproj
workspace: MyApp.xcworkspace
scheme: MyApp
output_directory: coverage
ignore:
- "Pods/**/*"
- "**/*Tests.swift"
- "**/*Mock*.swift"
Test files and mocks aren't real code; ignore them.
Reject MRs that drop coverage:
test:
script:
- bundle exec fastlane scan --code_coverage --output_directory test_output
- bundle exec slather coverage --simple-output --scheme MyApp MyApp.xcodeproj > coverage.txt
- cat coverage.txt
- python3 .ci/check-coverage.py coverage.txt 70
coverage: '/Total coverage: (\d+\.\d+)%/'
# .ci/check-coverage.py
import sys, re
with open(sys.argv[1]) as f:
text = f.read()
threshold = float(sys.argv[2])
match = re.search(r'(\d+\.\d+)%', text)
if not match:
print("Could not parse coverage")
sys.exit(1)
coverage = float(match.group(1))
print(f"Total coverage: {coverage:.1f}%")
if coverage < threshold:
print(f"FAIL: Coverage {coverage:.1f}% below threshold {threshold:.1f}%")
sys.exit(1)
Now CI fails if coverage drops below 70%.
For "don't decrease from main" (more sophisticated), you'd compare against the main branch's coverage. This requires storing main's coverage somewhere (a project artifact, a metrics service) and comparing against it in MR jobs.
GitLab shows coverage breakdown in MR Reviews. Click a changed file → see which lines are covered (green) and not covered (red). Helps reviewers focus on adding tests for genuinely uncovered new code.
For deeper inspection, the xcresult bundle itself has rich data. Open in Xcode: open test_output/MyApp.xcresult shows the full coverage view per file.
For terminal/CI inspection:
# Show files with coverage below 50%
xcrun xccov view --report --json test_output/MyApp.xcresult \
| jq '.targets[].files[] | select(.lineCoverage < 0.5) | {name, lineCoverage}'
Some files shouldn't count for coverage:
Slather has ignore: patterns. xccov has equivalents. Or use // MARK: - SwiftLint:disable:this all style annotations (varies by tool).
Excluding too much hides real coverage gaps. Excluding too little inflates "untested" code that's actually trivial. Balance.
For multi-module apps, per-module coverage is informative:
# Coverage per target
xcrun xccov view --report --json test_output/MyApp.xcresult \
| jq '.targets[] | {name, lineCoverage}'
A network module at 90% coverage and a UI module at 30% tells you where to focus.
Different teams have different preferences:
Most teams use one project-level number, perhaps with carve-outs (e.g., "core business logic: 85%; UI: 60%").
The simplest visualization is the coverage: regex output, surfaced on:
For repos that have configured this, the project README often has a coverage badge:
[](https://gitlab.com/group/project/-/commits/main)
Live-updating SVG badge showing current main-branch coverage. Pretty, motivating.
"Did this MR cover its own changes?" — a more useful metric than absolute coverage. New code at 100% covered; project-wide drops by 0.1% because a pre-existing untested file got a comment update.
GitLab Premium has diff coverage. For Free tier, third-party tools or scripts compare changed-file coverage between MR and main.
The diff-cover Python tool works:
pip install diff_cover
diff-cover coverage.xml --compare-branch=origin/main
# "Total: 95% covered (24 of 28 lines)"
Shows coverage of just the changed lines. A 95% diff coverage means new code is well-tested; an 50% means lots of new untested code.
Coverage scheme not enabled. Tests run but no coverage data. Verify .xcscheme has coverage enabled and is committed.
xcresult bundle missing. Some test commands don't produce one. --result_bundle true for fastlane scan; -resultBundlePath for raw xcodebuild.
Coverage regex not matching. The job's coverage: field is regex on raw log text. Test it.
Excluding too much. Generated files, but also the wrong files. Coverage looks great because it's measured on a tiny code surface.
Threshold too tight. 70% on main → MR adds a blank line → coverage drops to 69.9% → CI fails. Set thresholds with a buffer.
Coverage from old test run. xcresult from a previous job, accidentally cached, gives stale coverage. Clear before each test run.
Slow coverage report generation. Slather on large projects can take minutes. Profile if it slows your pipeline.
Enable coverage in your scheme. Run xcodebuild test -enableCodeCoverage YES (or fastlane scan --code_coverage). Parse with xcrun xccov or slather. Report via coverage: regex (for badges) and Cobertura format (for line-level MR display). Set thresholds with buffers; don't gate too tightly. Aim for diff coverage on new code; absolute coverage as a trend line. Excluded files matter — don't game your numbers.
As your codebase grows beyond one app, you'll have multiple repos that depend on each other — a shared SDK and the apps consuming it, multiple apps in a portfolio, separate iOS and Android repos. Multi-project pipelines and YAML includes let you coordinate CI across these repos without copy-pasting .gitlab-ci.yml everywhere.
The include: directive pulls YAML from another file or another project. The most basic case: factor your pipeline into multiple files within one repo:
# .gitlab-ci.yml (root)
include:
- local: '.gitlab/ci/lint.yml'
- local: '.gitlab/ci/test.yml'
- local: '.gitlab/ci/build.yml'
- local: '.gitlab/ci/deploy.yml'
stages:
- lint
- test
- build
- distribute
Each included file defines jobs for that stage. The root .gitlab-ci.yml ties them together. Easier to maintain; multiple people can edit different files without merge conflicts.
# .gitlab/ci/lint.yml
swiftlint:
stage: lint
tags: [macos, ios]
script:
- swiftlint --strict
# .gitlab/ci/test.yml
unit-tests:
stage: test
tags: [macos, ios]
script:
- bundle exec fastlane scan --testplan UnitTests
GitLab loads them all and merges into one pipeline.
Sharing pipeline templates across projects:
# .gitlab-ci.yml in any iOS project
include:
- project: 'mobile/ios-ci-templates'
file: '/templates/ios-app.yml'
ref: main
The mobile/ios-ci-templates project has the templates; multiple projects include them. Update the template once; all projects pick it up.
This is how iOS teams scale CI across multiple apps. Define "the iOS pipeline template" once with all your standard jobs — lint, test, build, deploy — and individual apps include it.
GitLab maintains some templates:
include:
- template: 'Auto-DevOps.gitlab-ci.yml'
For iOS, GitLab's own templates are limited (Auto DevOps is server-focused). Custom templates work better for iOS pipelines.
include:
- remote: 'https://example.com/shared-pipelines/ios.yml'
Pulls from any HTTPS URL. Use cautiously — you're trusting the remote.
A typical iOS template project structure:
mobile/ios-ci-templates/
├── README.md
├── templates/
│ ├── ios-app.yml # Full pipeline for an app
│ ├── ios-framework.yml # Pipeline for a shared framework
│ ├── lint.yml # Just linting
│ ├── test.yml # Just testing
│ └── deploy.yml # Just deployment
└── jobs/
├── swiftlint.yml
├── swiftformat.yml
├── scan.yml
├── archive.yml
└── pilot.yml
A consuming project includes what it needs:
# Some app's .gitlab-ci.yml
include:
- project: 'mobile/ios-ci-templates'
file: '/templates/ios-app.yml'
ref: 'v3.2.0' # pin to a tagged version
variables:
IOS_SCHEME: "MyApp"
IOS_BUNDLE_ID: "com.example.MyApp"
APP_STORE_APPLE_ID: "1234567890"
XCODE_VERSION: "15.4"
The template uses these variables to customize per-app behavior.
Pin the template version with ref::
include:
- project: 'mobile/ios-ci-templates'
file: '/templates/ios-app.yml'
ref: 'v3.2.0'
ref: can be a branch, tag, or commit SHA. Tags are best for templates — semantic versioning lets consumers update intentionally.
Updating the template:
v3.3.0).ref: to v3.3.0 when they're ready.Without versioning, every change to template main impacts every consuming project immediately. Risky for stability.
Different from includes. A multi-project pipeline triggers a pipeline in another project as part of yours:
trigger-app-build:
stage: trigger
trigger:
project: 'mobile/main-app'
branch: main
strategy: depend
When this job runs, it triggers a pipeline in mobile/main-app on its main branch. strategy: depend means our pipeline waits for the triggered pipeline to complete; if it fails, ours fails.
Use case: an SDK repo. When the SDK changes, run its own tests, then trigger downstream consumers' tests to verify nothing breaks.
# In mobile/auth-sdk's .gitlab-ci.yml
stages:
- test
- integration
test:
stage: test
tags: [macos, ios]
script:
- bundle exec fastlane scan
trigger-main-app:
stage: integration
trigger:
project: 'mobile/main-app'
branch: 'main'
strategy: depend
rules:
- if: $CI_COMMIT_BRANCH == "main"
When the SDK's main branch changes:
This is integration testing across repos.
trigger-main-app:
trigger:
project: 'mobile/main-app'
branch: 'sdk-test/${CI_COMMIT_BRANCH}'
strategy: depend
variables:
SDK_VERSION: ${CI_COMMIT_SHORT_SHA}
SDK_REPO: ${CI_PROJECT_PATH}
The triggered pipeline sees SDK_VERSION and SDK_REPO as variables. Its scripts can use them — e.g., update Package.resolved to point to the SDK at this SHA.
For SDKs distributed via tagged releases:
# In SDK repo:
publish-sdk:
stage: publish
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- # publish to package registry, etc.
notify-consumers:
stage: notify
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
trigger:
project: 'mobile/main-app'
branch: 'sdk-update'
strategy: depend
variables:
NEW_SDK_VERSION: $CI_COMMIT_TAG
When the SDK tags a release, it triggers the main app's pipeline on a sdk-update branch. That pipeline updates Package.resolved, opens an MR, runs tests.
This pattern (SDK release → automated dependency update PR) reduces manual tracking of SDK versions across consumers.
For a team with one SDK + multiple apps:
Both, in combination, are powerful.
GitLab introduced "CI/CD components" — reusable, parameterized job definitions. They're more sophisticated than plain includes, with explicit input/output contracts.
# In an app's .gitlab-ci.yml
include:
- component: gitlab.com/mobile/ios-components/ios-test@v1.0.0
inputs:
scheme: "MyApp"
device: "iPhone 15"
coverage: true
The component ships a parameterized template; consumers configure via inputs. More robust than passing variables.
For new template projects, components are the modern choice. For existing template repos, plain includes still work.
Unpinned includes. ref: main means a template change breaks all consumers immediately. Always pin to a tag or commit.
Circular includes. A includes B; B includes A. Pipeline fails to load. Keep dependencies acyclic.
Variables in include paths. include: 'templates/${IOS_PROJECT}.yml' — sometimes works, sometimes doesn't, depending on GitLab version. Test before relying on it.
Trigger loops. Project A triggers B; B triggers A. Loop until pipelines max out. Add guards (e.g., don't trigger on auto-generated commits).
Triggered pipeline failures cascading unintentionally. A flaky downstream pipeline failing your release pipeline is annoying. strategy: depend couples them; without strategy:, the trigger is fire-and-forget.
Too much in templates. Templates that try to do everything become unmaintainable. Keep them focused. One template per pattern.
include: pulls YAML from local files, other projects, or URLs. Use to factor out shared pipeline logic. Pin includes to tagged versions. trigger: runs pipelines in other projects; use strategy: depend to couple them. Multi-repo iOS setups (SDK + apps) benefit hugely. Template projects centralize standard pipelines; consumers customize via variables. CI/CD components are the modern, structured form of templates.
Default GitLab pipelines are stage-based: stage 1 runs all its jobs, then stage 2, then stage 3. Simple but inefficient — slow jobs in early stages block fast jobs in later stages even when they're not actually dependent.
DAG (Directed Acyclic Graph) pipelines let you express explicit job-to-job dependencies via needs:, ignoring stage order. Jobs run as soon as their needs: are satisfied, regardless of which stage they're in.
For iOS pipelines, this can shave significant time off complex pipelines.
A typical iOS pipeline:
stages:
- lint
- test
- build
- distribute
Imagine:
lint takes 1 minute.swiftformat-check takes 30 seconds.unit-tests takes 6 minutes.snapshot-tests takes 4 minutes.build takes 8 minutes.distribute takes 2 minutes.Stage-based: 1 + max(6, 4) + 8 + 2 = 17 minutes wall-clock.
But build doesn't actually depend on tests passing — they're independent. If we let build start in parallel with tests, the pipeline takes 1 + max(6, 8) + 2 = 11 minutes.
DAG pipelines express this with needs:.
needs:stages:
- check
- test
- build
- distribute
lint:
stage: check
script: [...]
swiftformat-check:
stage: check
script: [...]
unit-tests:
stage: test
needs:
- lint
- swiftformat-check
script: [...]
snapshot-tests:
stage: test
needs:
- lint
- swiftformat-check
script: [...]
build:
stage: build
needs:
- lint
- swiftformat-check
script: [...]
distribute:
stage: distribute
needs:
- unit-tests
- snapshot-tests
- build
script: [...]
Now build starts as soon as lint and swiftformat-check finish — in parallel with tests. The pipeline runs as a DAG.
Stages still exist, but they're more for organization/visualization. The dependency graph is what determines execution order.
GitLab shows a "Needs" view of pipelines: visual graph of jobs, with arrows showing dependencies. Cleaner than the stage view for complex pipelines.
Project → CI/CD → Pipelines → click a pipeline → "Needs" tab.
needs: with artifactsneeds: also pulls artifacts from those jobs. To skip artifacts:
distribute:
needs:
- job: unit-tests
artifacts: false
- job: build
artifacts: true
artifacts: true (default) downloads artifacts; artifacts: false doesn't. For iOS, distribute usually needs the IPA from build but not artifacts from test jobs.
needs: across stagesA job in stage: distribute can needs: a job in stage: lint — even skipping stages in between. The DAG ignores stage order.
quick-deploy:
stage: distribute
needs:
- job: lint
script:
- echo "Deploy without waiting for tests"
Use rare and intentionally — usually you do want tests to gate deployment. But the option exists.
needs: with jobs from earlier pipelinesYou can needs: jobs from upstream pipelines (e.g., parent or trigger-source pipelines):
my-job:
needs:
- project: 'mobile/auth-sdk'
job: build
ref: 'main'
artifacts: true
Useful in multi-project pipelines: pull the SDK build's artifacts directly into your pipeline.
A reasonable iOS pipeline DAG:
lint -----+
|
v
unit-tests --+
|
swiftformat -+ |
| |
v v
snapshot -+--> distribute
| |
v |
build ----+
Lint and swiftformat in parallel (no dependency). Unit-tests and snapshot-tests depend on lint passing. Build depends on lint passing (not tests). Distribute depends on tests AND build.
Express in YAML:
stages:
- check
- test
- build
- distribute
lint:
stage: check
script: [swiftlint --strict]
swiftformat:
stage: check
script: [swiftformat --lint .]
unit-tests:
stage: test
needs: [lint, swiftformat]
script: [bundle exec fastlane scan --testplan UnitTests]
snapshot-tests:
stage: test
needs: [lint, swiftformat]
script: [bundle exec fastlane scan --testplan SnapshotTests]
build:
stage: build
needs: [lint, swiftformat]
script: [bundle exec fastlane gym]
distribute:
stage: distribute
needs:
- unit-tests
- snapshot-tests
- build
script: [bundle exec fastlane pilot upload]
build starts in parallel with tests, not after. Distribute waits for everything. Wall-clock time: max(test_time, build_time) instead of test_time + build_time.
needs: and runner contentionDAG pipelines schedule more jobs in parallel — if your runner concurrency is 1, multiple jobs queue waiting for the runner. Wall clock doesn't improve.
For DAG to actually parallelize, you need:
concurrent: 2+.Section 28 covers this.
DAGs everywhere isn't always best. Stages have benefits:
needs:, jobs default to depending on all previous-stage jobs. For simple pipelines, this is convenient.Use DAGs where they buy real speed; stick with stages for simple pipelines.
needs: on a job that's been excluded. If a job is filtered out by rules:, jobs that needs: it never run (or fail). Make needs: and rules: consistent.
Circular needs:. A needs: [B]; B needs: [A]. Pipeline fails to start.
Too many parallel jobs starving the runner. 10 jobs in parallel on a 1-concurrent runner queue serially anyway.
Forgetting artifacts: true. Default is true — artifact downloaded. To explicitly skip, set artifacts: false. Confused engineers sometimes write artifacts: true thinking that turns it on, when default is already on.
needs: across project triggers without ref: and artifacts:. Needs full spec. Read the docs.
Premature optimization. A DAG that saves 30 seconds on a pipeline that runs once an hour isn't worth complexity. Optimize where time matters.
needs: declares explicit job dependencies, ignoring stage order. Jobs run as soon as their needs are satisfied. For iOS pipelines, this enables build to run parallel to tests, saving significant wall-clock time. Visualize with the "Needs" view in GitLab. artifacts: true|false controls per-need artifact transfer. Requires runner concurrency to actually parallelize. Don't over-DAG simple pipelines.
Parent-child pipelines split one logical pipeline into multiple actual pipelines. The parent triggers child pipelines, possibly in parallel, possibly conditionally. They're useful for monorepos, conditional sub-pipelines, and dynamically-generated pipelines.
For iOS, parent-child pipelines come up in:
In the parent pipeline:
trigger-ios-pipeline:
stage: build
trigger:
include: '.gitlab/ios-pipeline.yml'
strategy: depend
rules:
- changes:
- "ios/**/*"
This creates a child pipeline using .gitlab/ios-pipeline.yml. The child runs as a separate pipeline; the parent waits if strategy: depend.
In the child file:
# .gitlab/ios-pipeline.yml
stages:
- lint
- test
- build
ios-lint:
stage: lint
tags: [macos, ios]
script: [swiftlint --strict]
ios-test:
stage: test
tags: [macos, ios]
script: [bundle exec fastlane scan]
ios-build:
stage: build
tags: [macos, ios]
script: [bundle exec fastlane gym]
It's just another pipeline definition; just included via trigger: include:.
Compared to a single mega-pipeline, parent-child:
Compared to includes (covered in section 25), parent-child pipelines:
A monorepo example:
# Root .gitlab-ci.yml
stages:
- trigger
trigger-ios:
stage: trigger
trigger:
include: 'ios/.gitlab-ci.yml'
strategy: depend
rules:
- changes:
- "ios/**/*"
- "shared/**/*"
trigger-android:
stage: trigger
trigger:
include: 'android/.gitlab-ci.yml'
strategy: depend
rules:
- changes:
- "android/**/*"
- "shared/**/*"
trigger-backend:
stage: trigger
trigger:
include: 'backend/.gitlab-ci.yml'
strategy: depend
rules:
- changes:
- "backend/**/*"
Each platform has its own pipeline. Changing ios/MyApp/Foo.swift triggers only the iOS child. Changing shared/data-models/User.swift triggers iOS and Android (because both depend on shared). Backend changes only trigger backend.
Saves enormous amounts of CI time on monorepos.
Instead of a static file, generate the child YAML in a job, then trigger it:
generate-ios-pipeline:
stage: generate
script:
- python3 .ci/generate-ios-pipeline.py > generated-ios.yml
artifacts:
paths: [generated-ios.yml]
trigger-ios:
stage: trigger
needs: [generate-ios-pipeline]
trigger:
include:
- artifact: generated-ios.yml
job: generate-ios-pipeline
strategy: depend
Useful for:
trigger-ios:
trigger:
include: 'ios/.gitlab-ci.yml'
strategy: depend
variables:
PARENT_PIPELINE_ID: $CI_PIPELINE_ID
XCODE_VERSION: "15.4"
The child sees these variables. Useful for passing context.
strategy: depend — parent waits for child to finish; child's status affects parent's status.For iOS, depend is usually right — you want the parent to wait and report success/failure.
Without depend, you can't needs: the child's jobs from later parent jobs.
If a child fails (with strategy: depend), the trigger job in the parent shows failed. The parent pipeline fails.
Without strategy: depend, the parent's trigger job succeeds the moment it hands off to the child. Child failure doesn't propagate.
my-monorepo/
├── .gitlab-ci.yml # parent
├── shared/
│ ├── .gitlab/ci.yml # shared module pipeline
│ └── ...
├── ios/
│ ├── .gitlab/ci.yml # iOS pipeline
│ └── ...
└── android/
├── .gitlab/ci.yml # Android pipeline
└── ...
Parent:
# .gitlab-ci.yml
stages:
- shared
- platforms
shared-build:
stage: shared
trigger:
include: 'shared/.gitlab/ci.yml'
strategy: depend
rules:
- changes:
- "shared/**/*"
ios-build:
stage: platforms
trigger:
include: 'ios/.gitlab/ci.yml'
strategy: depend
rules:
- changes:
- "ios/**/*"
- "shared/**/*"
android-build:
stage: platforms
trigger:
include: 'android/.gitlab/ci.yml'
strategy: depend
rules:
- changes:
- "android/**/*"
- "shared/**/*"
Shared changes → all three pipelines run (shared first, then iOS and Android in parallel). iOS-only changes → only iOS pipeline. Each platform has its own runners, its own concerns.
Forgetting strategy: depend. Parent shows green, child fails silently. Surprising bug.
Trigger files not on disk at trigger time. trigger: include: 'foo.yml' requires foo.yml to exist in the source. If you generate it dynamically, use artifact: form.
Variables not propagating as expected. Some predefined variables don't pass to children. Test before relying on them.
Cycles between parents and children. A child pipeline that triggers its parent. Endless loop.
Permissions issues with cross-project triggers. The triggering job needs permission on the target project. Configure via deploy tokens or CI_JOB_TOKEN allowances.
Too many child pipelines. Each is a separate UI page; clicking through dozens to debug a monorepo failure is painful. Sometimes one pipeline with stages is simpler.
trigger: include: creates child pipelines. Use strategy: depend to couple parent and child. Conditional triggers (via rules: changes:) skip irrelevant child pipelines — huge savings in monorepos. Dynamic generation enables data-driven pipelines (sharding, per-target builds). Pass variables to children via variables:. Each child runs as its own pipeline ID. Use parent-child for clear separation between platforms in a monorepo; use stages for simpler pipelines.
iOS CI handles a lot of secrets — code signing certificates, App Store Connect API keys, fastlane match passwords, third-party service tokens. Mishandling them is how you end up with leaked credentials in CI logs or compromised accounts. This section covers GitLab's secret management primitives and the iOS-specific keychain dance.
In Settings → CI/CD → Variables, you have these options per variable:
*****-d.$VARIABLE_NAME instead of an environment variable. Useful for binary content (certs, keys).The combination matters:
Masked variables are hidden from logs. But:
echo "Password: $SECRET" → Password: actualpassword), the transformed string isn't masked.For genuine security:
For multi-line content like API keys:
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC...
-----END PRIVATE KEY-----
A regular variable would be unwieldy. File-type variables write the content to a temp file at the path stored in the variable:
script:
- cat $APP_STORE_CONNECT_API_KEY # the variable contains the file path
In the GitLab UI when adding a variable, choose "File" type. Paste the file content. The variable name (e.g., APP_STORE_CONNECT_API_KEY) holds the path; the file content is at that path.
For App Store Connect API keys, this is the standard approach.
iOS code signing uses macOS keychains. CI needs the certificate in a keychain accessible to the build process.
The keychain dance for a temporary CI keychain:
#!/bin/bash
# .ci/setup-keychain.sh
set -e
KEYCHAIN_NAME="ci.keychain"
KEYCHAIN_PASSWORD="${CI_KEYCHAIN_PASSWORD:-temppass}"
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Append to keychain search list (so codesign finds it)
security list-keychains -d user -s "$KEYCHAIN_NAME" $(security list-keychains -d user | tr -d '"' | tr -d ' ')
# Set as default
security default-keychain -s "$KEYCHAIN_NAME"
# Unlock
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Set timeout (1 hour)
security set-keychain-settings -lut 3600 "$KEYCHAIN_NAME"
Then import certificates:
# Import P12 (certificate + private key) from CI variable
echo "$CERTIFICATE_P12_BASE64" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 \
-k "$KEYCHAIN_NAME" \
-P "$CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
# Allow codesign to use it without prompt
security set-key-partition-list \
-S "apple-tool:,apple:,codesign:" \
-s -k "$KEYCHAIN_PASSWORD" \
"$KEYCHAIN_NAME"
# Cleanup
rm /tmp/cert.p12
Then teardown after the job:
#!/bin/bash
# .ci/teardown-keychain.sh
security delete-keychain ci.keychain || true
security default-keychain -s login.keychain || true
In the pipeline:
build:
before_script:
- .ci/setup-keychain.sh
script:
- bundle exec fastlane gym ...
after_script:
- .ci/teardown-keychain.sh
This whole dance is what fastlane match handles automatically. Manual keychain management is for when you don't want match (e.g., you're using xcode automatic signing manually configured, or some other setup).
When you call fastlane match, it:
fastlane_tmp_keychain or similar).This is invisible to you. If you call match, you don't need the manual setup-keychain.sh.
Three pieces of info:
ABC123XYZ)..p8 file).Store as:
APP_STORE_CONNECT_API_KEY_ID: plain string variable.APP_STORE_CONNECT_API_KEY_ISSUER_ID: plain string variable.APP_STORE_CONNECT_API_KEY: file variable, contents of the .p8.In Fastfile or fastlane CLI:
script:
- bundle exec fastlane pilot upload \
--api_key_path "$APP_STORE_CONNECT_API_KEY" \
--apple_id "1234567890"
Fastlane's app_store_connect_api_key action wraps this with explicit args:
# Fastfile
lane :setup_api_key do
app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
key_filepath: ENV["APP_STORE_CONNECT_API_KEY"]
)
end
For very secret-heavy organizations, dedicated secret managers:
before_script.Vault example:
build:
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
variables:
VAULT_AUTH_PATH: jwt
VAULT_AUTH_ROLE: my-role
VAULT_SERVER_URL: https://vault.example.com
secrets:
APP_STORE_KEY:
vault: secret/data/ios#app_store_key
file: true
script:
- cat "$APP_STORE_KEY"
GitLab fetches the secret from Vault using the JWT token, makes it available as $APP_STORE_KEY. No long-lived secret in GitLab variables.
For most teams, GitLab's built-in protected variables are sufficient. Vault makes sense for orgs with strict secret-management policies.
Plan for rotating secrets:
match change_password re-encrypts the certs repo.MATCH_GIT_URL.Document rotation procedure. Test it before you need it.
GitLab has built-in secret scanning that catches accidentally-committed secrets:
include:
- template: Security/Secret-Detection.gitlab-ci.yml
Runs on every commit. If a secret is detected (API key pattern, common token formats), it's flagged in the MR. Won't catch everything but catches the obvious mistakes.
For prevention, install a pre-commit hook:
# .git/hooks/pre-commit
#!/bin/bash
git diff --cached | gitleaks detect --no-banner --report-path /dev/null --no-git
Catches secrets before they're committed.
Be careful what scripts log:
# BAD
echo "Using API key: $APP_STORE_CONNECT_API_KEY_ID with password $PASSWORD"
# GOOD
echo "Using API key from environment"
Even masked variables can leak through partial matches, formatted output, or via tools that log.
For specific tools:
--verbose mode logs everything; avoid in production.Variables visible in MR pipelines. Without protected: true, secrets are available to any job, including ones run on feature branches by anyone. A malicious or accidental commit can exfiltrate them.
Storing certificates as plain variables. Multi-line content gets mangled. Use File-type variables.
Forgetting to mask new variables. Adding a variable doesn't auto-mask. Check the box.
Long-lived API keys. Rotation is overhead but reduces blast radius if leaked.
Self-hosted runners with persistent state. A runner that retains keychains across jobs leaks state between jobs. Use ephemeral keychains and clean up.
Logging $VARIABLE in scripts. If the variable is multi-byte or transformed, masking misses it.
Match passphrase shared across teams. Anyone with it can decrypt. Treat as the root credential it is.
GitLab variables can be Plain, Masked, Protected, File-type. Combine attributes for layered protection. File-type variables for multi-line content (API keys, certs). Masking catches accidental log leaks; Protected restricts to protected branches. Self-hosted runners need keychain hygiene — temp keychains per job, cleanup after. Fastlane match handles keychains automatically. App Store Connect API keys need three pieces (ID, issuer, key file). Consider Vault or AWS Secrets Manager for high-stakes setups. Rotate secrets periodically.
Slow CI is corrosive. A 30-minute MR pipeline destroys flow; engineers context-switch and forget. A 5-minute pipeline keeps people engaged. The big iOS-specific bottlenecks are clean builds, exhaustive tests, and sequential pipelines. This section is the toolkit for speeding things up.
Profile your pipeline first. Common culprits:
pod install / package resolution — 1-3 minutes.A naive sequential pipeline easily hits 30+ minutes. Targeted optimizations bring this to 10-15.
Within a single test run, xcodebuild can parallelize across simulators:
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyAppTests \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-parallel-testing-enabled YES \
-parallel-testing-worker-count 4 \
-maximum-parallel-testing-workers 4
Each worker spins up its own simulator, runs a portion of the tests. 4 workers can roughly 4x test speed (less due to overhead).
Caveats:
Break tests into shards across multiple jobs:
test:
stage: test
tags: [macos, ios]
parallel: 5
script:
- bundle exec fastlane scan \
--testplan UnitTests \
--output_directory test_output \
--test-without-building \
--xctestrun "build/Build/Products/MyApp_iphoneSimulator.xctestrun" \
--shard-index $CI_NODE_INDEX \
--total-shards $CI_NODE_TOTAL
parallel: 5 runs the job 5 times in parallel. $CI_NODE_INDEX (1-5) and $CI_NODE_TOTAL (5) are set by GitLab. The script uses these to run a shard.
To actually shard tests, you need a test runner that supports it. fastlane scan doesn't natively; you can use:
xcodebuild build-for-testing), then test-without-building per shard, with -only-testing: flags to pick which tests.Example custom sharding:
#!/bin/bash
# .ci/run-test-shard.sh
SHARD_INDEX=$1
TOTAL_SHARDS=$2
# List all test classes
ALL_TESTS=$(xcodebuild -dry-run test \
-workspace MyApp.xcworkspace \
-scheme MyAppTests \
-destination 'platform=iOS Simulator,name=iPhone 15' \
| grep "Test Case '-" | awk -F"'" '{print $2}' | sort -u)
# Pick tests for this shard (round-robin)
SHARD_TESTS=$(echo "$ALL_TESTS" | awk -v i=$SHARD_INDEX -v t=$TOTAL_SHARDS \
'NR % t == (i-1) % t')
# Build -only-testing flags
ARGS=""
for test in $SHARD_TESTS; do
ARGS="$ARGS -only-testing:$test"
done
# Run
xcodebuild test-without-building \
-xctestrun build/MyApp.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 15' \
$ARGS
Used in CI:
build-for-testing:
stage: build
tags: [macos, ios]
script:
- bundle exec fastlane scan --build-for-testing --output_directory build
artifacts:
paths: [build/]
test-shard:
stage: test
tags: [macos, ios]
needs: [build-for-testing]
parallel: 5
script:
- .ci/run-test-shard.sh $CI_NODE_INDEX $CI_NODE_TOTAL
Total wall-clock: max(shard times). If a sequential test run takes 10 minutes, sharded across 5 it's 2 minutes (with overhead).
Section 11 covered caching. Briefly: cache SPM, CocoaPods, Carthage, and (cautiously) DerivedData. The cache key strategy:
cache:
- key:
files:
- Podfile.lock
paths: [Pods/]
- key:
files:
- Package.resolved
paths: [.build/SourcePackages/]
- key: derived-data-${CI_COMMIT_REF_SLUG}
paths: [DerivedData/]
policy: pull-push
Cache hit ratio improvements: pre-built dependencies skip 2-5 minutes per pipeline.
Split build and test:
build-for-testing:
stage: build
tags: [macos, ios]
script:
- bundle exec fastlane scan --build-for-testing
artifacts:
paths:
- build/Build/Products/
test:
stage: test
tags: [macos, ios]
needs: [build-for-testing]
parallel: 4
script:
- bundle exec fastlane scan --test-without-building \
--xctestrun "build/Build/Products/MyApp_iphoneSimulator.xctestrun" \
--shard-index $CI_NODE_INDEX --total-shards $CI_NODE_TOTAL
Compile once; test multiple times in parallel. Saves the per-shard build time.
Booting an iOS simulator takes 30-60 seconds. For pipelines that run many test jobs, pre-booting helps:
test:
before_script:
- xcrun simctl boot "iPhone 15" || true
- xcrun simctl bootstatus "iPhone 15" -b
script:
- bundle exec fastlane scan --device "iPhone 15"
bootstatus -b blocks until the simulator is fully booted. The simulator stays booted between jobs on the same runner (with shell executor), so subsequent jobs are fast.
For shared runners with persistent simulators, this works. For ephemeral runners, simulators boot per job — accept the cost.
If iOS code hasn't changed, skip iOS jobs:
build:
rules:
- changes:
- "**/*.swift"
- "**/*.m"
- "**/*.h"
- "Podfile.lock"
- "Package.resolved"
- "*.xcodeproj/**"
- "*.xcworkspace/**"
- "fastlane/**/*"
Documentation-only changes don't trigger expensive builds.
GitLab clones the full repo by default. For large repos, this takes time. Shallow clone speeds it up:
variables:
GIT_DEPTH: "10"
GIT_STRATEGY: clone
GIT_DEPTH: 10 clones only the last 10 commits. For most CI work, you don't need full history.
For jobs that need full history (e.g., generating release notes from git log):
release-notes:
variables:
GIT_DEPTH: "0" # full clone
script:
- git log --oneline ...
Most jobs: shallow. A few that need history: full.
Test against multiple iOS versions simultaneously:
test-multi-ios:
parallel:
matrix:
- IOS_VERSION: ["16.4", "17.0", "17.5"]
DEVICE: ["iPhone 15"]
script:
- xcodebuild test \
-destination "platform=iOS Simulator,name=$DEVICE,OS=$IOS_VERSION"
Each iOS version runs as a separate job in parallel. Pipeline time = max single-version time, not sum.
Requires the runner to have all those iOS Simulator runtimes installed. xcrun simctl runtime for managing them.
Heavy jobs (build, archive) need fast machines. Light jobs (lint, doc generation) don't. Tag accordingly:
swiftlint:
tags: [macos, lint] # any macOS runner is fine for lint
build-archive:
tags: [macos, ios, fast] # the M2 Pro Mac mini, not the older Mac
Match tags to runner registrations.
For heavy CI machines that run frequent builds:
~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex) is huge but reusable.This is "warm runner" advantage. Self-hosted shines here. Hosted runners get cold caches.
For self-hosted, just don't aggressively clean caches. Let them grow. Monitor disk; clean only when needed.
For external services (CocoaPods CDN, SPM dependencies, fastlane gems):
For internal package registries (private SPM repos), authentication via deploy tokens or SSH should be set up once and reused.
GitLab's job log shows duration. Sum jobs to see total CI time per pipeline. Profile individual jobs:
script:
- time bundle exec fastlane scan
time shows real/user/sys time. For slow jobs, identify:
Use the GitLab pipeline duration graph to track trends. A slow regression often comes from a single job; identify it.
For most iOS teams, the highest-impact changes:
needs: for parallelism. Build runs alongside tests. 5+ minutes saved.build-for-testing once, test many. Don't rebuild for each shard. 5-10 minutes saved.Sum: 25-50 minutes potentially saved. From 45-minute pipelines to 10-15 minute pipelines. Real impact.
Premature optimization. Profile first. Don't refactor a fast pipeline.
Over-parallelization on under-powered runners. 8 parallel jobs on a runner with concurrent: 2 queue serially. Gain nothing.
Cache invalidation issues. Aggressive caching with bad keys → stale builds. Section 11 covers cache key strategies.
Test sharding without understanding test interdependencies. Tests that share state break with sharding. Refactor tests for isolation.
Using DAG without understanding it. needs: from a non-existent job, circular needs:, etc. Test pipelines on a feature branch first.
Big artifacts. Uploading and downloading multi-GB artifacts is slow. Save only what's needed.
Slow runners pretending to be fast. Old Mac minis on overloaded networks are slower than they look. Measure.
Profile first. The biggest speed wins for iOS are: caching dependencies, parallelizing build with tests via needs:, sharding tests via parallel: and build-for-testing, skipping unchanged code, and right-sizing runners. Don't over-parallelize beyond runner concurrency. Test sharding requires test isolation. Pre-warmed runners (self-hosted) are faster than cold runners (hosted) for iterative CI. Aim for 10-15 minute MR pipelines; 25+ is too slow for productive flow.
CI failures are inevitable. The question is how fast you go from "pipeline failed" to "I know why and how to fix it." Good debugging skills compress that interval. This section is the toolkit.
The fundamental loop:
The skill is in steps 5-7. iOS errors are often verbose and indirect.
No such module 'Foo' — SPM/CocoaPods didn't resolve. Check that pod install or xcodebuild -resolvePackageDependencies ran in the correct stage.
Code Signing Error: No profiles for 'com.example.MyApp' were found — match didn't install profiles, or the bundle ID doesn't match the profile. Check match ran. Verify bundle ID.
Code Signing Error: Failed to find profile — wrong profile name in build flags. Check PROVISIONING_PROFILE_SPECIFIER value matches what match installed.
Could not find scheme 'MyApp' — scheme not shared in Xcode. Check xcshareddata/xcschemes/.
The bundle "MyApp" couldn't be loaded because it is damaged or missing necessary resources — usually a code signing or resource issue. Check the build's exit code; xcodebuild errors are sometimes earlier in the log.
Test execution failed: <unknown> — simulator crashed. Check simulator state; may need to reset simulators.
zsh:1: command not found: bundle — Ruby gems not in path. Check before_script for bundle install.
xcodebuild: error: Could not resolve package dependencies — SPM auth issue. Check token-based authentication.
fastlane is not currently fastlane — fastlane gem version mismatch. Pin in Gemfile.
The most powerful debugging technique: run the exact CI command on your machine.
For iOS, this means:
bundle exec).# Set CI-like vars
export DEVELOPER_DIR=/Applications/Xcode-15.4.app/Contents/Developer
export CI_PIPELINE_IID=999
# Run the failing command
bundle exec fastlane gym --workspace MyApp.xcworkspace --scheme MyApp --silent
If it reproduces locally, debug locally with full Xcode tools. If it doesn't reproduce, the difference is in the CI environment — keychain, paths, simulator state.
When something fails on CI but you can't reproduce locally, increase logging:
build-debug:
script:
- bundle exec fastlane gym --verbose
- find . -name "*.log" -exec cat {} \; 2>/dev/null
- xcrun simctl list
- security list-keychains
- which xcodebuild
- xcodebuild -version
- sw_vers
- df -h
- ls -la build/
Dump the entire environment. Read with Ctrl+F for the specific bit you need.
For this kind of debugging, a separate build-debug job with when: manual is convenient — trigger when needed, doesn't slow normal pipelines.
For really stubborn issues, SSH into a runner during a job:
debug:
script:
- |
cat <<'EOF' >> ~/.ssh/authorized_keys
ssh-rsa AAAA... my-debug-key
EOF
- sleep 3600 # keep job alive for an hour
when: manual
rules:
- if: $CI_COMMIT_BRANCH == "debug"
Trigger this job manually. While it sleeps, SSH to the runner (you need network access — depends on your setup), poke around, run commands, identify the issue.
This works only on self-hosted runners with SSH access. GitLab.com hosted runners don't permit it. There are also security implications — limit who can run this job.
set -x for shell debuggingIn script blocks:
script:
- set -x
- bundle install
- bundle exec fastlane gym
set -x echoes each command before running it. The job log shows the command and its output, useful for tracing what actually executed.
For fastlane issues:
bundle exec fastlane gym --verbose
Adds tons of detail. Useful when fastlane fails in non-obvious ways.
For Ruby exceptions, the stack trace shows where in the gem the error occurred. Often points to a config issue (missing env var, wrong version).
xcodebuild ... -verbose
Shows every internal step. Massive output. Useful for "why is xcodebuild deciding to do this?" mysteries — like wrong Xcode toolchain, wrong derived data path, etc.
When tests fail mysteriously:
# Open xcresult in Xcode UI for visual inspection
open build/MyApp.xcresult
# Or extract data via xcrun
xcrun xcresulttool get \
--path build/MyApp.xcresult \
--format json | jq '.actions._values[].actionResult.testsRef'
xcresult contains test failures, screenshots (UI tests), logs, attachments. Rich source of post-mortem data.
For CI, save the xcresult bundle as an artifact:
test:
artifacts:
when: always # save even on failure
paths:
- test_output/MyApp.xcresult
expire_in: 30 days
When a test fails, download the artifact, open in Xcode, see screenshots, full logs, the works.
Configure artifacts to be saved on failure:
build:
artifacts:
when: on_failure
paths:
- build/
- logs/
- "*.log"
expire_in: 7 days
when: on_failure saves only if the job failed. Useful for capturing post-mortem state without bloating the artifact store.
when: always saves regardless — useful for tests where you always want test reports, but only sometimes want the full artifacts.
If a failure looks transient (network blip, simulator hiccup), retry the job:
flaky-test:
retry:
max: 2
when:
- script_failure
- runner_system_failure
Auto-retries up to 2 times for the listed failure modes. Caveat: hides genuine flakiness; investigate retry-prone jobs.
If a failure started recently, find the introducing commit:
git bisect start
git bisect bad # current commit fails
git bisect good <known-good-sha>
# git checks out a midpoint commit; you test, mark good or bad
git bisect good # or bad
# repeat until found
git bisect reset
For CI failures specifically:
git bisect start
git bisect bad HEAD
git bisect good HEAD~50
# Run the CI command for this commit
git bisect run bundle exec fastlane scan
git bisect run automates: it runs your command at each bisect step, marks good/bad based on exit code. Walks the tree until it finds the introducing commit.
When MR pipeline fails but main passes:
If pipelines are identical environments and one fails, the failure is in your code. If environments differ, the runner is suspect.
Some jobs leave state that causes subsequent failures:
Add after_script to clean up:
build:
script:
- bundle exec fastlane gym
after_script:
- rm -rf build/MyApp.xcarchive
- rm -rf "$HOME/Library/Developer/Xcode/DerivedData/MyApp-*"
- security delete-keychain ci.keychain || true
after_script runs whether or not script succeeded. Use for cleanup that should always happen.
For chronic issues, use GitLab's CI/CD Analytics:
If the same job fails 30% of the time, it's flaky and needs attention.
GitLab calls them "logs" or "traces" interchangeably. Each job has a trace; the entire pipeline view is traces concatenated.
For very large logs, GitLab truncates. Set output_limit in runner config to allow more:
[[runners]]
output_limit = 16384 # KB; default 4096
Or pipe to a file and save as artifact:
script:
- bundle exec fastlane gym 2>&1 | tee build.log
artifacts:
paths: [build.log]
Now you have the full log even if GitLab truncated.
Reading the wrong line of the log. Sometimes the actual error is buried. Search for "error", "FAILED", "exit 1".
Assuming local success means CI success. Differences exist. Reproduce on the runner if possible.
Retrying without understanding why. Hides real bugs. Always investigate before retrying.
Not preserving artifacts on failure. When you can't reproduce, the artifacts are your only evidence. Save them.
Ignoring chronic flakiness. "Just retry it" is technical debt. Fix flaky tests.
Variable values in logs. Even masked variables sometimes leak through tools. Be cautious with logs in commit messages or screenshots.
Read failing logs from bottom to top first. Common iOS failures: code signing, scheme not shared, dependencies not resolved, simulator crashes. Reproduce locally with bundle exec fastlane. For stubborn failures, increase verbosity (set -x, --verbose). Save xcresult and logs as artifacts on failure. Use git bisect run to find introducing commits. Auto-retry for transient failures, but investigate chronic flakiness. Run a "debug" job with manual trigger for ad-hoc inspection.
A consolidated list of the things that bite iOS teams running GitLab CI/CD. Some are shared with all CI systems; some are uniquely iOS. Treat as a checklist when designing or auditing pipelines.
Forgetting to share schemes. Schemes default to user-only. CI can't see them. Manage Schemes in Xcode → check Shared. Commit xcshareddata/xcschemes/.
Mixed automatic and manual signing. In Xcode, the project switches between styles depending on what someone last clicked. Pin to manual via CODE_SIGN_STYLE=Manual in build settings, and verify with each build.
Profile name typos. match AppStore com.acme.MyApp is the exact format. One character off (AppStor, com.acme.app vs com.acme.MyApp) → "no profile found."
Bundle ID mismatch across config + Info.plist + match. All three must agree. If you rename, update all.
Profiles missing entitlements. Push notifications, App Groups, iCloud — each needs its own entitlement on the profile. Regenerate profiles after adding capabilities.
Distribution certificate revoked. If someone "revoked" the cert (often unintentional via Xcode UI), profiles depending on it become invalid. Re-issue via match.
Old certs lingering in keychain. Multiple "iPhone Distribution" certs in keychain can confuse xcodebuild. Clean keychains periodically.
Wrong Xcode version. Project requires Xcode 15.4; CI has Xcode 15.0. Subtle bugs ensue. Pin via DEVELOPER_DIR.
Workspace vs project confusion. MyApp.xcworkspace (used with CocoaPods) vs MyApp.xcodeproj. xcodebuild flag must match what's in your repo.
Configuration name mismatches. Default is "Release" but some projects rename. Double-check.
Generated files not in repo. Some teams use SwiftGen or Sourcery. If their outputs are gitignored, CI runs them; if outputs are committed, they may be stale. Pick a strategy and stick to it.
Simulator runtime not installed. xcodebuild requests iOS 17.5; runner only has iOS 17.4. Tests fail with cryptic errors. Install runtimes via Xcode → Settings → Platforms.
Specific device + OS combos absent. "iPhone 15 Pro on iOS 16" doesn't exist. Use combinations that do.
Stale simulator state. Tests pass once, then fail because state from previous run interferes. Reset simulators between test runs:
xcrun simctl shutdown all
xcrun simctl erase all
Or use simulator pre-flight:
test:
before_script:
- xcrun simctl shutdown all
- xcrun simctl erase all
script:
- bundle exec fastlane scan
Boot timeouts. Simulators sometimes hang during boot. Add timeout and retry logic.
pod install not running on CI. New gem? bundle exec pod install. Forgetting → outdated Pods/.
Lockfile drift. Podfile.lock not committed; team members get different versions. Always commit it.
Repository access timing out. CocoaPods CDN is fast; old pod install configs use the trunk source which is slow. Use source 'https://cdn.cocoapods.org/'.
Ignoring Pods/ mismatch. Some teams gitignore Pods/; some commit it. Pick one. Mixed approaches lead to "Pods/ has changes" CI failures.
Gemfile + Gemfile.lock not committed. Fastlane version drifts between developers. Commit both.
bundle install skipped. Without bundle install in before_script, fastlane uses system-installed (or cached) gems, which may be wrong.
Fastlane plugins not declared. Some lanes use plugins (e.g., fastlane-plugin-firebase_app_distribution). Add to fastlane/Pluginfile. Without declaration, fastlane fails with "plugin not loaded."
Match called without readonly: true in CI. Match might regenerate certs, silently breaking other developers.
force option missing on automated commands. Fastlane prompts for confirmation; CI hangs. Use --force.
Branch + MR pipelines duplicating. Workflow rules to avoid.
Stages running unnecessarily. Use rules: changes: to skip.
Forgetting tags:. Job tries to run on any runner; usually fails because Linux runners can't compile iOS. Always tag iOS jobs.
Wrong tags: causing job to never schedule. Tag mismatch with runner registration → job stays "pending" forever.
needs: pointing to non-existent jobs. Pipeline fails to start.
Cache keys unstable across CI runs. If your cache key includes $CI_PIPELINE_ID, every pipeline misses cache. Use stable keys based on file hashes.
Artifacts too big. Whole DerivedData (~5-10 GB) as an artifact bogs down GitLab. Save only what's needed.
Artifacts expiring before download. Default expiry is 30 days; some teams need longer. Set expire_in: per job.
Forgotten artifact paths. A path that doesn't exist in the build → silent miss. The job succeeds without saving anything.
UI tests against simulator that doesn't exist. Verify simulator existence in before_script.
Tests depending on time-of-day. Test that asserts "today is Wednesday" fails on Sundays. Mock dates in tests.
Tests with order dependencies. Test A passes only after test B. Sharding/parallelization breaks them. Refactor to be independent.
Snapshot tests with platform-specific output. Snapshot generated on Mac with retina display fails on CI's headless display. Pin display configurations or use device-independent snapshots.
Network-dependent tests. Tests that hit real APIs fail on flaky networks or when external services are down. Use mocks.
Memory leaks accumulating across tests. Long test suites consume more RAM as they go; eventually crash. Profile, fix leaks, or split into smaller test runs.
Runner Mac sleeping. Power settings → never sleep.
FileVault locked at boot. After a reboot, FileVault is locked; runner can't auto-start. Use a stored recovery key for auto-unlock.
Runner running as wrong user. UID conflicts with development tools, certificates in wrong keychain.
Disk full. Runner accepts jobs until they fail mid-build with confusing errors. Monitor disk; alert when low.
Multiple GitLab Runner versions. Old runner version + new GitLab features = compatibility issues. Update.
Runner not registered to project. Tagged correctly but not granted access to the project. Check Settings → CI/CD → Runners.
Plain variables (not masked) leaking in logs. Mask everything sensitive.
Variables not protected. Available in feature-branch jobs, where any developer can dump them. Protect production credentials.
Variable values that contain shell metacharacters. PASSWORD="ab$cd" evaluates $cd. Quote variables in scripts: "$PASSWORD".
File-type variables with wrong line endings. A .p8 file with CRLF instead of LF doesn't parse. Convert before storing.
Variables clobbered by before_script. Setting a variable in YAML, then export VAR=newvalue in a script — the script wins. Be intentional.
Inconsistent indentation. YAML is whitespace-sensitive. Use a YAML linter.
Tabs vs spaces. YAML rejects tabs in indentation. Use spaces only.
Multiline strings done wrong. | (preserves newlines) vs > (folds to spaces) vs implicit. Pick | for scripts.
Quoting variables in if: rules. if: $VAR == "main" is generally safer than if: $VAR == main (where main might be parsed as a YAML keyword).
Every job rebuilds everything. No caching. Every pipeline starts cold. Devastatingly slow.
Tests with 100% coverage but no parallelism. Sequential is slow. Shard.
Heavy artifacts uploaded between every job. Slow. Use needs: with artifacts: false where unnecessary.
Sequential stages where DAGs would help. Build, then test, then archive — when archive could run alongside test.
No one looking at CI dashboards. Failures pile up; nobody fixes flaky tests; pipelines slowly degrade.
Pipeline owners invisible. "It's broken; whose responsibility is it?" Document ownership.
Production deploys on auto. Tag pushes a build live without confirmation. One typo, one bad merge, and it's on the App Store. Use when: manual.
Untested CI changes pushed to main. Test pipeline changes on a branch first.
Templates updated without versioning. Breaks all consumers simultaneously. Version your templates.
Most CI failures fall into recognizable categories: code signing (mismatch between profile, cert, bundle ID, capabilities), build config (Xcode version, scheme not shared), simulators (state, runtime availability), dependencies (lockfile drift, missing install). Avoid common pipeline anti-patterns: untagged jobs, every job rebuilding from scratch, sequential pipelines, missing rules. Treat your .gitlab-ci.yml as production code; review and test changes carefully. Keep production deploys behind manual gates. Watch the dashboard; pipelines silently rot otherwise.
You've made it through the guide. You know how to set up runners, write pipelines that build and test iOS apps, manage code signing without crying, ship to TestFlight and the App Store, and keep things performant and secure. That's a real CI/CD foundation.
This section points to where to go from here.
Boring but true: GitLab's own documentation is excellent and constantly updated.
docs.gitlab.com/ee/ci/. The reference for everything in .gitlab-ci.yml..gitlab-ci.yml reference. Every keyword documented with examples.For iOS specifically, GitLab's docs are sparse. Most iOS-specific knowledge lives in:
docs.fastlane.tools covers gym, scan, pilot, deliver, match in depth.Good pipelines on GitHub (yes, even for GitLab CI ideas) and GitLab:
.gitlab-ci.yml in iOS-tagged GitLab projects..gitlab-ci.yml. GitLab eats their own dog food. Their pipeline is sophisticated.Reading well-engineered pipelines exposes patterns you wouldn't think of yourself.
Fastlane is a deep tool. Beyond what we've covered:
fastlane-plugin-firebase_app_distribution, fastlane-plugin-bugsnag, dozens more.The fastlane book and the official docs are excellent. Spend a week internalizing them; payoff is years.
Many teams use fastlane as a wrapper without understanding the underlying xcodebuild. When fastlane breaks, they're stuck.
Learn xcodebuild flags directly:
-workspace, -project, -scheme, -configuration, -destination.-sdk, -arch.-derivedDataPath, -resultBundlePath.-only-testing, -skip-testing, -test-iterations, -retry-tests-on-failure.-allowProvisioningUpdates, -allowProvisioningDeviceRegistration.xcodebuild -help is your friend. The man page is dense but comprehensive.
Code signing is the murky depths of iOS CI. Understanding it deeply pays back forever.
security command-line tool. Manages keychains. Power user is fluent.codesign command-line tool. Signs binaries. Useful for forensic inspection.github.com/fastlane/fastlane/tree/master/match. Read it. Eye-opening.When you can debug code signing without panicking — finding entitlement mismatches, comparing profile dates, reading keychain entries — you're a senior iOS engineer.
CI/CD is itself a software system requiring operational care:
Larger teams have a "build infrastructure" role. Smaller teams distribute the work. Either way, someone owns it.
Beyond a single app:
For most teams, conventional CocoaPods/SPM works fine. For very large codebases, advanced build systems pay back.
Beyond GitLab's analytics:
Observability lets you see trends — pipeline slowdowns, test flakiness, runner health — before they become crises.
SwiftUI changes the testing story:
swift-snapshot-testing is the standard.UI testing is its own discipline. Books and blogs cover patterns specific to it.
Beyond basic secret hygiene:
Trends to watch:
Stay current. The ecosystem moves.
Throughout your career, accumulate:
Personal docs. Internal blogs. Code repos with example pipelines. The accumulation pays back across projects.
CI/CD for iOS is a craft. The tools — GitLab, Xcode, fastlane, the macOS command line — are genuinely complex, and getting them to work harmoniously in a maintainable way is real engineering.
But the rewards are real. A team with great CI/CD ships faster, debugs less, and trusts their releases. The friction of "is this build OK?" disappears. Engineers spend more time on product, less on plumbing.
Invest in this. Read deeply. Experiment. Make your team's CI/CD better than they thought possible.
Good luck, and have fun.