Learning iOS GitLab CI/CD

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.

Table of Contents

  1. What GitLab CI/CD Is (and Why You'd Use It for iOS)
  2. The Anatomy of .gitlab-ci.yml
  3. Runners: SaaS macOS Runners vs Self-Hosted
  4. Setting Up a Self-Hosted macOS Runner
  5. Your First iOS Pipeline: Build and Test
  6. Stages, Jobs, and Pipeline Flow
  7. Variables: Predefined, Custom, Masked, Protected
  8. Working with Xcode: xcodebuild Essentials
  9. Running Unit Tests in CI
  10. Running UI Tests in CI
  11. Caching: SPM, CocoaPods, DerivedData
  12. Artifacts: What to Save, What to Ignore
  13. Code Signing: The Hard Part, Solved
  14. Fastlane Integration
  15. Match: Centralized Code Signing
  16. Building for TestFlight
  17. Deploying to TestFlight
  18. App Store Submission
  19. Rules, only/except, and Pipeline Triggers
  20. Merge Request Pipelines
  21. Manual Jobs and Approvals
  22. Environments: Development, Staging, Production
  23. SwiftLint, Tests, and Quality Gates
  24. Code Coverage Reports
  25. Multi-Project Pipelines and Includes
  26. DAG Pipelines: Beyond Sequential Stages
  27. Parent-Child Pipelines
  28. Secrets Management: Keychains, Variables, Vaults
  29. Performance: Parallel Jobs, Sharding Tests
  30. Debugging Failed Pipelines
  31. Common Gotchas and Anti-Patterns
  32. Where to Go Deeper

1. What GitLab CI/CD Is (and Why You'd Use It for iOS)

GitLab 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:

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.

Why CI/CD matters for iOS apps

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.

Why GitLab CI/CD specifically

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.

The iOS-specific reality

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.

What you'll learn from this guide

By the end:

We'll start small — a "build and run tests" pipeline — and grow into something production-ready.

What to internalize

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.


2. The Anatomy of .gitlab-ci.yml

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

The minimum

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.

Top-level keys

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

Then jobs are top-level keys whose values are job definitions.

Job-level keys

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:

YAML syntax that bites

A few YAML quirks that catch people:

Running locally before pushing

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 real iOS skeleton

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.

File location matters

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

Pipeline visualization in GitLab

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.

Pitfalls

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.

What to internalize

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


3. Runners: SaaS macOS Runners vs Self-Hosted

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.

What a runner is

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 SaaS macOS runners

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.

Self-hosted macOS runners

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.

Hybrid: a Mac mini with on-demand SaaS overflow

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.

Cloud-based Mac providers

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

Tags

Regardless of how you provision runners, you assign tags to control which runners pick up which jobs.

Common tags for iOS:

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

Concurrency

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

Choosing for an iOS team

For a team starting out, my recommendation:

  1. One Mac mini in the office with gitlab-runner installed. M2 or M4 silicon, 16-32 GB RAM, 1 TB disk. ~$1000-1500.
  2. Tag it macos and target it for all iOS jobs.
  3. Caching set up so repeat builds are fast.
  4. Document the OS, Xcode, and runner version. These should be reproducible if you need a second Mac.

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.

Pitfalls

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.

What to internalize

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.


4. Setting Up a Self-Hosted macOS Runner

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.

Prerequisites

You need:

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.

Installing gitlab-runner

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.

Registering the runner

Get a registration token from GitLab:

  1. Project → Settings → CI/CD → Runners
  2. Expand "New project runner" (or "New group runner" / "New instance runner" for those scopes)
  3. Configure tags (e.g., macos, xcode-15), description, etc.
  4. Click "Create runner"
  5. Copy the registration token

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:

After registration, gitlab-runner writes config to /usr/local/etc/gitlab-runner/config.toml (or /opt/homebrew/etc/gitlab-runner/config.toml).

The config file

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:

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.

Starting the runner

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

Runner runs as which user?

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.

Auto-login for GUI access

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:

  1. System Settings → Users & Groups → Automatic Login → choose your runner user.
  2. Disable screen saver and sleep on the Mac.
  3. Optionally, install Caffeinate or use 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.

Disk space

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:

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 /

Updating gitlab-runner

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.

Updating Xcode

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

Verifying the runner works

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.

Pitfalls

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.

What to internalize

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.


5. Your First iOS Pipeline: Build and Test

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.

The project setup

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

A first pipeline

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:

Installing xcpretty (or xcbeautify)

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.

Pipefail

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

Selecting a simulator

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

Creating simulators in CI

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.

What the output looks like

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

Test reports in GitLab

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.

The "before script" pattern

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.

Caching to make it fast

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.

Iterating on the pipeline

After your first pipeline runs:

  1. Check the log. Even successful builds have warnings worth reading.
  2. Make a small change to .gitlab-ci.yml. Push.
  3. Watch the new pipeline.
  4. Repeat.

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.

Pitfalls

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.

What to internalize

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.


6. Stages, Jobs, and Pipeline Flow

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.

The default model

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.

A minimal multi-stage pipeline

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:

  1. lint runs alone.
  2. After lint succeeds, unit_tests and ui_tests start in parallel.
  3. After both tests succeed, build runs.

If any stage fails, subsequent stages are skipped (by default).

Why parallel within stages

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.

When jobs are blocked

Jobs in a stage can be blocked by:

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 ordering

Sometimes 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_failure

Sometimes 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 timing

By 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:

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

Stages that always run

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

Multiple jobs across stages

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:

Pipeline visualization

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

What runs on what

To keep the pipeline understandable, group related work in one stage. Don't have 20 stages. A typical good shape:

If you find yourself wanting 8 stages, you might be over-engineering — DAG with needs is usually a better way to express complex dependencies.

Pipeline retry

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 sequentially

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

Pitfalls

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.

What to internalize

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.


7. Variables: Predefined, Custom, Masked, Protected

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.

Predefined variables

GitLab provides many predefined variables to every job. The most useful for iOS:

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

Defining custom variables

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.

UI-defined variables

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:

Use these for:

Masked variables

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.

Protected variables

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.

File-type variables

For multi-line values like PEM keys or .p12 certificate exports:

  1. UI: Add Variable → Type: File → paste the multi-line content.
  2. The variable name (e.g., APPLE_API_KEY_FILE) is set to a path, not the content.
  3. In the job, the file exists at that path. You read it with 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.

Variable precedence

When the same variable is defined in multiple places, this is the precedence (highest wins):

  1. Trigger variables / scheduled pipeline variables
  2. Project-level UI variables
  3. Group-level UI variables
  4. Instance-level UI variables
  5. Job-level variables: in .gitlab-ci.yml
  6. Top-level variables: in .gitlab-ci.yml
  7. default: block variables
  8. Auto-DevOps variables
  9. Predefined variables

So 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 in conditions

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.

Inheritance and scope

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

Common iOS variable patterns

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):

Pitfalls

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

What to internalize

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.


8. Working with Xcode: xcodebuild Essentials

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.

The basic invocations

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

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

-scheme

Schemes 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

-destination

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

-configuration

Build configuration: Debug or Release (or custom configurations you've defined).

xcodebuild ... -configuration Debug
xcodebuild ... -configuration Release

Debug for tests; Release for distribution.

-derivedDataPath

Where xcodebuild puts intermediate build outputs. Default is ~/Library/Developer/Xcode/DerivedData.

xcodebuild ... -derivedDataPath ./DerivedData

For CI:

A common pattern is to set it explicitly:

variables:
  DERIVED_DATA: "$CI_PROJECT_DIR/DerivedData"

build:
  script:
    - xcodebuild ... -derivedDataPath $DERIVED_DATA
  cache:
    paths:
      - DerivedData/

-resultBundlePath

Saves an .xcresult bundle (Xcode's structured test results format).

xcodebuild ... -resultBundlePath build/MyApp.xcresult test

Useful for:

Always specify this for test jobs.

Selecting an Xcode version

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_DEFINITIONS

To 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:

-quiet and output verbosity

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

Common errors and what they mean

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

Build-only vs test-only

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.

xcrun

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

Pretty-printing tools

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.

Pitfalls

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.

What to internalize

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.


9. Running Unit Tests in CI

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.

The basic test command

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.

Test plans (modern approach)

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

Running specific tests

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.

Generating JUnit reports

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.

Reporting to GitLab

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

Sample Fastfile for tests

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.

Code coverage

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.

Boot a simulator before tests

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.

Test parallelization (per-target)

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.

Testing on multiple simulators

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.

Failing fast

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.

Pitfalls

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.

What to internalize

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.


10. Running UI Tests in CI

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.

What makes UI tests hard

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.

The basic UI test job

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.

Auto-login required

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.

Simulator preparation

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

Headless?

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

Capturing screenshots and video

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.

Video recording

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?"

Retrying flaky tests

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 on multiple devices

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.

Splitting UI tests across jobs

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.

Test isolation

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.

When UI tests don't fit

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.

Pitfalls

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.

What to internalize

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.


11. Caching: SPM, CocoaPods, DerivedData

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

How GitLab cache works

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

What to cache

For iOS, the candidates:

Each has different cache characteristics.

Caching Bundler

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.

Caching CocoaPods

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.

Caching SPM

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.

Caching DerivedData

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.

Cache key strategies

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:

Cache scopes: job, stage, pipeline

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.

Cache fallback

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.

Multiple caches

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.

Self-hosted runners and caching

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.

Cache eviction

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 {} \;

Pitfalls

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.

What to internalize

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.


12. Artifacts: What to Save, What to Ignore

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.

Basics

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.

Multiple paths

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 upload

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

Reports

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:

For iOS, junit and coverage_report are the main two. codequality if you set up SwiftLint reporting.

Artifacts vs cache

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.

Passing artifacts between jobs

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.

What not to artifact

Don't save:

Do save:

A good rule: artifact what a human or downstream job needs to consume.

dSYMs

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.

Excluding files

artifacts:
  paths:
    - build/
  exclude:
    - build/**/*.swiftmodule
    - build/**/*.swiftdoc

exclude: removes matching paths from what's saved. Useful for trimming noise.

Artifact size

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.

Browsing artifacts in the UI

After a job finishes, the GitLab UI shows artifacts. You can:

For shareable IPAs (e.g., for QA), the direct-link pattern is useful — bookmark the URL, get the latest IPA from that job.

artifacts:public

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

Pitfalls

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.

What to internalize

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.


13. Code Signing: The Hard Part, Solved

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.

What code signing actually is

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):

  1. Compiles the code.
  2. Embeds the provisioning profile.
  3. Computes signatures of every file in the bundle.
  4. Signs them with the certificate's private key.
  5. Wraps the bundle as an IPA.

The IPA can then be installed on devices listed in the profile (for development/ad-hoc) or submitted to Apple for distribution.

Certificate types

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

Provisioning profile types

The CI challenge

Local development:

  1. You log in to Xcode with your Apple ID.
  2. Xcode auto-creates certs and profiles.
  3. They're stored in your login keychain.
  4. Xcode signs builds using them.

CI:

  1. The runner has no Apple ID logged in.
  2. There's no GUI Xcode session.
  3. The keychain is empty (or has only the runner user's stuff).
  4. Manual provisioning profile setup.

You must, in the CI pipeline:

  1. Have certificates and profiles available.
  2. Import certificates into a keychain.
  3. Make the keychain accessible to xcodebuild.
  4. Install provisioning profiles in the right location.
  5. Tell xcodebuild which identity and profile to use.

Doing this manually is doable; doing it correctly is finicky. Hence Fastlane and match.

The manual approach (for understanding)

Step 1: Export certs and profiles

On a development Mac with the certs/profiles installed:

  1. Open Keychain Access → "login" keychain → My Certificates → find the iOS Distribution certificate → right-click → Export → format .p12. Set a strong password — this protects the private key.
  2. Find the provisioning profile at ~/Library/MobileDevice/Provisioning Profiles/. Each is a .mobileprovision file. Copy the relevant ones.

You now have:

Step 2: Make them available to CI

Two options:

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

Step 3: In the CI job, set up the keychain

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:

Step 4: Build and sign

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>

Step 5: Cleanup

after_script:
  - security delete-keychain ci-keychain || true
  - rm -f distribution.p12 profile.mobileprovision

Why we don't do this manually in production

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.

Automatic signing in CI (don't)

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

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

Pitfalls

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.

What to internalize

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.


14. Fastlane Integration

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.

Why Fastlane

The alternatives are: hand-rolled shell scripts (verbose, fragile), or one-off CI jobs that call xcodebuild directly (works for simple cases). Fastlane provides:

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

Installation

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

Fastlane init

Initialize Fastlane in your project:

cd path/to/MyApp
bundle exec fastlane init

Walks through a setup wizard. Creates a fastlane/ directory with:

Commit these to the repo.

Fastfile structure

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_ci

The setup_ci action is critical. It:

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

Calling Fastlane from .gitlab-ci.yml

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

Common actions

A reference of the most-used Fastlane actions for iOS CI:

Each has many options; consult Fastlane's docs.

Configuration files

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.

App Store Connect API key

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:

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.

Logging and verbosity

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.

Customizing per-environment

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

Pitfalls

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.

What to internalize

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.


15. Match: Centralized Code Signing

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.

How match works

  1. You have a private git repository — call it certificates.
  2. Match generates certs and profiles via the Apple Developer API and pushes encrypted versions to this repo.
  3. On any machine, match nuke deletes everything; match generate (or specifically match appstore/match development/etc.) fetches/regenerates as needed.
  4. On CI, match fetches and installs into the keychain.

Encryption: match uses a passphrase you control. The certs/profiles in the repo are useless without it.

Setting up match

Step 1: Create a private repo

In GitLab, create a new private repository, e.g., myorg/ios-certificates. Initialize it empty.

Step 2: Initialize match

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")

Step 3: Generate initial certs

bundle exec fastlane match development
bundle exec fastlane match appstore

This:

  1. Logs into Apple Developer (use App Store Connect API key for non-interactive).
  2. Generates a development cert (or finds existing).
  3. Creates a development provisioning profile.
  4. Encrypts everything with your passphrase.
  5. Pushes to the certificates repo.

Match prompts for a passphrase the first time. Save this passphrase securely — you'll need it everywhere.

Step 4: Verify

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

Using match in CI

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

CI variables for match

In GitLab UI, set:

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

App Store Connect API key for match

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"]
  )
)

Match types

Per app, you might have all four (or just development + appstore for typical use).

Multiple bundle IDs

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 nuke

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

Local development with match

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.

Match maintenance

Annually or so:

For most teams, the maintenance is "developer regenerates once a year on their Mac, push to certs repo, everyone else syncs."

Changing the match passphrase

To rotate:

  1. Run match change_password locally.
  2. Update GitLab variable.
  3. New encrypted versions in the certs repo.

Pitfalls

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.

What to internalize

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.


16. Building for TestFlight

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.

What "build for TestFlight" actually means

A TestFlight-ready build is:

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 xcodebuild archive command

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:

Exporting the IPA

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:

If your app has extensions (Today extension, Watch app, etc.), each needs its own bundle ID and profile in this dict.

A complete TestFlight build job

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:

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.

A more explicit version using xcodebuild directly

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.

Build numbers and versioning

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:

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

Signing verification

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)

dSYMs and crash reporting

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:

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.

Conditional build types

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.

Pitfalls

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.

What to internalize

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


17. Deploying to TestFlight

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.

What "deploying to TestFlight" means

When you upload an IPA to App Store Connect:

  1. Apple's servers receive the binary.
  2. App Store Connect runs validation — checks bundle structure, signing, entitlements, embedded frameworks, info.plist values.
  3. If validation passes, the build is "Processing." This takes 5-30 minutes — Apple is doing additional analysis.
  4. Once processed, the build is available in TestFlight. Internal testers see it immediately; external testers see it after Beta App Review (which takes hours to days for the first build, faster for updates).

The CI's job is the upload + setting metadata (test notes, tester groups). Apple handles the rest.

Upload tools

Three common tools:

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.

A first deploy job

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:

The job pulls the IPA from the build job's artifacts (because needs:), uploads it, and exits.

Authentication: App Store Connect API key

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.

Release notes (what's new for testers)

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.

Tester groups

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

Multi-app workflows

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.

Handling failures

TestFlight upload failures fall into a few buckets:

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.

Slack/notification on success

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.

Adding a wait for processing

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.

Distributing to specific testers (not just groups)

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 complete distribute stage

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.

Pitfalls

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.

What to internalize

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.


18. App Store Submission

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.

What "App Store submission" means

The full release flow:

  1. Upload an IPA in app-store distribution mode (same as TestFlight).
  2. Set version metadata in App Store Connect — release notes, screenshots, app description, what's-new text.
  3. Submit for App Review. Reviewers verify the app meets Apple's guidelines.
  4. Apple approves (or rejects). Approval typically takes 24-48 hours but varies.
  5. Release — automatic upon approval, manual at your discretion, or scheduled for a date.

The first three are scriptable. Approval is Apple's. Release timing is your call.

What you can automate

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.

fastlane deliver (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:

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.

Metadata in fastlane

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

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.

Versioning in App Store Connect

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.

Submitting for review

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.

Auto-release vs phased release

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.

Review information

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.

Build selection in deliver

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.

Common scenarios

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.

Pitfalls

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.

What to internalize

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.


19. Rules, only/except, and Pipeline Triggers

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 way

Older 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 way

rules 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:

Common iOS patterns

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

Common variables

Variables you'll use heavily in if::

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 control

Within a rule, when controls how the job runs:

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 jobs

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

Pipeline-level rules vs job-level rules

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.

Avoiding double pipelines

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.

Conditional job names and parallel runs

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

Schedules: pipelines on a timer

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 needs

Two different concepts often confused:

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.

Pitfalls

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+$/.

What to internalize

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


20. Merge Request Pipelines

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.

Branch pipelines vs MR pipelines

When you push a commit to a branch:

These are distinct. Their $CI_PIPELINE_SOURCE differs:

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

Configuring MR pipelines

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

Merged result pipelines

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

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.

Required pipelines (gating merges)

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.

A practical MR pipeline

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.

Distributing preview builds

The IPA from build-preview is artifacted but unwieldy to install. Make it easier:

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"

MR pipeline status in the UI

GitLab shows pipeline status next to each MR:

Hover over the icon for details. Click into the pipeline for full job logs.

Failed pipeline workflow

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.

Skipping CI on draft MRs

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.

Detached pipelines

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.

Pipeline performance for MR feedback

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:

A typical iOS MR pipeline targets:

Pitfalls

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.

What to internalize

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.


21. Manual Jobs and Approvals

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.

Manual jobs basics

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.

Use cases

Common scenarios for manual jobs:

Manual jobs in practice — App Store flow

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:

  1. Upload — automatic on tag.
  2. Submit for review — manual. Click after sanity-checking the App Store Connect listing.
  3. Release — manual. Click after Apple approves and you're ready to make it live.

This gives clear human checkpoints. CI does the work; humans make the decisions.

allow_failure: true on manual jobs

If 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 jobs

To 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:

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.

Approvals via merge requests (workaround)

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:

  1. Auto-deploy goes to staging.
  2. Production deploy is a manual job triggered by clicking play in the pipeline.
  3. To "approve" a production deploy, two team members both need to click play (or you trust one person).

For more formal approvals, integrate with external systems via webhooks or external status checks.

start_in: for delayed manual jobs

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

Manual jobs and pipeline status

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.

Pitfalls

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.

What to internalize

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


22. Environments: Development, Staging, Production

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.

What an environment is

In GitLab, an environment represents a deployment target. Examples:

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.

Declaring environments in pipelines

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.

Viewing environments

Project → Operate → Environments. Lists all environments, current deployment, history, links.

For each, you see:

Dynamic environments

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:

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.

Deployment tracking

GitLab tracks not just "did the deploy job succeed" but specifically what was deployed. The deployment is associated with:

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.

Preventing concurrent deployments

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:

Rollback via environments

To rollback:

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.

Manual deploy to specific environments

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.

Environment-specific variables

CI/CD variables can be scoped to specific environments. Configure in Settings → CI/CD → Variables → "Environment scope."

Examples:

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.

Using environment URLs in scripts

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

Environment lifecycle

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.

Why bother with environments

Without environments, your pipeline runs the deploy job and that's it. With environments:

For small teams, environments feel like overhead. For larger or longer-lived projects, they pay back in operational visibility.

Pitfalls

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.

What to internalize

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


23. SwiftLint, Tests, and Quality Gates

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 integration

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:

Now lint failures show up nicely in the MR. Each violation links back to the file and line.

SwiftLint installation

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 (different beast)

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 gates

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

Coverage gates

Code coverage gates require:

  1. Test jobs collect coverage.
  2. Coverage is reported to GitLab.
  3. Coverage threshold rule blocks merges below threshold.

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.

Required vs allowed-failure quality jobs

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.

Warning vs error

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.

Other useful checks

Beyond lint and test:

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 as a culture

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.

Pitfalls

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.

What to internalize

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.


24. Code Coverage Reports

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.

Enabling coverage in Xcode

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.

Running tests with coverage

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.

Parsing coverage from 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%).

Reporting to GitLab

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:

Coverage reports in MRs

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.

Coverage thresholds

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.

Reading the report

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}'

Excluded files

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.

Coverage by target/module

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%").

Coverage in pipeline summary

The simplest visualization is the coverage: regex output, surfaced on:

For repos that have configured this, the project README often has a coverage badge:

[![coverage report](https://gitlab.com/group/project/badges/main/coverage.svg)](https://gitlab.com/group/project/-/commits/main)

Live-updating SVG badge showing current main-branch coverage. Pretty, motivating.

Diff coverage

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

Pitfalls

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.

What to internalize

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.


25. Multi-Project Pipelines and Includes

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.

YAML includes

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.

Including from other projects

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.

Including from public templates

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.

Including remote URLs

include:
  - remote: 'https://example.com/shared-pipelines/ios.yml'

Pulls from any HTTPS URL. Use cautiously — you're trusting the remote.

A real templates project

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.

Versioning includes

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:

  1. Make changes in the template repo.
  2. Tag a new version (v3.3.0).
  3. Consuming projects update their ref: to v3.3.0 when they're ready.

Without versioning, every change to template main impacts every consuming project immediately. Risky for stability.

Multi-project pipelines

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:

  1. SDK tests run.
  2. If they pass, the main app's pipeline triggers.
  3. Main app pulls the new SDK version and runs its tests.
  4. If main app's pipeline fails, the SDK's pipeline fails.

This is integration testing across repos.

Passing variables to triggered pipelines

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.

Cross-project pipeline triggers via tags

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.

Includes vs triggers — when to use which

For a team with one SDK + multiple apps:

Both, in combination, are powerful.

Component-style includes

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.

Pitfalls

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.

What to internalize

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.


26. DAG Pipelines: Beyond Sequential Stages

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.

The problem with stages

A typical iOS pipeline:

stages:
  - lint
  - test
  - build
  - distribute

Imagine:

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

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

Visualizing DAGs

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 artifacts

needs: 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 stages

A 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 pipelines

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

Optimal pipeline shape for iOS

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 contention

DAG 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:

Section 28 covers this.

When stages still help

DAGs everywhere isn't always best. Stages have benefits:

Use DAGs where they buy real speed; stick with stages for simple pipelines.

Pitfalls

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.

What to internalize

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.


27. Parent-Child 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:

Basic parent-child syntax

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

When parent-child helps

Compared to a single mega-pipeline, parent-child:

Compared to includes (covered in section 25), parent-child pipelines:

Conditional parent-child

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.

Dynamically-generated child pipelines

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:

Passing variables to child pipelines

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 vs not

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.

Failure handling

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.

Real example: monorepo with shared code

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.

Pitfalls

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.

What to internalize

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.


28. Secrets Management: Keychains, Variables, Vaults

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.

Levels of secret protection in GitLab

In Settings → CI/CD → Variables, you have these options per variable:

The combination matters:

Masking effectiveness

Masked variables are hidden from logs. But:

For genuine security:

File-type variables

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.

Keychain for code signing

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

Match's keychain handling

When you call fastlane match, it:

  1. Creates a temp keychain (fastlane_tmp_keychain or similar).
  2. Imports certs into it.
  3. Sets the keychain as default.
  4. Cleans up at job end (or relies on launchd at next reboot).

This is invisible to you. If you call match, you don't need the manual setup-keychain.sh.

App Store Connect API key handling

Three pieces of info:

Store as:

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

External secret stores

For very secret-heavy organizations, dedicated secret managers:

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.

Rotation

Plan for rotating secrets:

Document rotation procedure. Test it before you need it.

Secret detection

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.

Logging hygiene

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:

Pitfalls

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.

What to internalize

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.


29. Performance: Parallel Jobs, Sharding Tests

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.

Where iOS pipeline time goes

Profile your pipeline first. Common culprits:

A naive sequential pipeline easily hits 30+ minutes. Targeted optimizations bring this to 10-15.

Parallel test execution

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:

Test sharding via parallel matrix

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:

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

Caching aggressively

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.

Build-for-testing pattern

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.

Pre-warmed simulators

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.

Skipping unchanged work

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.

Shallow clone

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.

Parallel across multiple iOS versions

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.

Using the right runner for the right job

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.

Caching xcbuild outputs to runners' disks

For heavy CI machines that run frequent builds:

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.

Network optimization

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.

Profiling pipelines

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.

The 80/20 of pipeline speed

For most iOS teams, the highest-impact changes:

  1. Cache SPM/CocoaPods. 1-3 minutes saved per pipeline.
  2. Use needs: for parallelism. Build runs alongside tests. 5+ minutes saved.
  3. Shard tests. Split into 4 parallel jobs. 5-15 minutes saved on test-heavy pipelines.
  4. Skip unchanged code. Doc-only PRs don't trigger iOS builds. 10-15 minutes saved.
  5. 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.

Pitfalls

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.

What to internalize

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.


30. Debugging Failed Pipelines

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.

Reading pipeline failures

The fundamental loop:

  1. GitLab notifies (Slack, email, MR status).
  2. Open the failed pipeline.
  3. Identify the failed job (red icon).
  4. Open the job log.
  5. Scroll to the error. Usually near the bottom, but not always.
  6. Read carefully.
  7. Reproduce locally if possible.
  8. Fix and push.

The skill is in steps 5-7. iOS errors are often verbose and indirect.

Common iOS-specific error patterns

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.

Reproducing locally

The most powerful debugging technique: run the exact CI command on your machine.

For iOS, this means:

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

Logging for debug

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.

Interactive debugging via SSH

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 debugging

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

Ruby and fastlane debugging

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

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.

xcresult inspection

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.

Job artifacts on failure

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.

Retrying failed jobs

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.

Bisecting via git history

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.

Comparing pipelines

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.

Post-failure cleanup

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.

Pipeline dashboard analytics

For chronic issues, use GitLab's CI/CD Analytics:

If the same job fails 30% of the time, it's flaky and needs attention.

Logs vs. trace

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.

Pitfalls

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.

What to internalize

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.


31. Common Gotchas and Anti-Patterns

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.

Code signing gotchas

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.

Build configuration gotchas

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 gotchas

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.

CocoaPods gotchas

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.

Fastlane gotchas

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.

Pipeline structure gotchas

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.

Artifact gotchas

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.

Testing gotchas

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 gotchas

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.

Variable and secrets gotchas

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.

YAML syntax gotchas

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

Performance anti-patterns

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.

Process anti-patterns

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.

What to internalize

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.


32. Where to Go Deeper

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.

Read the docs

Boring but true: GitLab's own documentation is excellent and constantly updated.

For iOS specifically, GitLab's docs are sparse. Most iOS-specific knowledge lives in:

Read other people's pipelines

Good pipelines on GitHub (yes, even for GitLab CI ideas) and GitLab:

Reading well-engineered pipelines exposes patterns you wouldn't think of yourself.

Master fastlane

Fastlane is a deep tool. Beyond what we've covered:

The fastlane book and the official docs are excellent. Spend a week internalizing them; payoff is years.

Learn xcodebuild fluently

Many teams use fastlane as a wrapper without understanding the underlying xcodebuild. When fastlane breaks, they're stuck.

Learn xcodebuild flags directly:

xcodebuild -help is your friend. The man page is dense but comprehensive.

Master code signing

Code signing is the murky depths of iOS CI. Understanding it deeply pays back forever.

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 operations

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.

Multi-app and modular architecture

Beyond a single app:

For most teams, conventional CocoaPods/SPM works fine. For very large codebases, advanced build systems pay back.

Observability for builds

Beyond GitLab's analytics:

Observability lets you see trends — pipeline slowdowns, test flakiness, runner health — before they become crises.

CI for SwiftUI and UI testing

SwiftUI changes the testing story:

UI testing is its own discipline. Books and blogs cover patterns specific to it.

iOS-specific security in CI

Beyond basic secret hygiene:

Where iOS CI is going

Trends to watch:

Stay current. The ecosystem moves.

Build a personal mental library

Throughout your career, accumulate:

Personal docs. Internal blogs. Code repos with example pipelines. The accumulation pays back across projects.

Closing thought

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.