iOS CI/CD: The Complete Production Guide
From Zero to Automated App Store Deployments β A Beginner-Friendly Deep Dive
Who This Guide Is For
You are an iOS developer who has been building and deploying your app manually β pressing "Run" in Xcode, archiving by hand, dragging IPAs into App Store Connect, and praying you remembered to switch to the Release configuration. This guide will take you from that world into one where a single git push triggers an entire pipeline that builds your app, runs every test, signs the binary, uploads it to TestFlight, and notifies your team on Slack β all without you touching Xcode.
Prerequisites you should have:
- A working Xcode project (ideally with Configurations, Schemes, and Targets set up β see the companion "iOS Xcode: Configurations, Schemes & Targets" guide)
- Basic Git knowledge (commit, push, pull, branches)
- A paid Apple Developer account ($99/year β required for code signing and distribution)
- A GitHub account (we use GitHub Actions as our primary CI platform)
- Basic terminal/command-line comfort
What you do NOT need:
- Prior experience with CI/CD on any platform
- Ruby knowledge (Fastlane is written in Ruby, but you will use it like a configuration tool β not write Ruby programs)
- DevOps experience
- Knowledge of Docker, Kubernetes, or cloud infrastructure
Table of Contents
- Chapter 1: What Is CI/CD and Why iOS Makes It Hard
- Chapter 2: The iOS CI/CD Pipeline β Bird's Eye View
- Chapter 3: Code Signing Demystified
- Chapter 4: Fastlane β Your Automation Swiss Army Knife
- Chapter 5: Fastlane Match β Team Code Signing Solved
- Chapter 6: Automating Tests
- Chapter 7: GitHub Actions for iOS β Complete Setup
- Chapter 8: TestFlight Deployment Automation
- Chapter 9: App Store Deployment Automation
- Chapter 10: Xcode Cloud β Apple's Native CI/CD
- Chapter 11: Advanced Topics
- Chapter 12: Troubleshooting Guide
- Chapter 13: Complete Reference & Cheat Sheets
Chapter 1: What Is CI/CD and Why iOS Makes It Hard
1.1 The Core Idea
Imagine you work at a bakery. Every morning, you:
- Mix the dough (write code)
- Check if the dough tastes right (run tests)
- Bake the bread (build the app)
- Wrap it for customers (sign and package)
- Put it on the shelf (deploy to TestFlight or the App Store)
Now imagine you do all five steps by hand every single day. Some days you forget to taste the dough. Other days the oven is set to the wrong temperature. Sometimes you wrap the bread in the wrong bag.
CI/CD is an automated bakery. You put the dough in one end, and a perfectly wrapped loaf comes out the other end β every single time, with no human mistakes.
Here is what those letters stand for:
CI β Continuous Integration: Every time a developer pushes code to the shared repository, the system automatically pulls that code, builds it, and runs all the tests. If anything breaks, the team finds out in minutes β not days later when someone manually tries to build.
Think of CI as a robot that watches your Git repository. Every time new code arrives, the robot says: "Let me try to build this and run all the tests. I'll tell you if anything is broken."
The word "Continuous" is the key. It does not mean "once a day" or "when we remember." It means every single push, every single pull request, every single time. The moment code lands in the repository, the robot is already building it. This is what makes it powerful β problems are caught immediately, while the code is still fresh in the developer's mind.
The word "Integration" means bringing code together. In a team of five developers, each person writes their own code on their own branch. Integration is the moment those five branches come together. Without CI, you might not discover that Developer A's login screen changes broke Developer B's profile page until a week later. With CI, you find out within minutes of the merge.
CD β Continuous Delivery / Continuous Deployment: Once the code passes all tests, the system automatically packages it up and delivers it to the next destination. That destination might be:
- TestFlight β for beta testers and QA
- App Store Connect β for App Store review and public release
- An internal distribution system β for your team to test
Think of CD as the robot saying: "Everything passed! I've already uploaded it to TestFlight. Your testers can install it right now."
There is a subtle but important distinction between Continuous Delivery and Continuous Deployment:
- Continuous Delivery means the app is always in a deployable state and CAN be deployed with one click. A human still makes the final decision to release.
- Continuous Deployment means the app is automatically deployed all the way to production with no human intervention.
For iOS, most teams use Continuous Delivery because Apple requires a review process β you cannot deploy directly to the App Store without Apple's approval. However, you can continuously deploy to TestFlight (which does not require manual approval for internal testers), giving your QA team a fresh build every single day automatically.
1.2 What Life Looks Like WITHOUT CI/CD
Let's paint a realistic picture of the manual workflow so you can appreciate what CI/CD replaces:
Monday morning β releasing version 1.3.0:
- Developer opens Xcode, switches to the
mainbranch - Developer realizes they have uncommitted changes on the branch. Stashes them.
- Developer goes to the scheme picker, selects "MyApp-Production" β wait, was it "MyApp-Release"? They check with a colleague.
- Developer opens Build Settings, manually verifies the API URL is pointing to production. It is pointing to staging. They change it manually.
- Developer goes to Product > Archive. The build takes 15 minutes.
- The archive fails because CocoaPods is out of date. They run
pod install, wait 3 minutes, and archive again. - The archive succeeds. They click "Distribute App" and work through the signing wizard.
- Xcode says the provisioning profile has expired. They open the Apple Developer Portal, regenerate the profile, download it, double-click to install it, and try again.
- The upload to App Store Connect begins. It takes 5 minutes.
- It fails because the build number was not incremented. They go to the target settings, change the build number from 42 to 43, and start the entire archive process again. Another 15 minutes.
- The upload succeeds. They go to App Store Connect, wait for processing (30 minutes), fill in the "What's New" text, and click "Submit for Review."
- Total time: approximately 1.5 hours. And the developer forgot to update the API URL back to development. The next day's debugging session uses production data by mistake.
The same Monday morning WITH CI/CD:
- Developer pushes a tag:
git tag v1.3.0 && git push --tags - The CI pipeline automatically builds, tests, signs, archives, uploads to TestFlight, and submits to the App Store.
- Developer gets a Slack notification 20 minutes later: "v1.3.0 submitted for App Store review."
- Total human effort: one terminal command. Total human time: 10 seconds.
1.3 Why iOS CI/CD Is Uniquely Challenging
If you have ever done CI/CD for a web application or an Android app, iOS will surprise you with its complexity. Here is why β and this context is important so you understand WHY the tools and steps in later chapters exist.
Challenge 1: The macOS Requirement
iOS apps can ONLY be built on macOS. This is a hard requirement imposed by Apple β the Xcode build toolchain (the compiler, linker, and SDK) only runs on macOS. You cannot build an iOS app on Linux or Windows. Period.
This has enormous implications for CI/CD:
- Cost: Cloud macOS machines are expensive. A GitHub Actions Linux runner costs $0.008/minute. A macOS runner costs $0.08/minute β 10x more. A 30-minute iOS build costs $2.40, compared to $0.24 for a typical web build on Linux.
- Availability: There are fewer macOS cloud providers than Linux providers. Your options are GitHub Actions (macOS runners), Xcode Cloud (Apple's service), MacStadium (dedicated Macs in a data center), AWS EC2 Mac instances, and self-hosted Mac Minis.
- Virtualization: macOS virtual machines are legally restricted β Apple's license only allows macOS VMs to run on Apple hardware. This means you cannot spin up macOS VMs on commodity x86 servers like you can with Linux.
- Speed: macOS runners are generally slower to provision than Linux containers because you cannot simply spin up a new container β you need an actual macOS environment.
Challenge 2: Code Signing β The #1 Headache
Before Apple allows your app to run on ANY device (even your own test phone), the app must be code signed. Code signing is Apple's security system that guarantees:
- The app was created by a known, trusted developer (you)
- The app has not been modified since the developer created it
- The app is authorized to run on this specific device (or any device, for App Store builds)
Code signing involves a chain of cryptographic certificates, private keys, and provisioning profiles that all must match perfectly. It is the single most common source of CI/CD failures for iOS teams. We dedicate all of Chapter 3 to understanding it.
The core problem for CI/CD is this: code signing credentials (certificates and private keys) live in your Mac's Keychain. Your CI server is a DIFFERENT Mac. How do you get the credentials there? Securely? Automatically? And what about the other five developers on your team? This is the problem that Fastlane Match solves (Chapter 5).
Challenge 3: Provisioning Profiles
Even after you have certificates (your identity), you need provisioning profiles β files that act as permission slips. A provisioning profile says: "This specific app (com.yourcompany.myapp), signed by this specific certificate (developer John Smith's certificate), is allowed to run on these specific devices (iPhone UDID abc123, UDID def456)."
Provisioning profiles:
- Must be generated in the Apple Developer Portal
- Must match your certificate exactly
- Must list every device that can run the app (for development and ad-hoc builds)
- Expire after approximately one year
- Must be regenerated whenever you add a new test device
- Are different for development, ad-hoc, and App Store distribution
This is another layer of complexity that does not exist on other platforms. Android apps are signed with a simple keystore file β there are no provisioning profiles, no device lists, no expiration dates.
Challenge 4: Xcode Version Management
Apple releases new versions of Xcode several times a year. Each version:
- Includes a new iOS SDK (e.g., Xcode 16 includes the iOS 18 SDK)
- May change the build system behavior
- Includes new simulator runtimes (and may remove old ones)
- May require a newer version of macOS
- May change the format of project files or build settings
Your CI machine must have the correct version of Xcode installed. If your project uses Xcode 16 features but your CI machine has Xcode 15, the build will fail. If your CI machine has Xcode 16.1 but your project was configured with Xcode 16.0, subtle differences might cause issues.
Many teams pin their Xcode version explicitly:
# In CI, select the exact Xcode version
sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer
Some teams even use tools like xcodes (a command-line Xcode version manager) to install and switch between versions:
# Install a specific Xcode version
xcodes install 16.0
xcodes select 16.0
Challenge 5: App Store Review
Unlike web applications (where you deploy and it is live instantly) or even Android (where Google Play review takes hours, not days), Apple reviews every iOS app submission. This review can take 24 hours to several days. Your pipeline cannot deploy to production instantly β it can only submit for review and then wait.
This means your pipeline needs to handle:
- Metadata management: App descriptions, screenshots, keywords, age ratings β all must be correct before submission
- Review notes: Sometimes the review team needs a demo account or special instructions
- Rejection handling: If Apple rejects the build, you need to fix the issue and resubmit
- Phased releases: You might want to roll out to 1% of users first, then 5%, then 25%, etc.
Challenge 6: Binary Size and Build Times
A typical iOS app takes 5-30 minutes to compile from a clean state. A large app with many dependencies can take even longer. This means:
- CI costs add up fast: 20 minutes Γ $0.08/min = $1.60 per build. If you build 50 times a day, that is $80/day just for builds.
- Caching is essential: You need to cache compiled artifacts, dependency downloads, and other intermediate products to avoid rebuilding everything from scratch every time.
- Incremental builds vs. clean builds: CI servers often start with a clean state (no cached build artifacts), which means every build is a clean build β the slowest kind.
1.4 The Payoff β Why It Is Worth the Effort
Despite the complexity, a well-configured iOS CI/CD pipeline saves enormous amounts of time and prevents entire categories of bugs:
- No more "works on my machine" problems β every build happens in a clean, reproducible environment. If it works in CI, it works everywhere.
- No more forgetting to switch configurations β the pipeline always builds with the correct settings. The Staging pipeline always uses the Staging configuration. The Production pipeline always uses the Release configuration. There is no human to make a mistake.
- No more manual code signing headaches β certificates and profiles are managed automatically by Fastlane Match. No more emailing
.p12files to colleagues. - Faster feedback β developers know within minutes if their code broke something. The sooner you find a bug, the cheaper it is to fix.
- Consistent builds β every TestFlight or App Store build is created the exact same way, on the same kind of machine, with the same tools and dependencies. No more "but it built fine on my laptop."
- One-click releases β deploy to production with a single command or git tag. The entire process that used to take 1.5 hours now takes 10 seconds of human effort.
- Audit trail β every build is logged, including which code was built (exact Git commit), which tests passed, who triggered it, and when it was deployed. This is invaluable for debugging production issues.
- Team velocity β new developers can start contributing on day one. They do not need to learn the manual build/deploy process. They push code, CI handles the rest.
- Confidence β you can merge pull requests knowing that if all CI checks pass, the code is safe to ship. This psychological benefit cannot be overstated.
1.5 CI/CD Terminology Glossary
Before we proceed, here are terms you will see throughout this guide:
| Term | Definition |
|---|---|
| Pipeline | The entire automated process from code push to deployment. Also called a "workflow" in GitHub Actions. |
| Runner / Agent | The machine that executes the pipeline. For iOS, this must be a Mac. |
| Job | A set of steps that run on a single runner. A pipeline can have multiple jobs (e.g., one for testing, one for deploying). |
| Step | A single task within a job (e.g., "install CocoaPods" or "run tests"). |
| Artifact | A file produced by the pipeline that you want to keep (e.g., the IPA file, test results, screenshots). |
| Secret | A sensitive value (password, API key, certificate) stored securely in the CI platform and injected as an environment variable. |
| Trigger | The event that starts a pipeline (e.g., a push to a branch, a pull request, a tag, a manual click). |
| Cache | Stored files from a previous build that can be reused to speed up the next build (e.g., compiled dependencies). |
| IPA | iOS App Store Package β the distributable file format for iOS apps. Think of it like a .zip that contains your compiled app. |
| dSYM | Debug Symbol file β contains the mapping between compiled binary addresses and your source code. Essential for crash report symbolication (turning memory addresses into readable function names and line numbers). |
| Archive | The process of creating a final, optimized build of your app in Xcode. An archive (.xcarchive) contains the compiled app plus debug symbols. |
| Provisioning | The process of setting up certificates, profiles, and device authorizations so an app can be installed on devices. |
| Lane | Fastlane term for a named sequence of automated actions (like a "recipe" for a build/deploy process). |
Chapter 2: The iOS CI/CD Pipeline β Bird's Eye View
2.1 The Complete Pipeline
Here is every step of a production iOS CI/CD pipeline, from code commit to App Store. Understanding this big picture will make every subsequent chapter make sense. Do not worry about memorizing it β we will cover each step in detail in its own chapter.
Developer pushes code to GitHub
β
βΌ
βββββββββββββββββββββββββββββ
β 1. TRIGGER β β GitHub detects the push
β (push, PR, tag, etc.) β
βββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β 2. PROVISION ENVIRONMENT β β Spin up a macOS runner
β β’ Install Xcode β Install dependencies
β β’ Install Ruby/Gems β Restore caches
β β’ Install CocoaPods β
β β’ Restore caches β
βββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β 3. CODE SIGNING β β Install certificates
β β’ Install certificatesβ and provisioning profiles
β β’ Install profiles β (Fastlane Match)
β β’ Configure keychain β
βββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β 4. BUILD β β Compile the app
β β’ Resolve packages β (xcodebuild or Fastlane)
β β’ Compile sources β
β β’ Link frameworks β
βββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β 5. TEST β β Run all test suites
β β’ Unit tests β
β β’ UI tests β
β β’ Code coverage β
β β’ Static analysis β
βββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β 6. ARCHIVE & EXPORT β β Create the IPA file
β β’ Archive (.xcarchive)β (the distributable
β β’ Export IPA β app package)
β β’ Generate dSYMs β
βββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β 7. DISTRIBUTE β β Upload to destination
β β’ TestFlight upload β
β β’ App Store submissionβ
β β’ dSYM upload β
β β’ Slack notification β
βββββββββββββ
Let's walk through each step in more detail so you understand what is happening and why.
Step 1: Trigger
Something happens that starts the pipeline. The most common triggers are:
- Push: A developer pushes commits to a branch. The pipeline starts automatically.
- Pull Request: A developer opens (or updates) a pull request. The pipeline runs tests and reports whether the code is safe to merge.
- Tag: A developer creates a Git tag (like
v1.3.0). This typically triggers the production deployment pipeline. - Schedule (Cron): The pipeline runs at a scheduled time (e.g., every night at 2 AM) regardless of whether code changed.
- Manual: A developer clicks a button in the CI platform's web interface to start a pipeline manually.
The trigger also determines WHICH pipeline runs. You would not want to deploy to the App Store every time someone pushes to a feature branch. Triggers are configured in the workflow YAML file (Chapter 7).
Step 2: Provision the Environment
The CI platform spins up a macOS machine (called a "runner") and prepares it for the build. This involves:
- Selecting the Xcode version: The runner might have multiple Xcode versions installed. We select the one our project needs.
- Installing Ruby and Bundler: Fastlane is a Ruby gem, so Ruby must be available. macOS comes with Ruby pre-installed, but we might need a specific version.
- Installing Fastlane: Using Bundler to install the exact version specified in our Gemfile.
- Installing CocoaPods or resolving Swift Package Manager dependencies: Your project's third-party libraries need to be downloaded.
- Restoring caches: Previous builds might have cached compiled dependencies, downloaded pods, or other artifacts. Restoring these saves time.
Think of this step as "setting up the kitchen before you start cooking." You need the right oven (Xcode), the right utensils (Fastlane), and the right ingredients (dependencies) before you can bake anything.
Step 3: Code Signing
This is the step that trips up most iOS CI/CD setups. The runner needs to:
- Create a temporary Keychain: On a CI machine, there is no logged-in user and no default Keychain. We create a temporary one.
- Install signing certificates: Download the development or distribution certificate and its private key, and import them into the temporary Keychain.
- Install provisioning profiles: Download the appropriate provisioning profile and copy it to the correct directory (
~/Library/MobileDevice/Provisioning Profiles/).
Fastlane Match handles all of this automatically by downloading encrypted certificates from a Git repository. Chapter 5 covers this in full detail.
Step 4: Build
Now the actual compilation happens. The build step:
- Resolves package dependencies: Swift Package Manager downloads any packages defined in your
Package.resolvedfile. - Compiles every Swift file: The Swift compiler turns your
.swiftfiles into machine code. - Links frameworks: The linker combines your compiled code with system frameworks (UIKit, SwiftUI, etc.) and third-party frameworks.
- Processes resources: Asset catalogs (
.xcassets), storyboards, XIBs, and other resources are compiled and bundled into the app. - Generates the app bundle: All compiled code and resources are assembled into a
.appbundle.
Under the hood, Fastlane calls xcodebuild, which is the command-line interface to Xcode's build system.
Step 5: Test
The test step runs your automated tests:
- Unit tests: Test individual functions, classes, and logic in isolation. These are fast (milliseconds each) and should cover the core logic of your app.
- UI tests: Test the actual user interface by simulating taps, swipes, and text input on a simulator. These are slow (seconds to minutes each) but catch visual and interaction bugs.
- Code coverage: Measures what percentage of your code is exercised by tests. A good target is 70-80% for production apps.
- Static analysis: Tools like SwiftLint check your code for style violations, and Xcode's built-in analyzer checks for potential bugs.
If ANY test fails, the pipeline stops. The developer is notified that their code broke something, and the build is not deployed.
Step 6: Archive & Export
If all tests pass, the pipeline creates a distributable build:
- Archive:
xcodebuild archivecompiles the app with the Release (or Staging) configuration and creates an.xcarchivefile. This is the same as clicking Product > Archive in Xcode. - Export IPA:
xcodebuild -exportArchivetakes the archive and creates an.ipafile β the actual file that gets uploaded to App Store Connect. The export process also code-signs the binary. - Generate dSYMs: Debug symbol files are generated alongside the archive. These are essential for crash report symbolication β without them, crash reports show memory addresses instead of function names.
Step 7: Distribute
The final step delivers the build to its destination:
- Upload to TestFlight: The IPA is uploaded to App Store Connect via the App Store Connect API. Apple processes the build (which takes 5-45 minutes), and then it becomes available to TestFlight testers.
- Upload dSYMs: Debug symbols are uploaded to your crash reporting service (Firebase Crashlytics, Sentry, Bugsnag, etc.) so future crash reports can be symbolicated.
- Notify the team: A Slack message, email, or other notification tells the team that a new build is available.
2.2 When Each Step Runs β Trigger Strategies
Not every pipeline step runs for every code change. Here is a typical trigger strategy used by production iOS teams:
| Trigger Event | What Runs | Why |
|---|---|---|
| Pull Request opened/updated | Steps 1-5 (Build + Test only) | Verify the code compiles and all tests pass before merging. No deployment β we do not want every PR branch uploaded to TestFlight. |
Merge to develop branch |
Steps 1-7 (Full pipeline, deploy to TestFlight with Staging config) | Every merge to develop automatically creates a Staging TestFlight build for QA. This means QA always has the latest integrated code to test. |
Merge to main branch |
Steps 1-7 (Full pipeline, deploy to TestFlight with Release config) | Every merge to main creates a Production TestFlight build for final testing before App Store submission. |
Git tag v1.2.3 created |
Steps 1-7 (Full pipeline, submit to App Store) | Creating a version tag triggers the App Store submission pipeline. This is the release trigger. |
| Nightly schedule (cron) | Steps 1-5 (Build + full test suite including slow UI tests) | Run the complete test suite overnight, including tests that are too slow for PR checks. Catches flaky tests and regressions. |
| Manual trigger (workflow_dispatch) | Whatever you configure | For ad-hoc builds, debugging, or releasing outside the normal flow. |
The beauty of this approach is that it is proportional β quick changes get quick feedback (PR tests take ~10 minutes), while major milestones get full treatment (production deploys take ~30 minutes but are fully automated).
2.3 Understanding Build Artifacts
A build artifact is any file produced by your pipeline that you want to keep after the pipeline finishes. CI runners are ephemeral β they are destroyed after the pipeline completes. If you do not save your artifacts, they disappear forever.
Common iOS artifacts:
| Artifact | What It Is | Why Keep It |
|---|---|---|
| IPA file | The distributable app package | You might need to install it directly on a device, or re-upload it |
| dSYM files | Debug symbols | Essential for crash report symbolication. Upload to Crashlytics/Sentry. |
| xcresult bundle | Test results | Contains detailed test logs, screenshots from UI tests, code coverage data |
| Test report (JUnit XML) | Machine-readable test results | Can be parsed by CI platforms to show test results in the PR |
| Build logs | The full output of xcodebuild | Invaluable for debugging build failures |
In GitHub Actions, you save artifacts using the upload-artifact action:
- name: Upload IPA
uses: actions/upload-artifact@v4
with:
name: production-ipa
path: build/MyApp-Production.ipa
retention-days: 90 # Keep for 90 days, then auto-delete
2.4 Tools You Will Use
Here is the complete toolchain we will set up in this guide. Each tool is a chapter (or section) in itself:
| Tool | What It Does | Why We Use It | Alternative |
|---|---|---|---|
| Git / GitHub | Source control and repository hosting | Industry standard, excellent CI integration | GitLab, Bitbucket |
| Fastlane | iOS build automation (build, test, sign, deploy) | De facto standard for iOS automation. Wraps xcodebuild with sensible defaults and hundreds of plugins. |
Raw xcodebuild scripts |
| Fastlane Match | Team code signing management | Stores certificates and profiles in a Git repo, shared across the whole team and CI. Eliminates code signing headaches. | Manual .p12 export/import |
| GitHub Actions | CI/CD platform (runs the pipeline) | Tight GitHub integration, macOS runners available, generous free tier for open source. | Xcode Cloud, Bitrise, CircleCI, Jenkins |
| Xcode Command Line Tools | Building and testing from the terminal | Required for any CI β Xcode's GUI is not available in CI environments. | None β this is the only option |
| Bundler | Ruby dependency management | Ensures everyone uses the exact same version of Fastlane and other Ruby tools. | None β always use Bundler with Fastlane |
| CocoaPods / SPM | iOS dependency management | CocoaPods still used in many projects; SPM is Apple's native solution. | Carthage (mostly deprecated) |
| SwiftLint | Code style enforcement | Catches code style issues automatically. | SwiftFormat |
| xcpretty | Build output formatting | Makes xcodebuild's verbose output human-readable. |
xcbeautify |
Chapter 3: Code Signing Demystified
Code signing is the single biggest source of confusion and frustration in iOS CI/CD. If you have ever seen an error like "No signing certificate found" or "Provisioning profile doesn't match signing certificate" and wanted to flip your desk, this chapter is for you. We are going to break it down completely, from first principles.
3.1 What Is Code Signing and Why Does It Exist?
When you install an app on your iPhone, how does the phone know the app is safe? How does it know the app was actually made by the developer who claims to have made it? How does it know the app has not been modified by a hacker after the developer created it?
The answer is code signing β a system based on public-key cryptography.
Here is the analogy: Imagine you are a medieval king sending a sealed letter. You press your unique royal seal into hot wax on the envelope. When the recipient receives the letter, they can:
- Verify the seal matches yours β this proves the letter came from YOU (authenticity)
- Check that the wax has not been broken β this proves the letter was not opened and altered in transit (integrity)
Code signing is the digital version of this royal seal. When you code-sign an app:
- A mathematical hash is computed for every file in your app bundle
- That hash is encrypted with your private key (your "royal seal")
- The encrypted hash (the "signature") is embedded in the app
When a device receives the app:
- It decrypts the signature using your public key (which is in your certificate)
- It recomputes the hash of every file in the app
- If the decrypted hash matches the recomputed hash, the app is authentic and unmodified
Apple requires code signing for ALL iOS apps because iPhones are locked-down devices. Unlike macOS (where you can run unsigned apps), iOS refuses to launch any app that is not properly signed with an Apple-trusted certificate.
3.2 The Four Pieces of the Puzzle
iOS code signing involves four interconnected pieces. You need ALL of them working together. If any one is missing, wrong, or expired, your build will fail.
Piece 1: Your Apple Developer Account
This is your identity with Apple. When you pay $99/year for an Apple Developer Program membership, Apple is saying: "We have verified that you are a real person/organization, and we trust you to distribute software on our platform."
Your account has a Team ID β a unique 10-character alphanumeric string like A1B2C3D4E5. This Team ID is embedded in your certificates and provisioning profiles.
How to find your Team ID:
- Go to https://developer.apple.com/account
- Scroll down to "Membership Details"
- Your Team ID is listed there
If you are on a team, every member of the team uses the SAME Team ID. The Team ID identifies the organization, not the individual developer.
Piece 2: Signing Certificates
A signing certificate is a cryptographic identity. It consists of two parts:
The Private Key β A secret file that lives in your Mac's Keychain. It is used to create digital signatures. Think of it as the actual mold of your royal seal β if someone steals it, they can forge your seal. You must keep it private.
The Certificate β A public document that contains your public key, your name, your Team ID, and Apple's signature vouching for you. Think of it as a letter from Apple that says "This person is who they claim to be, and here is their public seal impression so you can verify their letters."
There are two main types of signing certificates:
| Certificate Type | Official Name | Used For | How Many Can Exist |
|---|---|---|---|
| Development | "Apple Development" | Running apps on test devices from Xcode. Used during daily development. | Up to ~100 per team (one per developer) |
| Distribution | "Apple Distribution" | Distributing apps via TestFlight, App Store, or Ad Hoc. Used for release builds. | Up to 3 per team (shared by the whole team) |
How certificates are created (the full process):
- You open Keychain Access on your Mac (or Xcode does this for you)
- Your Mac generates a key pair β a private key and a public key. These are mathematically linked.
- Your Mac creates a Certificate Signing Request (CSR) β this is a file that contains your public key and some identifying information, signed with your private key.
- You upload the CSR to the Apple Developer Portal (or Xcode does this automatically).
- Apple takes your public key from the CSR, wraps it in a certificate, adds your Team ID and other metadata, and then signs the whole thing with Apple's own certificate. This is Apple saying: "We vouch for this developer."
- You download the signed certificate and install it in your Mac's Keychain.
- Your Keychain automatically pairs the downloaded certificate with the private key that was generated in step 2 (because they are mathematically linked).
The critical thing to understand: The private key was generated on YOUR Mac in step 2 and NEVER left your Mac. The CSR only contained the public key. This means:
- If you create a certificate on your laptop, only your laptop has the private key.
- If your CI server needs to sign apps, it needs BOTH the certificate AND the private key.
- If your laptop dies without you having backed up the private key, the certificate is useless β you must revoke it and create a new one.
- To share signing credentials with CI or teammates, you must export the private key (as a
.p12file) β this is what Fastlane Match automates.
Piece 3: App IDs (Bundle Identifiers)
An App ID is a registration of your app's Bundle Identifier with Apple. It tells Apple: "I plan to create an app with the Bundle ID com.yourcompany.myapp, and this app will use these specific capabilities."
You register App IDs in the Apple Developer Portal under Certificates, Identifiers & Profiles > Identifiers.
An App ID contains:
- The Bundle Identifier (e.g.,
com.yourcompany.myapp) - A list of Capabilities the app will use (Push Notifications, iCloud, App Groups, Sign in with Apple, In-App Purchases, etc.)
- The Team ID it belongs to
If you have separate Bundle IDs for Debug, Staging, and Production (as recommended in the Configurations guide), you need THREE App IDs registered in the portal:
com.yourcompany.myapp.debug β for Debug builds
com.yourcompany.myapp.staging β for Staging builds
com.yourcompany.myapp β for Production builds
Why separate App IDs? Because each App ID can have different capabilities configured, and more importantly, each App ID gets its OWN provisioning profiles. This means your Debug app can be installed side-by-side with your Production app on the same device β they appear as separate apps.
If you have App Extensions (widgets, share extensions, etc.), EACH extension needs its own App ID too:
com.yourcompany.myapp.debug.widget β Debug widget
com.yourcompany.myapp.staging.widget β Staging widget
com.yourcompany.myapp.widget β Production widget
Piece 4: Provisioning Profiles
A provisioning profile is the glue that ties everything together. It is a file (.mobileprovision) that says: "This app (App ID), signed by this certificate, is allowed to run on these devices, using these capabilities."
Think of a provisioning profile as a permission slip. A certificate proves WHO you are. A provisioning profile says what you are ALLOWED to do.
A provisioning profile contains:
- App ID: Which app this profile is for (e.g.,
com.yourcompany.myapp) - Certificate(s): Which signing certificate(s) can use this profile
- Device UDIDs: Which physical devices can run apps signed with this profile (for Development and Ad Hoc profiles only β App Store profiles do not have device lists because Apple handles device authorization)
- Capabilities: Which entitlements the app can claim (push notifications, iCloud, etc.)
- Expiration date: Profiles expire after approximately 12 months
There are four types of provisioning profiles:
| Profile Type | Used For | Device Limit | Certificate Type Needed |
|---|---|---|---|
| Development | Running on your registered test devices from Xcode during daily development | Yes β only registered devices (up to 100) | Development certificate |
| Ad Hoc | Distributing beta builds to specific test devices outside of TestFlight | Yes β only registered devices (up to 100) | Distribution certificate |
| App Store | Submitting to the App Store and distributing via TestFlight | No device list β Apple handles authorization | Distribution certificate |
| Enterprise | Internal distribution within a large company (requires $299/year Enterprise account) | No device list β any device in the organization | Enterprise certificate |
For CI/CD, you primarily need App Store profiles. Even though the name says "App Store," these profiles are also used for TestFlight distribution. TestFlight is part of the App Store infrastructure, so the same profile works for both.
3.3 How Code Signing Works End-to-End
Here is the complete flow when you build and sign an app:
Your Swift source code
β
βΌ
[Swift Compiler] β Turns .swift files into machine code
β
βΌ
[Linker] β Combines your code with frameworks (UIKit, etc.)
β
βΌ
Unsigned app bundle (MyApp.app)
β
βΌ
[codesign tool] β Apple's code signing command-line tool
β
β What codesign does:
β 1. Takes the unsigned .app bundle
β 2. Reads your signing certificate from the Keychain
β 3. Reads the corresponding private key from the Keychain
β 4. Computes a SHA-256 hash of every file in the bundle
β 5. Signs each hash with your private key
β 6. Creates a _CodeSignature/ directory inside the .app
β 7. Stores all signatures in _CodeSignature/CodeResources
β 8. Embeds the provisioning profile as embedded.mobileprovision
β
βΌ
Signed app bundle (MyApp.app)
β
β What the signed bundle contains:
β βββ MyApp (the compiled executable, signed)
β βββ _CodeSignature/
β β βββ CodeResources (hash + signature for every file)
β βββ embedded.mobileprovision (the provisioning profile)
β βββ Info.plist
β βββ Assets.car (compiled asset catalog)
β βββ ... (other resources)
β
βΌ
[Export as IPA] β Package the .app into an .ipa for distribution
β
βΌ
MyApp.ipa (ready for upload to App Store Connect)
When a device (or Apple's processing servers) receives the IPA:
- Check the provisioning profile: "Is this app (
com.yourcompany.myapp) allowed to be installed? Is it signed with a trusted certificate? Is this device authorized (for Dev/Ad Hoc builds)?" - Check the certificate chain: "Was this certificate issued by Apple? Is it still valid (not expired, not revoked)?"
- Verify every signature: "Has any file in the app been modified since it was signed?" The device recomputes the hash of every file and compares it to the signed hash.
- If ALL checks pass, the app launches. If ANY check fails, the device refuses to run the app.
3.4 The CI Code Signing Problem
Here is the fundamental problem that makes code signing in CI so difficult:
Your CI server is a different machine from your Mac.
When you develop locally, Xcode handles code signing seamlessly because:
- Your signing certificate is in your Mac's Keychain
- Your private key is in your Mac's Keychain (it was generated there)
- Your provisioning profiles are in
~/Library/MobileDevice/Provisioning Profiles/ - Xcode knows how to find all of these automatically
But your CI server (a GitHub Actions runner, an Xcode Cloud machine, etc.) starts with a BLANK Keychain and NO provisioning profiles. It does not have your certificate. It does not have your private key. It cannot sign anything.
You need a way to:
- Get your signing credentials (certificate + private key + provisioning profiles) onto the CI machine
- Do this SECURELY (these credentials are extremely sensitive β if someone steals your distribution certificate and private key, they can sign malicious apps as if they were you)
- Do this AUTOMATICALLY (you do not want to manually SSH into the CI machine and import credentials)
- Clean up after the build (remove credentials from the CI machine so the next pipeline doesn't find stale credentials)
There are three approaches to solving this:
| Approach | How It Works | Complexity | Recommended? |
|---|---|---|---|
Manual .p12 export |
Export your certificate and private key as a .p12 file, store it as a CI secret, import it during the build |
High complexity, error-prone, hard to maintain | No |
| Fastlane Match | Store encrypted certificates and profiles in a private Git repo. Match downloads and installs them automatically. | Medium setup, low ongoing maintenance | Yes β this is what we use |
| Xcode Cloud (Apple-managed) | Apple manages all signing automatically. You connect your Developer account and Xcode Cloud handles the rest. | Lowest complexity | Yes β if you use Xcode Cloud |
We cover Fastlane Match in detail in Chapter 5, and Xcode Cloud in Chapter 10.
3.5 Code Signing Quick Diagnostics
When code signing goes wrong (and it will), here are the commands to diagnose the issue. Run these in Terminal:
# ββ What certificates are in my Keychain? ββ
# This lists all signing certificates (both development and distribution)
security find-identity -v -p codesigning
# Output looks like:
# 1) ABC123... "Apple Development: John Smith (TEAMID)"
# 2) DEF456... "Apple Distribution: YourCompany (TEAMID)"
# 2 valid identities found
# ββ What is inside a provisioning profile? ββ
# This decodes a profile and shows its contents (App ID, devices, expiration, etc.)
security cms -D -i ~/Library/MobileDevice/Provisioning\ Profiles/SOME-UUID.mobileprovision
# ββ List all provisioning profiles on this Mac ββ
ls ~/Library/MobileDevice/Provisioning\ Profiles/
# ββ Is this .app bundle properly signed? ββ
codesign -dv --verbose=4 /path/to/MyApp.app
# Shows: the signing identity, team ID, provisioning profile, entitlements
# ββ Is the signature VALID? (No files modified?) ββ
codesign --verify --deep --strict /path/to/MyApp.app
# Output: valid on success, error details on failure
# ββ What entitlements does this app claim? ββ
codesign -d --entitlements :- /path/to/MyApp.app
# Shows: push notifications, iCloud, App Groups, etc.
# ββ Decode an IPA file ββ
# An IPA is just a zip file containing a Payload/ folder with the .app inside
unzip MyApp.ipa -d MyApp-unzipped/
# Then inspect: MyApp-unzipped/Payload/MyApp.app/
3.6 Common Code Signing Errors Explained
Here are the errors you will encounter most often, what they mean in plain English, and how to fix them:
"No signing certificate 'iOS Distribution' found"
Plain English: "I looked in the Keychain on this machine and could not find a distribution certificate. Without it, I cannot sign the app."
Cause: The distribution certificate is not installed on this machine. Either Match has not been run, or the certificate has expired/been revoked.
Fix: Run bundle exec fastlane match appstore to download and install the certificate.
"Provisioning profile 'X' doesn't include signing certificate 'Y'"
Plain English: "I found a provisioning profile and I found a certificate, but they do not match. This profile was created with a DIFFERENT certificate than the one I have."
Cause: Someone regenerated the certificate (creating a new one) but did not regenerate the provisioning profile (which still references the old certificate).
Fix: Regenerate the provisioning profile to include the current certificate: bundle exec fastlane match appstore --force_for_new_devices
"Provisioning profile doesn't match bundle identifier"
Plain English: "This provisioning profile is for com.yourcompany.myapp but you are trying to sign an app with bundle ID com.yourcompany.myapp.staging. Those do not match."
Cause: The wrong provisioning profile is being used, or the bundle identifier in Build Settings does not match what is expected.
Fix: Ensure the export_options in your Fastfile correctly maps each bundle ID to its matching profile name.
Chapter 4: Fastlane β Your Automation Swiss Army Knife
4.1 What Is Fastlane?
Fastlane is an open-source tool (originally created by Felix Krause, now maintained by Google) that automates virtually every tedious task in iOS development: building, testing, code signing, capturing screenshots, uploading to TestFlight, submitting to the App Store, and much more.
Think of Fastlane as a high-level wrapper around Apple's low-level tools. Instead of writing complex xcodebuild commands with dozens of flags, remembering the exact API to call for uploading to App Store Connect, and manually handling certificate installation, you tell Fastlane what you want in a few lines and it handles the complexity.
Under the hood, Fastlane is a collection of "actions" β each action does one specific thing. Some examples:
build_app(also calledgym) β builds your app (wrapsxcodebuild)run_tests(also calledscan) β runs your tests (wrapsxcodebuild test)matchβ manages code signing (downloads certificates and profiles)upload_to_testflight(also calledpilot) β uploads to TestFlight (calls the App Store Connect API)deliverβ uploads metadata and screenshots to the App Storesnapshotβ captures screenshots using your UI tests
You chain actions together in "lanes" (like lanes on a highway β each lane goes to a different destination). A lane is a named sequence of actions. Lanes are defined in a file called the Fastfile.
Think of Fastlane as a recipe book. Each lane is a recipe: "To make a Staging TestFlight build, do these 8 steps in this order." You run a lane with a single command, and Fastlane executes every step automatically.
4.2 How Fastlane Relates to xcodebuild
It is important to understand that Fastlane does NOT replace Xcode or xcodebuild. Fastlane calls xcodebuild under the hood. Here is the relationship:
You (developer)
β
β runs: bundle exec fastlane staging
β
βΌ
Fastlane
β
β internally calls: xcodebuild archive -workspace MyApp.xcworkspace
β -scheme MyApp-Staging -configuration Staging ...
β
βΌ
xcodebuild (Apple's command-line build tool)
β
β invokes: Swift compiler, linker, asset compiler, code signing tool
β
βΌ
Signed IPA file
So when you use Fastlane, you are still using xcodebuild. Fastlane just provides a nicer interface, handles edge cases, provides better error messages, and chains multiple xcodebuild invocations together (build, then archive, then export).
You CAN do everything Fastlane does with raw xcodebuild commands. But the commands would be much longer, you would have to handle errors yourself, and you would lose Fastlane's vast ecosystem of plugins and integrations. In practice, virtually every professional iOS team uses Fastlane.
4.3 Installing Fastlane
Fastlane is a Ruby gem (Ruby's equivalent of a npm package or Swift package). The recommended way to install it is through Bundler, which is Ruby's dependency manager. Using Bundler ensures every developer and your CI server use the EXACT same version of Fastlane β this prevents "works on my machine" issues.
Step-by-Step: Setting Up Fastlane with Bundler
Step 1: Verify Ruby is installed
macOS comes with Ruby pre-installed. Check your version:
ruby --version
# Should output something like: ruby 3.x.x
If the version is very old (below 2.7), install a newer version via Homebrew:
brew install ruby
# Then add Homebrew's Ruby to your PATH (follow Homebrew's output instructions)
Step 2: Install Bundler
Bundler is Ruby's equivalent of CocoaPods β it manages Ruby library versions. Install it globally:
gem install bundler
Step 3: Create a Gemfile in your project root
Navigate to your Xcode project's root directory (the folder that contains MyApp.xcodeproj or MyApp.xcworkspace):
cd /path/to/your/project
Create a file called Gemfile (no extension) with this content:
# Gemfile
# This file specifies which Ruby tools your project uses and their versions.
# It is the Ruby equivalent of a Podfile or Package.swift.
source "https://rubygems.org"
# Pin Fastlane to a specific version for reproducibility.
# The "~> 2.225" means "version 2.225.x" β allows patch updates but not major/minor.
gem "fastlane", "~> 2.225"
# Optional but recommended plugins:
gem "fastlane-plugin-versioning" # Better version/build number management
Why pin the version? If you do not pin the version, bundle install will install the latest version of Fastlane. This is fine until Fastlane releases a new version that changes behavior or has a bug β suddenly all your CI builds break because CI installed a different version than you tested locally. Pinning the version prevents this.
Step 4: Install the gems
bundle install
This does two things:
- Downloads and installs Fastlane and its dependencies
- Creates a
Gemfile.lockfile that records the EXACT versions of everything that was installed
Commit BOTH Gemfile and Gemfile.lock to Git. When your CI server (or another developer) runs bundle install, Bundler reads the Gemfile.lock and installs the exact same versions. This guarantees reproducibility.
Step 5: Initialize Fastlane in your project
bundle exec fastlane init
Important: Always prefix Fastlane commands with bundle exec. This ensures you run the version specified in your Gemfile, not whatever version might be installed globally on your system.
Fastlane will ask you what you want to do. Choose the manual setup option (usually option 4) β we will configure everything ourselves for maximum understanding and control.
This creates a fastlane/ directory in your project with two files:
MyApp/
βββ MyApp.xcodeproj/
βββ fastlane/
β βββ Appfile β Your app's identity information
β βββ Fastfile β Your automation lanes (the main file)
βββ Gemfile
βββ Gemfile.lock
βββ ...
4.4 The Appfile β Your App's Identity
The Appfile tells Fastlane who you are and which app you are working with. Open fastlane/Appfile and fill it in:
# fastlane/Appfile
# This file provides default values for Fastlane actions.
# Every Fastlane action that needs an Apple ID, Team ID, or app identifier
# will read from this file unless you override the value in the Fastfile.
# Your Apple Developer account email
# This is the Apple ID you use to log in to developer.apple.com and
# appstoreconnect.apple.com
apple_id("developer@yourcompany.com")
# Your Apple Developer Team ID
# Found at: https://developer.apple.com/account β Membership Details
# This is a 10-character alphanumeric string like "A1B2C3D4E5"
team_id("A1B2C3D4E5")
# Your App Store Connect Team ID
# This can be DIFFERENT from the Developer Team ID (especially for organizations)
# Found at: https://appstoreconnect.apple.com β Users & Access β look at the URL
# or run: bundle exec fastlane spaceship
itc_team_id("123456789")
# Your app's bundle identifier
# This is the PRODUCTION bundle ID β Fastlane will use this as the default
# Individual lanes can override this for staging/debug builds
app_identifier("com.yourcompany.myapp")
4.5 The Fastfile β Your Automation Lanes
The Fastfile is where the magic happens. It is written in Ruby, but you do not need to be a Ruby expert β it reads almost like English. Think of it as a configuration file that happens to use Ruby syntax.
Here is a complete, production-ready Fastfile with extensive comments explaining every line:
# fastlane/Fastfile
# This file defines all your automation "lanes" (workflows).
# Each lane is a sequence of actions that Fastlane executes in order.
# Run a lane with: bundle exec fastlane <lane_name>
# βββββββββββββββββββββββββββββββββββββββββββββ
# CONSTANTS
# Define constants at the top so they are easy to find and change.
# βββββββββββββββββββββββββββββββββββββββββββββ
WORKSPACE = "MyApp.xcworkspace" # Use .xcworkspace if you have CocoaPods or
# multiple projects. Use .xcodeproj if not.
# PROJECT = "MyApp.xcodeproj" # Uncomment this and use it instead of
# WORKSPACE if you do not have a workspace.
APP_ID_PROD = "com.yourcompany.myapp"
APP_ID_STG = "com.yourcompany.myapp.staging"
APP_ID_DEV = "com.yourcompany.myapp.debug"
# βββββββββββββββββββββββββββββββββββββββββββββ
# BEFORE ALL
# This block runs BEFORE every lane. Use it for common setup tasks.
# βββββββββββββββββββββββββββββββββββββββββββββ
before_all do
# Ensure we are on a clean Git state (no uncommitted changes).
# This prevents accidentally including local debug changes in a release build.
# Comment this out during local testing if you are iterating quickly.
# ensure_git_status_clean
end
# βββββββββββββββββββββββββββββββββββββββββββββ
# LANE: Run Tests
# Purpose: Build and run all unit and UI tests.
# When used: On every PR (via CI) and locally before pushing.
# βββββββββββββββββββββββββββββββββββββββββββββ
desc "Run all unit and UI tests" # 'desc' adds a description shown by 'fastlane lanes'
lane :test do
# 'run_tests' is an alias for 'scan'.
# It calls xcodebuild test under the hood.
run_tests(
workspace: WORKSPACE,
scheme: "MyApp-Dev", # Use the Dev scheme (Debug configuration)
devices: ["iPhone 16 Pro"], # Which simulator to run tests on
configuration: "Debug",
code_coverage: true, # Collect code coverage data
output_directory: "./fastlane/test_output", # Where to save results
output_types: "html,junit", # html = human-readable report
# junit = machine-readable (for CI platforms)
clean: true, # Clean build folder before building
# (slower but avoids stale build artifacts)
result_bundle: true # Generate .xcresult bundle (detailed test data)
)
end
# βββββββββββββββββββββββββββββββββββββββββββββ
# LANE: Build for Staging (TestFlight)
# Purpose: Build the app with Staging config and upload to TestFlight.
# When used: Automatically when code is merged to the 'develop' branch.
# βββββββββββββββββββββββββββββββββββββββββββββ
desc "Build and upload a Staging build to TestFlight"
lane :staging do
# On CI, set up the temporary keychain
# 'is_ci' is a built-in Fastlane helper that returns true when running in CI
# (it checks for environment variables like CI=true, GITHUB_ACTIONS=true, etc.)
setup_ci if is_ci
# Step 1: Set up the App Store Connect API key for authentication
# This is the modern way to authenticate β avoids Apple ID 2FA issues in CI
setup_api_key
# Step 2: Install code signing credentials via Fastlane Match
# Match downloads the certificate and provisioning profile from the
# certificates Git repo and installs them on this machine
setup_signing(type: "appstore", app_id: APP_ID_STG)
# Step 3: Increment the build number
# Queries App Store Connect for the latest build number and adds 1.
# This ensures every upload has a unique build number.
# Example: if the latest Staging build is #47, this sets it to #48.
increment_build_number(
build_number: latest_testflight_build_number(
app_identifier: APP_ID_STG
) + 1
)
# Step 4: Build and archive the app
# 'build_app' (alias: 'gym') calls xcodebuild archive + xcodebuild -exportArchive
build_app(
workspace: WORKSPACE,
scheme: "MyApp-Staging", # This scheme uses the Staging configuration
configuration: "Staging", # Explicit β uses Staging xcconfig settings
export_method: "app-store", # "app-store" is used for BOTH TestFlight and
# App Store uploads. Despite the name, TestFlight
# uses the same distribution method.
output_directory: "./build",
output_name: "MyApp-Staging.ipa",
clean: true,
include_bitcode: false, # Bitcode was deprecated in Xcode 14.
# Always set to false for modern projects.
export_options: {
# This tells the export process which provisioning profile to use
# for each bundle ID in the app.
provisioningProfiles: {
APP_ID_STG => "match AppStore com.yourcompany.myapp.staging"
# The profile name format is: "match <Type> <BundleID>"
# Match creates profiles with this naming convention automatically.
}
}
)
# Step 5: Upload to TestFlight
# 'upload_to_testflight' (alias: 'pilot') uses the App Store Connect API
# to upload the IPA.
upload_to_testflight(
app_identifier: APP_ID_STG,
skip_waiting_for_build_processing: true,
# Apple processes every TestFlight upload (validation, app thinning, etc.)
# This takes 5-45 minutes. If true, Fastlane returns immediately after upload
# instead of polling for completion. Recommended for CI to save time/money.
changelog: "Staging build from CI β branch: #{git_branch}"
# This text appears in TestFlight as "What to Test" for this build.
)
# Step 6: Upload debug symbols for crash reporting
# Uncomment if you use Firebase Crashlytics:
# upload_symbols_to_crashlytics(
# dsym_path: "./build/MyApp-Staging.app.dSYM.zip",
# gsp_path: "./MyApp/GoogleService-Info-Staging.plist"
# )
# Step 7: Notify the team
notify_slack(message: "β
Staging build ##{get_build_number} uploaded to TestFlight!")
end
# βββββββββββββββββββββββββββββββββββββββββββββ
# LANE: Build for Production (App Store)
# Purpose: Build the app with Release config and upload to TestFlight.
# When used: When a version tag (v*.*.*) is pushed.
# βββββββββββββββββββββββββββββββββββββββββββββ
desc "Build and upload a Production build to TestFlight / App Store"
lane :production do
setup_ci if is_ci
setup_api_key
setup_signing(type: "appstore", app_id: APP_ID_PROD)
increment_build_number(
build_number: latest_testflight_build_number(
app_identifier: APP_ID_PROD
) + 1
)
build_app(
workspace: WORKSPACE,
scheme: "MyApp-Production",
configuration: "Release",
export_method: "app-store",
output_directory: "./build",
output_name: "MyApp-Production.ipa",
clean: true,
include_bitcode: false,
export_options: {
provisioningProfiles: {
APP_ID_PROD => "match AppStore com.yourcompany.myapp"
}
}
)
upload_to_testflight(
app_identifier: APP_ID_PROD,
skip_waiting_for_build_processing: true,
changelog: last_git_commit[:message]
# For production, use the last commit message as the changelog.
# This is often a merge commit message like "Merge PR #123: Add payment feature"
)
notify_slack(message: "β
Production build ##{get_build_number} uploaded to TestFlight!")
end
# βββββββββββββββββββββββββββββββββββββββββββββ
# LANE: Submit to App Store Review
# Purpose: Take the latest TestFlight build and submit it for App Store review.
# When used: Manually, or as part of a release workflow.
# Note: This does NOT build the app β it submits an existing TestFlight build.
# βββββββββββββββββββββββββββββββββββββββββββββ
desc "Submit the latest TestFlight build to App Store review"
lane :release do
setup_api_key
deliver(
app_identifier: APP_ID_PROD,
submit_for_review: true, # Actually submit (not just upload metadata)
automatic_release: false, # After Apple approves, do NOT auto-release.
# A human should click "Release" manually.
# Set to true if you want instant release on approval.
force: true, # Skip interactive confirmations (required for CI)
skip_screenshots: true, # Screenshots managed separately (see Chapter 9)
skip_metadata: false, # Upload metadata from fastlane/metadata/ directory
submission_information: {
add_id_info_uses_idfa: false # IDFA = advertising identifier.
# Set to true if your app uses IDFA for tracking.
},
precheck_include_in_app_purchases: false # Skip in-app purchase validation
)
notify_slack(message: "π App submitted for App Store review!")
end
# βββββββββββββββββββββββββββββββββββββββββββββ
# PRIVATE LANES (Helper functions)
# Private lanes cannot be called from the command line β only from other lanes.
# βββββββββββββββββββββββββββββββββββββββββββββ
private_lane :setup_api_key do
# App Store Connect API key authentication.
# This is the modern, preferred way to authenticate with App Store Connect.
# It avoids Apple ID passwords, 2FA prompts, and session expiration issues.
# See Chapter 7 for how to create the API key and set up the environment variables.
app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
key_content: ENV["APP_STORE_CONNECT_API_KEY_KEY"],
is_key_content_base64: false,
in_house: false # Set to true only if you have an Enterprise account
)
end
private_lane :setup_signing do |options|
# Fastlane Match handles all code signing.
# It downloads certificates and profiles from the certificates Git repo,
# decrypts them with MATCH_PASSWORD, and installs them in the Keychain.
match(
type: options[:type], # "development", "adhoc", or "appstore"
app_identifier: options[:app_id],
readonly: is_ci
# CRITICAL: readonly:true on CI means "only download existing certs/profiles,
# do NOT create new ones." This prevents CI from accidentally creating
# duplicate certificates or profiles. Only run match WITHOUT readonly
# on a developer's local machine.
)
end
private_lane :notify_slack do |options|
# Only send Slack notifications if the webhook URL is configured.
# This makes the Fastfile work both locally (where you might not have Slack)
# and in CI (where Slack is configured).
if ENV["SLACK_WEBHOOK_URL"]
slack(
message: options[:message],
slack_url: ENV["SLACK_WEBHOOK_URL"],
default_payloads: [:git_branch, :git_author, :build_number]
# default_payloads adds extra context to the Slack message automatically
)
else
UI.message("Slack notification skipped (no SLACK_WEBHOOK_URL set)")
# UI.message prints to the Fastlane console output
end
end
# βββββββββββββββββββββββββββββββββββββββββββββ
# ERROR HANDLING
# This block runs if ANY lane fails. Use it for cleanup and notifications.
# βββββββββββββββββββββββββββββββββββββββββββββ
error do |lane, exception|
if ENV["SLACK_WEBHOOK_URL"]
slack(
message: "β Build failed in lane '#{lane}': #{exception.message}",
slack_url: ENV["SLACK_WEBHOOK_URL"],
success: false, # This makes the Slack message appear in red
default_payloads: [:git_branch, :git_author]
)
end
end
4.6 Understanding Key Fastlane Actions in Detail
run_tests (alias: scan)
This action builds your app and runs the test suite. Under the hood, it calls xcodebuild test.
What it does step by step:
- Boots the specified iOS simulator (if not already running)
- Compiles your app target
- Compiles your test target(s)
- Installs the app on the simulator
- Runs every test method in the test target(s)
- Collects results, code coverage, and generates reports
- Returns success if all tests pass, failure if any test fails
Key parameters explained:
| Parameter | What It Does | Typical Value |
|---|---|---|
workspace |
Which Xcode workspace to build | "MyApp.xcworkspace" |
scheme |
Which scheme (target + configuration) | "MyApp-Dev" |
devices |
Which simulator(s) | ["iPhone 16 Pro"] |
code_coverage |
Collect coverage data | true |
only_testing |
Run only specific test bundles | ["MyAppTests"] (skip UI tests) |
output_types |
Report formats | "html,junit" |
clean |
Clean build folder first | true for CI, false for local |
number_of_retries |
Retry failed tests (catches flaky tests) | 2 |
build_app (alias: gym)
This action builds and archives your app, producing an IPA file. Under the hood, it calls xcodebuild archive followed by xcodebuild -exportArchive.
What it does step by step:
- Archive: Compiles your app with the specified configuration (Release, Staging, etc.), optimizes the binary, and creates an
.xcarchivebundle. This is the same as clicking Product > Archive in Xcode. - Export: Takes the archive and creates an
.ipafile. During export, it code-signs the binary using the specified provisioning profiles and creates device-specific variants (app thinning). - Generate dSYMs: Produces debug symbol files alongside the archive.
Key parameters explained:
| Parameter | What It Does | Typical Value |
|---|---|---|
scheme |
Which scheme to build | "MyApp-Production" |
configuration |
Which build configuration | "Release" |
export_method |
Distribution method | "app-store" for TestFlight/Store, "ad-hoc" for direct device install |
export_options |
Provisioning profile mapping | See Fastfile above |
output_directory |
Where to save the IPA | "./build" |
include_bitcode |
Include bitcode (deprecated) | false always for modern projects |
upload_to_testflight (alias: pilot)
This action uploads an IPA file to App Store Connect for TestFlight distribution.
What it does:
- Authenticates with App Store Connect (using the API key or Apple ID)
- Validates the IPA (checks code signing, entitlements, bundle ID)
- Uploads the IPA to Apple's servers
- Optionally waits for Apple to process the build (validation, app thinning)
- Optionally adds the build to TestFlight groups and sets the changelog
Key parameters:
| Parameter | What It Does | Typical Value |
|---|---|---|
app_identifier |
Which app to upload to | "com.yourcompany.myapp" |
skip_waiting_for_build_processing |
Don't wait for Apple processing | true (saves CI time) |
changelog |
"What to Test" text shown to testers | "Bug fixes and improvements" |
distribute_external |
Auto-distribute to external testers | true if you have external groups |
groups |
TestFlight group names | ["QA Team", "Beta Testers"] |
4.7 Running Fastlane Locally
Always use bundle exec to run Fastlane β this ensures you use the version specified in your Gemfile:
# Run tests
bundle exec fastlane test
# Build and upload staging to TestFlight
bundle exec fastlane staging
# Build and upload production to TestFlight
bundle exec fastlane production
# Submit to App Store review (no build β uses latest TestFlight build)
bundle exec fastlane release
# See all available lanes and their descriptions
bundle exec fastlane lanes
# See all available Fastlane actions
bundle exec fastlane actions
# Get detailed help for a specific action
bundle exec fastlane action build_app
4.8 Environment Variables and Secrets
Fastlane uses environment variables for sensitive data that should NEVER be committed to Git. You can set them in your shell, in a .env file, or in your CI platform's secrets.
Create a file called fastlane/.env:
# fastlane/.env
# β οΈ THIS FILE IS NOT COMMITTED TO GIT β it's in .gitignore
# These values are only used for local development.
# CI uses its own secrets management (GitHub Secrets, etc.)
# Slack webhook for build notifications
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX
# Fastlane Match encryption password
# (the password you chose when running 'fastlane match' for the first time)
MATCH_PASSWORD=your_match_encryption_password
# App Store Connect API key (see Chapter 7 for how to create this)
APP_STORE_CONNECT_API_KEY_KEY_ID=ABC123DEFG
APP_STORE_CONNECT_API_KEY_ISSUER_ID=12345678-1234-1234-1234-123456789012
APP_STORE_CONNECT_API_KEY_KEY=-----BEGIN EC PRIVATE KEY-----\nMIGTAg...your key content...==\n-----END EC PRIVATE KEY-----
CRITICAL: Add .env files to .gitignore:
# In your .gitignore:
fastlane/.env
fastlane/.env.*
Fastlane automatically loads fastlane/.env when it starts. You can also have environment-specific files like fastlane/.env.staging and load them explicitly.
Chapter 5: Fastlane Match β Team Code Signing Solved
5.1 The Problem Match Solves
Remember the code signing problem from Chapter 3? Every developer and every CI machine needs the same certificates and provisioning profiles. Without Match, teams typically handle this through a painful manual process:
- One developer (usually the "tech lead" or "iOS lead") creates the distribution certificate on their Mac
- They export the certificate and private key as a
.p12file (Keychain Access > right-click certificate > Export) - They share the
.p12file via Slack, email, or a shared drive β this is INSECURE because.p12files contain your private signing key - Every other developer and the CI server imports the
.p12manually - When the certificate expires (after 1 year), the entire process repeats
- If the original developer leaves the company and their Mac is wiped, the private key might be LOST β the certificate becomes useless, and all provisioning profiles must be regenerated
- If someone accidentally clicks "Revoke" in the Apple Developer Portal, everyone's build breaks instantly
This is a nightmare that gets worse as your team grows.
Fastlane Match solves ALL of this with one elegant idea: store all certificates and profiles in a private Git repository, encrypted with a shared password. Any developer or CI machine can run match to download and install the latest credentials. If credentials expire, Match regenerates them and pushes the new ones to the repository.
5.2 How Match Works β The Full Picture
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Private Git Repository β
β (e.g., github.com/your-org/ios-certificates)β
β β
β Everything here is ENCRYPTED with β
β MATCH_PASSWORD using AES-256 β
β β
β Contents: β
β βββ certs/ β
β β βββ development/ β
β β β βββ ABCDEF1234.cer (certificate) β
β β β βββ ABCDEF1234.p12 (private key) β
β β βββ distribution/ β
β β βββ GHIJKL5678.cer (certificate) β
β β βββ GHIJKL5678.p12 (private key) β
β βββ profiles/ β
β βββ development/ β
β β βββ Development_com.co.app.debug.mobileprovision β
β β βββ Development_com.co.app.mobileprovision β
β βββ adhoc/ β
β β βββ AdHoc_com.co.app.mobileprovision β
β βββ appstore/ β
β βββ AppStore_com.co.app.mobileprovision β
β βββ AppStore_com.co.app.staging.mobileprovision β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββ
β β β
βΌ βΌ βΌ
Developer A Developer B CI Server
runs: match runs: match runs: match
β β β
βΌ βΌ βΌ
1. Clones the Same Same
cert repo process process
2. Decrypts files
with MATCH_PASSWORD
3. Imports certificate
+ private key
into Keychain
4. Copies provisioning
profile to
~/Library/MobileDevice/
Provisioning Profiles/
5. Done! Ready
to sign apps.
The critical insight is that Match uses ONE shared certificate for the whole team. Instead of each developer having their own certificate (which leads to conflicts and the 3-certificate limit for distribution), everyone shares a single certificate managed by Match.
5.3 Setting Up Match β Step by Step
Step 1: Create a Private Git Repository for Certificates
Go to GitHub and create a new private repository. Name it something like ios-certificates, fastlane-certs, or signing-credentials. It MUST be private β this repository will contain your encrypted signing certificates.
https://github.com/your-org/ios-certificates
Do NOT add a README, .gitignore, or any files β leave it completely empty. Match will populate it.
Why Git? Because Git provides:
- Version history (you can see when certificates were updated)
- Access control (only team members with repo access can get certificates)
- Reliability (Git repos are backed up on GitHub's infrastructure)
- Familiarity (your team already knows Git)
Match also supports other storage backends (Google Cloud Storage, Amazon S3), but Git is the simplest and most common.
Step 2: Initialize Match in Your Project
cd /path/to/your/project
bundle exec fastlane match init
Match will ask you several questions:
- Storage method: Choose
git - URL of the Git Repo: Enter the URL of the repo you just created:
https://github.com/your-org/ios-certificates.git
This creates a fastlane/Matchfile with your configuration.
Step 3: Edit the Matchfile
Open fastlane/Matchfile and configure it fully:
# fastlane/Matchfile
# ββ Storage ββββββββββββββββββββββββββββββββ
# URL of the private Git repo that stores your certificates and profiles.
# Match clones this repo, decrypts the contents, installs them, and cleans up.
git_url("https://github.com/your-org/ios-certificates.git")
# Branch to use (default: "master"). Using "main" is recommended for new repos.
git_branch("main")
# ββ App Configuration ββββββββββββββββββββββ
# ALL bundle identifiers that need provisioning profiles.
# Match creates a separate profile for each bundle ID Γ type combination.
# Include your main app AND all extensions (widgets, share extensions, etc.)
app_identifier([
"com.yourcompany.myapp", # Production
"com.yourcompany.myapp.staging", # Staging
"com.yourcompany.myapp.debug", # Debug
# Uncomment if you have extensions:
# "com.yourcompany.myapp.widget",
# "com.yourcompany.myapp.staging.widget",
# "com.yourcompany.myapp.debug.widget",
])
# ββ Apple Account ββββββββββββββββββββββββββ
username("developer@yourcompany.com")
team_id("A1B2C3D4E5")
# ββ Safety βββββββββββββββββββββββββββββββββ
# On CI, ALWAYS use readonly mode.
# readonly = true means: "Only download existing certs. Do NOT create new ones."
# This prevents CI from accidentally creating duplicate certs/profiles.
readonly(is_ci)
Step 4: Generate Certificates and Profiles
Now run Match to generate (or fetch) certificates and profiles for each distribution type:
# Generate DEVELOPMENT certificates and profiles
# Used for: Running on test devices from Xcode
bundle exec fastlane match development
# Generate APP STORE certificates and profiles
# Used for: TestFlight AND App Store distribution
bundle exec fastlane match appstore
# Generate AD HOC certificates and profiles (optional)
# Used for: Direct device distribution outside TestFlight
bundle exec fastlane match adhoc
The very first time you run each command, Match will:
- Ask you for an encryption password (MATCH_PASSWORD) β Choose something strong and memorable. Every team member and the CI server will need this password to decrypt the certificates. Store it in a password manager.
- Create a new signing certificate in the Apple Developer Portal (if one does not already exist)
- Create new provisioning profiles for each bundle ID
- Download everything, encrypt it with your MATCH_PASSWORD, and push it to your Git certificate repository
You will see output like:
[10:23:45]: Cloning remote git repo...
[10:23:47]: Creating a new certificate for AppStore distribution...
[10:23:52]: Certificate created successfully.
[10:23:53]: Creating provisioning profile for com.yourcompany.myapp...
[10:23:55]: Profile created successfully.
[10:23:56]: Encrypting and pushing to git repo...
[10:23:58]: All done! π
Subsequent runs just download the existing certificates (unless they have expired or been revoked). This is very fast β usually under 10 seconds.
Step 5: Verify Everything Worked
# Check your Keychain β you should see the new certificates
security find-identity -v -p codesigning
# Expected output includes lines like:
# "Apple Development: Your Name (TEAMID)"
# "Apple Distribution: Your Company (TEAMID)"
# Check provisioning profiles were installed
ls ~/Library/MobileDevice/Provisioning\ Profiles/
# You should see .mobileprovision files here
Step 6: Onboard Your Team
Every other developer on your team runs:
# First, they need the MATCH_PASSWORD
# Share it via your team's password manager (1Password, LastPass, etc.)
# Then they run match in readonly mode:
bundle exec fastlane match development --readonly
bundle exec fastlane match appstore --readonly
The --readonly flag is crucial β it means "just download what already exists, do NOT create new certificates or profiles." You do not want every developer creating their own certificates.
Important convention: Only ONE person (the "signing administrator" β usually the iOS lead) should run Match WITHOUT --readonly. Everyone else uses --readonly.
Step 7: Configure CI to Use Match
On your CI server, Match needs three things:
MATCH_PASSWORDβ to decrypt the certificate repository (stored as a CI secret)- Access to the Git certificate repository β via a Personal Access Token or SSH key
- A temporary Keychain β because CI machines don't have a logged-in user's default Keychain
We set these up in Chapter 7 (GitHub Actions secrets).
5.4 Match Keychain Setup for CI
On a CI machine, there is no logged-in user, so there is no default login Keychain. You need to create a temporary Keychain for the duration of the build.
Fastlane provides a built-in helper for this:
# In your Fastfile, at the beginning of any deployment lane:
lane :staging do
setup_ci if is_ci
# setup_ci does the following:
# 1. Creates a temporary Keychain named "fastlane_tmp_keychain"
# 2. Sets it as the default Keychain
# 3. Unlocks it
# 4. Disables the Keychain lock timeout
# After this, Match can install certificates into this temporary Keychain.
setup_signing(type: "appstore", app_id: APP_ID_STG)
# ... rest of the lane
end
The setup_ci action is the recommended approach. It handles all the Keychain configuration automatically.
5.5 Renewing Expired Certificates
Certificates and profiles expire after approximately 1 year. When they expire, Match will tell you:
[error] Your certificate has expired. Run `fastlane match nuke` to revoke and recreate.
Here is how to renew:
# Option A: Force-renew a specific type (recommended)
bundle exec fastlane match appstore --force
# This creates new certs/profiles, replaces the old ones in the Git repo,
# and pushes the update.
# Option B: Nuclear option β revoke everything and start fresh
bundle exec fastlane match nuke distribution
# β οΈ WARNING: This revokes ALL distribution certificates for your team.
# Every developer and CI server will need to re-run match afterward.
# Only use this if things are truly broken.
# After nuking, regenerate:
bundle exec fastlane match appstore
bundle exec fastlane match development
After renewing, every developer runs match --readonly to get the new credentials.
Chapter 6: Automating Tests
6.1 Why Automated Testing Is the Foundation of CI/CD
CI/CD without tests is like an assembly line without quality control β you are just shipping faster, but you are shipping bugs faster too. The whole point of CI is to catch problems early. If you do not have tests, there is nothing to catch. The pipeline just builds the app and ships it, bugs and all.
A good test suite for a production iOS app includes multiple types of tests, each catching different kinds of problems:
| Test Type | What It Tests | Speed | Tools | Coverage Goal |
|---|---|---|---|---|
| Unit Tests | Individual functions, classes, methods in isolation. Pure logic. | Very fast (ms per test) | XCTest | 70-80% of core logic |
| Integration Tests | How multiple components work together (e.g., API client + JSON parser + database) | Medium (seconds) | XCTest | Key user flows |
| UI Tests | The actual user interface β tapping buttons, scrolling, verifying text appears | Slow (seconds to minutes per test) | XCUITest | Critical user journeys |
| Snapshot Tests | Visual appearance of views β detects unintended pixel-level UI changes | Medium | swift-snapshot-testing | Key screens |
| Performance Tests | Measures execution time and catches performance regressions | Medium | XCTest measure {} |
Hot paths |
6.2 Running Tests from the Command Line
In CI, there is no Xcode GUI. Everything runs from the command line. Here is the raw xcodebuild command that Fastlane uses under the hood:
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyApp-Dev \
-configuration Debug \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0' \
-resultBundlePath ./TestResults.xcresult \
-enableCodeCoverage YES \
| xcpretty --report junit --output TestResults.xml
Let's break down every flag:
| Flag | What It Does | Why It Matters |
|---|---|---|
-workspace |
Specifies the Xcode workspace | Required if you use CocoaPods or have multiple projects. Use -project instead if you have a standalone .xcodeproj. |
-scheme |
Selects the scheme to build | The scheme determines which target to build and which configuration (Debug/Staging/Release) to use. |
-configuration |
Overrides the scheme's configuration | Explicit is better than implicit. Ensures we build with the correct configuration regardless of scheme settings. |
-destination |
Where to run the tests | For CI, this is always a simulator. Format: 'platform=iOS Simulator,name=<SimulatorName>,OS=<iOSVersion>'. |
-resultBundlePath |
Where to save the .xcresult bundle |
The result bundle contains detailed test logs, screenshots from UI tests, code coverage data, and performance metrics. |
-enableCodeCoverage YES |
Collect code coverage | Measures which lines of code were executed during tests. Essential for understanding test quality. |
| xcpretty |
Pipes output through xcpretty | xcodebuild output is extremely verbose and hard to read. xcpretty formats it into clean, colored output and can generate reports. |
How to find available simulator names:
xcrun simctl list devices available
# Shows all installed simulators with their names and iOS versions
6.3 Fastlane Test Configuration
The Fastlane run_tests action wraps xcodebuild test with sensible defaults and better error handling:
# fastlane/Fastfile
# βββββββββββββββββββββββββββββββββββββββββββββ
# LANE: Fast unit tests (for PR checks)
# βββββββββββββββββββββββββββββββββββββββββββββ
desc "Run unit tests only (fast β suitable for every PR)"
lane :test_unit do
run_tests(
workspace: "MyApp.xcworkspace",
scheme: "MyApp-Dev",
devices: ["iPhone 16 Pro"],
configuration: "Debug",
code_coverage: true,
only_testing: [
"MyAppTests" # Only the unit test bundle
# Skip UI tests (MyAppUITests) β they are too slow for PRs
],
output_directory: "./fastlane/test_output",
output_types: "junit", # JUnit format can be parsed by CI platforms
# to show test results directly in the PR
clean: false # Don't clean build folder β faster for incremental builds
)
end
# βββββββββββββββββββββββββββββββββββββββββββββ
# LANE: Full test suite (for nightly/pre-release)
# βββββββββββββββββββββββββββββββββββββββββββββ
desc "Run ALL tests including UI tests (slow β for nightly builds)"
lane :test_all do
run_tests(
workspace: "MyApp.xcworkspace",
scheme: "MyApp-Dev",
devices: ["iPhone 16 Pro"],
configuration: "Debug",
code_coverage: true,
# No 'only_testing' β runs ALL test bundles (unit + UI)
output_directory: "./fastlane/test_output",
output_types: "html,junit", # HTML for human viewing, JUnit for CI
clean: true, # Clean build to ensure reproducibility
number_of_retries: 2 # Retry failed tests up to 2 times
# This helps with flaky UI tests
)
end
6.4 Parallel Testing
Xcode supports running tests on multiple simulators simultaneously. This can dramatically reduce test time β if you have 100 tests that each take 1 second, running on 4 simulators in parallel cuts the total time from 100 seconds to ~25 seconds.
run_tests(
workspace: "MyApp.xcworkspace",
scheme: "MyApp-Dev",
devices: [
"iPhone 16 Pro",
"iPhone SE (3rd generation)",
"iPad Pro (12.9-inch) (6th generation)"
],
parallel_testing: true,
concurrent_workers: 4 # How many simulators to run simultaneously
)
Xcode automatically distributes test classes across the available simulators. Each simulator runs a subset of the tests, and results are merged at the end.
Warning: Parallel testing can expose concurrency bugs in your tests. If your tests share state (a shared database, UserDefaults, a singleton), they may fail when run in parallel. Good tests should be isolated and independent.
6.5 Code Coverage
Code coverage measures what percentage of your code is executed by your tests. It answers the question: "How much of my code is actually being tested?"
After running tests with code_coverage: true, you can view the coverage report:
# View coverage in Terminal
xcrun xccov view --report ./TestResults.xcresult
# Export as JSON for programmatic use
xcrun xccov view --report --json ./TestResults.xcresult > coverage.json
What coverage numbers mean:
| Coverage | Interpretation |
|---|---|
| 0-30% | Minimal testing. Most code paths are untested. High risk of undetected bugs. |
| 30-50% | Basic testing. Core happy paths are covered, but edge cases and error paths are not. |
| 50-70% | Good testing. Most important code paths are covered. Acceptable for many apps. |
| 70-80% | Strong testing. A professional-grade target for production apps. |
| 80-100% | Excellent testing. Diminishing returns above 80% β the last 20% often covers trivial code. |
Tip: Focus on covering your CRITICAL code paths (authentication, payments, data persistence) rather than chasing a specific number.
Chapter 7: GitHub Actions for iOS β Complete Setup
7.1 What Is GitHub Actions?
GitHub Actions is GitHub's built-in CI/CD platform. It runs workflows (pipelines) directly from your GitHub repository β no external CI service needed, no separate account to create, no additional configuration files to maintain outside your repo.
When you push code to GitHub, GitHub Actions reads workflow files from the .github/workflows/ directory in your repo and executes them on virtual machines called "runners."
Key Concepts
Workflow: A YAML file in .github/workflows/ that defines an entire pipeline. You can have multiple workflows β for example, one for testing PRs, one for deploying staging, and one for deploying production.
Job: A set of steps that run on a single runner machine. A workflow can have multiple jobs that run in parallel or sequentially. Each job gets a fresh machine β jobs do NOT share files unless you explicitly pass artifacts between them.
Step: A single task within a job. Steps run sequentially β step 2 starts after step 1 finishes. If any step fails, the remaining steps (by default) are skipped.
Runner: The virtual machine that executes the job. For iOS builds, you MUST use a macOS runner (e.g., macos-15). GitHub provides hosted runners with macOS, Xcode, and common tools pre-installed.
Action: A reusable, community-created step. For example, actions/checkout@v4 checks out your repository's code. Actions are like plugins β they save you from writing common boilerplate.
Secret: An encrypted environment variable stored in your GitHub repository's settings. Secrets are injected into workflows as environment variables but are NEVER printed in logs (they show *** instead). Use secrets for passwords, API keys, certificates, and other sensitive values.
GitHub Actions Pricing for iOS
| Runner Type | Cost per Minute | Typical Build Time | Cost per Build |
|---|---|---|---|
macos-15 (GitHub-hosted) |
$0.08 | 15-30 minutes | $1.20 - $2.40 |
ubuntu-latest (for comparison) |
$0.008 | N/A for iOS | N/A |
| Self-hosted Mac | $0 (your hardware cost) | 10-20 minutes | $0 |
GitHub provides 2,000 free CI minutes per month for private repos (at 1x multiplier β macOS minutes count at 10x, so that is effectively 200 macOS minutes). For open source repos (public), GitHub Actions is completely free.
7.2 Your First Workflow β Test on Every PR
This workflow runs every time someone opens or updates a pull request. It builds the app and runs all tests. If tests fail, the PR shows a red X and cannot be merged (if you enable branch protection rules).
Create the file .github/workflows/test.yml:
# .github/workflows/test.yml
#
# PURPOSE: Run tests on every pull request to catch bugs before merging.
# TRIGGER: Any pull request targeting main or develop branches.
# WHAT IT DOES: Checks out code, installs dependencies, runs unit tests.
# WHAT IT DOES NOT: Deploy anywhere. This is purely a quality gate.
name: Test # Name shown in GitHub's Actions tab and PR status checks
# ββ TRIGGERS ββββββββββββββββββββββββββββββββ
# 'on' defines when this workflow runs.
on:
pull_request:
branches: [main, develop]
# Only run if iOS-relevant files changed.
# This saves precious macOS CI minutes by skipping runs
# when only README.md or documentation files changed.
paths:
- '**/*.swift'
- '**/*.xib'
- '**/*.storyboard'
- '*.xcodeproj/**'
- '*.xcworkspace/**'
- 'Podfile*'
- 'Package.*'
- '.github/workflows/test.yml'
- 'fastlane/**'
# ββ CONCURRENCY βββββββββββββββββββββββββββββ
# If you push 5 commits quickly to a PR, you do NOT want 5 pipelines
# running simultaneously. This cancels any in-progress run for the
# same PR and starts a new one with the latest code.
concurrency:
group: test-${{ github.head_ref }} # Group by PR branch name
cancel-in-progress: true # Cancel older runs in this group
# ββ JOBS ββββββββββββββββββββββββββββββββββββ
jobs:
test:
name: Build & Test
runs-on: macos-15 # MUST be macOS for iOS builds
timeout-minutes: 30 # Kill the job if it exceeds 30 minutes
# (prevents runaway builds from burning CI credits)
steps:
# ββ Step 1: Check out your code ββ
# This action clones your repository onto the runner machine.
# Without this, the runner has nothing to build.
- name: Checkout code
uses: actions/checkout@v4
# ββ Step 2: Select the correct Xcode version ββ
# GitHub runners have multiple Xcode versions installed.
# We explicitly select the one our project needs.
# List available versions: ls /Applications/Xcode*.app
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer
# ββ Step 3: Cache Ruby gems ββ
# Caching saves the 30-60 seconds it takes to install Fastlane and its
# dependencies. The cache key includes the Gemfile.lock hash β if you
# update a gem version, the cache is automatically invalidated.
- name: Cache Ruby gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: gems-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }}
restore-keys: gems-${{ runner.os }}-
# ββ Step 4: Install Ruby gems (Fastlane) ββ
- name: Install Ruby gems
run: |
bundle config path vendor/bundle # Install gems to ./vendor/bundle
bundle install --jobs 4 --retry 3 # Install with 4 parallel threads
# ββ Step 5: Cache CocoaPods ββ
# Similar to gem caching β saves the 1-5 minutes of pod install.
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: pods-${{ runner.os }}-${{ hashFiles('Podfile.lock') }}
restore-keys: pods-${{ runner.os }}-
# ββ Step 6: Install CocoaPods ββ
- name: Install CocoaPods
run: bundle exec pod install
# If using ONLY Swift Package Manager (no CocoaPods):
# Remove steps 5 and 6 entirely. Xcode resolves SPM packages automatically.
# ββ Step 7: Cache Swift Package Manager packages ββ
- name: Cache SPM packages
uses: actions/cache@v4
with:
path: |
~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: spm-${{ runner.os }}-${{ hashFiles('*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-${{ runner.os }}-
# ββ Step 8: Run tests ββ
- name: Run tests
run: bundle exec fastlane test
# ββ Step 9: Upload test results ββ
# 'if: always()' means "run this step even if tests failed."
# We ALWAYS want to upload results β especially when tests fail,
# because that is when you need the results most.
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: fastlane/test_output/
retention-days: 14 # Auto-delete after 14 days
# ββ Step 10: Post test results as a PR comment ββ
# This action parses the JUnit XML and posts a nicely formatted
# test report directly in the PR conversation.
- name: Publish test report
if: always()
uses: dorny/test-reporter@v1
with:
name: Unit Test Results
path: fastlane/test_output/report.junit
reporter: java-junit
7.3 Staging Deployment Workflow
This workflow runs when code is merged to develop and deploys a Staging build to TestFlight:
# .github/workflows/staging.yml
#
# PURPOSE: Build Staging app and deploy to TestFlight for QA testing.
# TRIGGER: Code merged (pushed) to the 'develop' branch.
# WHAT IT DOES: Full pipeline β build, test, sign, archive, upload to TestFlight.
name: Deploy Staging
on:
push:
branches: [develop] # Triggers on merge to develop
concurrency:
group: staging-deploy
cancel-in-progress: false # Do NOT cancel in-progress deployments
# (canceling mid-upload could leave App Store Connect
# in a weird state)
jobs:
deploy:
name: Build & Deploy to TestFlight (Staging)
runs-on: macos-15
timeout-minutes: 45 # Deployment takes longer than testing
# GitHub Environments add an extra layer of protection.
# You can require approvals, limit which branches can deploy,
# and set environment-specific secrets.
# Configure at: GitHub β Settings β Environments β New environment
environment: staging
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer
- name: Cache Ruby gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: gems-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }}
- name: Install Ruby gems
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: pods-${{ runner.os }}-${{ hashFiles('Podfile.lock') }}
- name: Install CocoaPods
run: bundle exec pod install
# ββ The deployment step ββ
# All sensitive values come from GitHub Secrets.
# The Fastfile reads them via ENV["SECRET_NAME"].
- name: Build and deploy to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: bundle exec fastlane staging
# Save the IPA as a build artifact
- name: Upload IPA artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: staging-ipa
path: build/MyApp-Staging.ipa
retention-days: 30
7.4 Production Deployment Workflow
This workflow runs when a version tag is pushed:
# .github/workflows/production.yml
#
# PURPOSE: Build Production app and deploy to TestFlight.
# TRIGGER: A Git tag matching the pattern v*.*.* (e.g., v1.3.0, v2.0.1)
# HOW TO TRIGGER: git tag v1.3.0 && git push --tags
name: Deploy Production
on:
push:
tags:
- 'v*.*.*'
jobs:
deploy:
name: Build & Deploy to TestFlight (Production)
runs-on: macos-15
timeout-minutes: 45
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer
- name: Cache Ruby gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: gems-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }}
- name: Install Ruby gems
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: pods-${{ runner.os }}-${{ hashFiles('Podfile.lock') }}
- name: Install CocoaPods
run: bundle exec pod install
- name: Build and deploy
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: bundle exec fastlane production
- name: Upload IPA artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: production-ipa
path: build/MyApp-Production.ipa
retention-days: 90
# Create a GitHub Release with the IPA attached
- name: Create GitHub Release
if: success()
uses: softprops/action-gh-release@v2
with:
files: build/MyApp-Production.ipa
generate_release_notes: true
7.5 Setting Up GitHub Secrets β Complete Walkthrough
Your workflows reference several secrets. Here is exactly how to create each one.
How to Add Secrets in GitHub
- Go to your repository on GitHub
- Click Settings (the gear icon in the top navigation)
- In the left sidebar, click Secrets and variables β Actions
- Click New repository secret
- Enter the Name and Value for each secret listed below
- Click Add secret
Secret: MATCH_PASSWORD
- Name:
MATCH_PASSWORD - Value: The encryption password you chose when you first ran
fastlane match - How to get it: You set this password yourself during Match setup (Step 4 in Chapter 5)
Secret: MATCH_GIT_BASIC_AUTHORIZATION
This allows the CI runner to clone your private certificate Git repository.
- Name:
MATCH_GIT_BASIC_AUTHORIZATION - Value: Base64-encoded
username:personal_access_token
How to create it:
- Go to https://github.com/settings/tokens
- Click Generate new token (classic)
- Give it a name like "CI Certificate Repo Access"
- Select the
reposcope (full control of private repositories) - Click Generate token
- Copy the token (you can only see it once!)
- Encode it:
echo -n "your-github-username:ghp_yourPersonalAccessTokenHere" | base64
# Output: eW91ci1naXRodWItdXNlcm5hbWU6Z2hwX3lvdXJQZXJzb25hbEFjY2Vzc1Rva2VuSGVyZQ==
- Use the Base64 output as the secret value
Secrets: App Store Connect API Key
This is the recommended way to authenticate with App Store Connect from CI. It avoids Apple ID 2FA issues entirely.
How to create the API key:
- Go to https://appstoreconnect.apple.com
- Click Users and Access in the top navigation
- Click Integrations β App Store Connect API
- Click the + button to generate a new key
- Name: "CI/CD Key"
- Access: App Manager (or Admin if you need full access)
- Click Generate
- Download the
.p8file immediately β you can only download it ONCE - Note the Key ID and Issuer ID displayed on the page
Now create three secrets:
| Secret Name | Value | Where to Find It |
|---|---|---|
ASC_KEY_ID |
The Key ID (a short alphanumeric string like ABC123DEFG) |
Shown on the API Keys page next to your key |
ASC_ISSUER_ID |
The Issuer ID (a UUID like 12345678-1234-1234-1234-123456789012) |
Shown at the top of the API Keys page |
ASC_PRIVATE_KEY |
The entire contents of the .p8 file (including the -----BEGIN EC PRIVATE KEY----- and -----END EC PRIVATE KEY----- lines) |
Open the downloaded .p8 file in a text editor and copy everything |
Secret: SLACK_WEBHOOK_URL (Optional)
- Name:
SLACK_WEBHOOK_URL - Value: Your Slack incoming webhook URL
- How to create it: Go to https://api.slack.com/apps β Create New App β Incoming Webhooks β Activate β Add New Webhook to Workspace β Choose a channel β Copy the webhook URL
7.6 Optimizing Build Times with Caching
macOS CI minutes are expensive. Caching is your most powerful tool for reducing build times and costs.
How caching works in GitHub Actions:
- First run: The cache does not exist. The step downloads and installs everything from scratch. At the end, the files are saved to GitHub's cache storage with a specific key.
- Subsequent runs: Before installing, GitHub checks if a cache with the matching key exists. If it does, the cached files are restored, and the install step becomes a fast no-op (or a quick verification that nothing changed).
- Cache invalidation: The cache key includes a hash of a lock file (like
Gemfile.lock). When you update a dependency (changing the lock file), the hash changes, the old cache is not found, and a fresh install happens.
| What to Cache | Cache Key | Typical Time Saved |
|---|---|---|
Ruby gems (vendor/bundle) |
hashFiles('Gemfile.lock') |
30-60 seconds |
CocoaPods (Pods/) |
hashFiles('Podfile.lock') |
1-5 minutes |
| SPM packages | hashFiles('Package.resolved') |
1-3 minutes |
| Xcode DerivedData (advanced) | hashFiles('**/*.swift', '**/*.xcodeproj/**') |
2-10 minutes |
Chapter 8: TestFlight Deployment Automation
8.1 What Is TestFlight?
TestFlight is Apple's official beta testing platform. It lets you distribute pre-release builds of your app to testers before it goes live on the App Store. Think of it as a "preview channel" β testers install the TestFlight app on their iPhone, and through it they can install and test your beta builds.
TestFlight supports two types of testers:
| Tester Type | Limit | Apple Review Required? | How They Access |
|---|---|---|---|
| Internal testers | Up to 100 (must be members of your App Store Connect team) | No | Automatic access to every build |
| External testers | Up to 10,000 | Yes (first build of each version only) | You invite them by email or share a public link |
Internal testers are your team members β developers, designers, QA engineers, product managers. External testers are beta users, early adopters, or stakeholders outside your immediate team.
8.2 The Upload and Processing Pipeline
When Fastlane uploads your IPA to TestFlight, here is what happens behind the scenes:
Fastlane: upload_to_testflight
β
β (uploads IPA via App Store Connect API)
β
βΌ
App Store Connect receives the IPA
β
βΌ
Apple Processing Pipeline (5-45 minutes)
βββ Binary validation β checks the IPA structure, code signature, entitlements
βββ Compliance checks β verifies export compliance declarations
βββ Privacy manifest analysis β checks your app's privacy declarations
βββ App thinning β generates device-specific variants (so an iPhone 15 only
β downloads assets it needs, not iPad-only assets)
βββ Symbol processing β processes dSYMs for crash reporting
βββ Beta review (external testers only) β Apple reviews the first build of each version
β
βΌ
Build Available in TestFlight
Testers receive a push notification: "A new build is available for testing"
They open TestFlight app and tap "Update" to install the new build
The processing time varies. Small apps might process in 5 minutes. Large apps with many assets can take 30-45 minutes. Fastlane's skip_waiting_for_build_processing: true parameter tells Fastlane to return immediately after the upload succeeds, rather than polling App Store Connect every 30 seconds waiting for processing to complete.
8.3 Build Number Management
Every TestFlight upload must have a unique build number. Apple rejects uploads with duplicate version + build number combinations.
Your app has two version numbers:
- Marketing Version (
CFBundleShortVersionString): The user-facing version, like1.3.0. This is what appears on the App Store. - Build Number (
CFBundleVersion): An internal number that distinguishes different builds of the same marketing version. For example, version1.3.0might have builds1,2,3during development and testing.
The rule: Within a given marketing version, every build number must be unique. You cannot upload two IPAs both labeled 1.3.0 (build 5).
Recommended strategy: Auto-increment based on TestFlight:
increment_build_number(
build_number: latest_testflight_build_number(
app_identifier: "com.yourcompany.myapp.staging"
) + 1
)
This queries App Store Connect for the highest build number ever uploaded for this app and adds 1. If the latest build is #47, the next becomes #48. This guarantees uniqueness without any manual tracking.
Alternative strategy: Git commit count:
increment_build_number(
build_number: sh("git rev-list --count HEAD").strip
)
This uses the total number of commits in your Git history as the build number. Since commits are always added (never removed, in normal workflow), this number is always increasing. It also gives you a direct link between build number and Git history β build #523 corresponds to the 523rd commit.
8.4 Automating TestFlight Group Management
You can automatically add builds to specific TestFlight groups:
upload_to_testflight(
app_identifier: "com.yourcompany.myapp.staging",
skip_waiting_for_build_processing: false, # Must wait to add to groups
distribute_external: true, # Make available to external testers
groups: ["QA Team", "Product Managers"], # TestFlight group names
changelog: "Build #{get_build_number}\n\nChanges:\n#{changelog_from_git_commits}"
)
Note: If distribute_external is true and this is the first build of a new marketing version, Apple performs a brief beta review. This can take a few hours. Subsequent builds of the same version do not require review.
8.5 Export Compliance
Apple requires you to declare whether your app uses encryption. If your app ONLY uses HTTPS (which most apps do), you can automatically declare compliance by adding this to your Info.plist:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
This tells Apple: "My app only uses standard HTTPS encryption, which is exempt from export compliance requirements." Without this key, every TestFlight build will be stuck in "Missing Compliance" status until you manually answer the question in App Store Connect.
If your app uses custom encryption (beyond HTTPS), set this to true and be prepared to provide export compliance documentation.
Chapter 9: App Store Deployment Automation
9.1 The App Store Submission Pipeline
Submitting to the App Store involves more than just uploading a binary. You also need to provide:
- Metadata β app name, subtitle, description, keywords, categories, copyright, privacy URL, support URL
- Screenshots β at least one screenshot per required device size and orientation
- App Preview Videos β optional promotional videos
- Review Notes β instructions for the App Store review team (demo account credentials, special instructions)
- Pricing & Availability β free or paid, which countries
- Age Rating β answers to Apple's content questionnaire
- Privacy Details β what data your app collects, how it is used, whether it is linked to the user's identity
Fastlane can automate ALL of this through the deliver action and the metadata directory structure.
9.2 Setting Up Metadata with Fastlane Deliver
Initialize the metadata folder structure:
bundle exec fastlane deliver init
This creates a fastlane/metadata/ directory with subfolders for each locale:
fastlane/
βββ metadata/
β βββ copyright.txt β e.g., "2026 YourCompany LLC"
β βββ primary_category.txt β e.g., "PRODUCTIVITY"
β βββ en-US/
β β βββ name.txt β App name (max 30 chars)
β β βββ subtitle.txt β App subtitle (max 30 chars)
β β βββ description.txt β Full description (max 4000 chars)
β β βββ keywords.txt β Search keywords, comma-separated (max 100 chars)
β β βββ release_notes.txt β "What's New" text for this version
β β βββ promotional_text.txt β Can be updated WITHOUT a new review
β β βββ privacy_url.txt β URL to your privacy policy
β β βββ support_url.txt β URL to your support page
β β βββ marketing_url.txt β URL to your marketing page
β βββ review_information/
β β βββ demo_user.txt β Demo account username for the reviewer
β β βββ demo_password.txt β Demo account password
β β βββ notes.txt β Special instructions for the reviewer
β β βββ email_address.txt β Contact email for Apple
β β βββ first_name.txt β Your first name
β β βββ last_name.txt β Your last name
β β βββ phone_number.txt β Your phone number
β βββ es-ES/ β Spanish localization
β βββ name.txt
β βββ description.txt
β βββ ...
βββ screenshots/
βββ en-US/
β βββ iPhone 16 Pro Max-1.png
β βββ iPhone 16 Pro Max-2.png
β βββ ...
βββ es-ES/
βββ ...
Commit this metadata directory to Git. This way, metadata changes go through the same review process as code changes β you can review description changes in a PR, see the history of keyword changes, and roll back if needed.
9.3 Automating Screenshots with Fastlane Snapshot
Manually taking screenshots for 5 device sizes Γ 3 languages = 15 sets of screenshots is incredibly tedious. Fastlane Snapshot automates this by running your UI tests on every device/language combination and capturing screenshots at specific points.
Step 1: Initialize Snapshot
bundle exec fastlane snapshot init
Step 2: Configure the Snapfile
# fastlane/Snapfile
# Which devices to screenshot on
devices([
"iPhone 16 Pro Max", # 6.9" display
"iPhone 16 Pro", # 6.3" display
"iPhone SE (3rd generation)", # 4.7" display (still required by Apple)
"iPad Pro (12.9-inch) (6th generation)" # Required if you support iPad
])
# Which languages
languages(["en-US", "es-ES", "ja"])
# Which scheme to build
scheme("MyApp-Production")
# Where to save screenshots
output_directory("./fastlane/screenshots")
# Remove old screenshots before capturing new ones
clear_previous_screenshots(true)
# Stop immediately if a screenshot capture fails
stop_after_first_error(true)
Step 3: Write Screenshot UI Tests
Create a UI test class specifically for screenshots:
// MyAppUITests/ScreenshotTests.swift
import XCTest
class ScreenshotTests: XCTestCase {
let app = XCUIApplication()
override func setUp() {
super.setUp()
continueAfterFailure = false
// setupSnapshot is a Fastlane helper that configures the app
// for the current device and language combination
setupSnapshot(app)
app.launch()
}
func testCaptureScreenshots() {
// Screenshot 1: Home screen
// Allow the UI to settle before capturing
sleep(1)
snapshot("01-HomeScreen")
// Screenshot 2: Navigate to a feature
app.buttons["Explore"].tap()
sleep(1)
snapshot("02-ExploreScreen")
// Screenshot 3: Detail view
app.cells.firstMatch.tap()
sleep(1)
snapshot("03-DetailScreen")
// Screenshot 4: Profile
app.tabBars.buttons["Profile"].tap()
sleep(1)
snapshot("04-ProfileScreen")
// Screenshot 5: Settings
app.buttons["Settings"].tap()
sleep(1)
snapshot("05-SettingsScreen")
}
}
Step 4: Capture Screenshots
bundle exec fastlane snapshot
This takes a while (5-30 minutes) because it builds and runs the app on every device/language combination. The result is a screenshots/ directory with perfectly named files for every combination.
9.4 The Complete Release Process
Here is a lane that handles the full release:
desc "Full App Store release: capture screenshots, build, upload, submit"
lane :full_release do
# Step 1: Capture screenshots (optional β skip if screenshots haven't changed)
# capture_screenshots
# Step 2: Build the production IPA
production # Calls the :production lane defined earlier
# Step 3: Upload metadata + screenshots + submit for review
deliver(
app_identifier: APP_ID_PROD,
ipa: "./build/MyApp-Production.ipa",
submit_for_review: true,
automatic_release: false, # Manually release after approval
force: true, # Skip interactive confirmations
overwrite_screenshots: true, # Replace existing screenshots
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false
}
)
end
Chapter 10: Xcode Cloud β Apple's Native CI/CD
10.1 What Is Xcode Cloud?
Xcode Cloud is Apple's own CI/CD service, built directly into Xcode and App Store Connect. It provides macOS build machines managed by Apple, automatic code signing, and tight integration with TestFlight and the App Store.
Think of Xcode Cloud as the "easy mode" for iOS CI/CD. It trades flexibility and customization for simplicity and convenience.
10.2 When to Use Xcode Cloud vs. GitHub Actions + Fastlane
| Factor | Xcode Cloud | GitHub Actions + Fastlane |
|---|---|---|
| Setup time | ~30 minutes (mostly GUI) | ~2-4 hours (config files, secrets, Match) |
| Code signing | Automatic β Apple handles everything | Manual β Match setup required |
| Customization | Limited β pre/post scripts only | Unlimited β any shell command, any tool |
| Cost | 25 hours free/month, paid plans above | Free for public repos; ~$0.08/min for private |
| Build speed | Moderate | Depends on runner (can be faster with self-hosted) |
| Multi-platform | Apple platforms only (iOS, macOS, watchOS, tvOS, visionOS) | Any platform (iOS, Android, web, server) |
| Tool ecosystem | Limited (Apple tools only) | Huge (Fastlane plugins, Actions marketplace, custom tools) |
| Team size | Best for small-medium teams | Scales to any size |
| Learning curve | Low (GUI-driven) | Medium (YAML + Ruby + terminal) |
Recommendation: Use Xcode Cloud if you are a solo developer or small team, want minimal setup, and your needs are straightforward. Use GitHub Actions + Fastlane if you need maximum flexibility, have complex multi-environment setups, or build for multiple platforms.
10.3 Setting Up Xcode Cloud
Step 1: Connect Your Repository
- Open your project in Xcode
- Go to Product β Xcode Cloud β Create Workflow...
- Sign in with your Apple ID (must be part of your Apple Developer team)
- Select your app target
- Grant Xcode Cloud access to your Git repository (supports GitHub, GitLab, Bitbucket)
Step 2: Configure the Workflow
The workflow editor lets you set:
- Start Conditions: Which branches/tags/PRs trigger the workflow
- Environment: Xcode version and macOS version
- Actions: Build, Test, Analyze, Archive
- Post-Actions: Deploy to TestFlight, notify via email
Step 3: Custom Scripts
Xcode Cloud supports custom shell scripts at three points:
ci_scripts/
βββ ci_post_clone.sh β After cloning (install dependencies)
βββ ci_pre_xcodebuild.sh β Before building (generate files)
βββ ci_post_xcodebuild.sh β After building (upload symbols, notify)
Example ci_post_clone.sh:
#!/bin/bash
set -e
echo "Installing CocoaPods..."
gem install cocoapods
pod install
echo "Generating secrets..."
cat > Configurations/Secrets/Secrets-Staging.xcconfig << EOF
STRIPE_KEY = ${STRIPE_KEY}
MAPS_KEY = ${MAPS_KEY}
EOF
Make scripts executable: chmod +x ci_scripts/ci_post_clone.sh
Chapter 11: Advanced Topics
11.1 Branch Strategy for CI/CD
A well-defined branch strategy makes your CI/CD pipeline predictable:
main (production-ready) β Tagged releases go to App Store
β
βββ develop (integration) β Merges trigger Staging TestFlight
β β
β βββ feature/login β PRs trigger test runs only
β βββ feature/payments
β βββ bugfix/crash-123
β
βββ hotfix/critical-fix β Emergency fixes β main β immediate deploy
| Branch | CI Action | Deployment Target |
|---|---|---|
feature/*, bugfix/* |
Build + Unit Tests (on PR) | None |
develop |
Build + All Tests + Deploy | Staging TestFlight |
main |
Build + All Tests + Deploy | Production TestFlight |
v*.*.* tag on main |
Build + Deploy + Submit | App Store |
hotfix/* β main |
Build + Tests + Deploy | Production TestFlight (urgent) |
11.2 Automated Version Bumping
Use Fastlane to manage version numbers based on semantic versioning:
desc "Bump version, commit, tag, and push"
lane :bump_version do |options|
bump_type = options[:type] || "patch"
# Increment: patch (1.0.0β1.0.1), minor (1.0.0β1.1.0), major (1.0.0β2.0.0)
increment_version_number(bump_type: bump_type)
version = get_version_number
commit_version_bump(message: "Bump version to #{version}")
add_git_tag(tag: "v#{version}")
push_to_git_remote(tags: true)
end
Usage:
bundle exec fastlane bump_version type:patch # 1.0.0 β 1.0.1
bundle exec fastlane bump_version type:minor # 1.0.1 β 1.1.0
bundle exec fastlane bump_version type:major # 1.1.0 β 2.0.0
11.3 Multiple Targets (Widgets, Extensions)
If your app has extensions, each needs its own code signing:
private_lane :setup_all_signing do
match(type: "appstore", app_identifier: "com.yourcompany.myapp")
match(type: "appstore", app_identifier: "com.yourcompany.myapp.widget")
match(type: "appstore", app_identifier: "com.yourcompany.myapp.share")
end
And in export options:
build_app(
export_options: {
provisioningProfiles: {
"com.yourcompany.myapp" => "match AppStore com.yourcompany.myapp",
"com.yourcompany.myapp.widget" => "match AppStore com.yourcompany.myapp.widget",
"com.yourcompany.myapp.share" => "match AppStore com.yourcompany.myapp.share"
}
}
)
11.4 Self-Hosted Runners
For teams that build frequently, a self-hosted Mac saves significant money:
| Option | Monthly Cost | Speed | Maintenance |
|---|---|---|---|
| GitHub macOS runners | ~$200-$500 (usage-based) | Moderate | Zero |
| MacStadium cloud Mac | ~$100-$200 fixed | Fast | Medium |
| Mac Mini in office | ~$800 one-time purchase | Very fast | High |
Setting Up a Self-Hosted Runner
- Get a Mac (Mac Mini recommended)
- In GitHub: Settings β Actions β Runners β New self-hosted runner
- Follow the setup instructions
- Reference in your workflow:
runs-on: self-hosted
Chapter 12: Troubleshooting Guide
12.1 Code Signing Errors
"No signing certificate found"
What it means: The Keychain on this machine does not contain the required signing certificate.
Fix: bundle exec fastlane match appstore (or development)
"Provisioning profile doesn't match"
What it means: The profile was created with a different certificate.
Fix: bundle exec fastlane match appstore --force_for_new_devices
Nuclear option: bundle exec fastlane match nuke distribution then match appstore
"Code signing blocked mmap()"
What it means: The binary was modified after signing, invalidating the signature.
Fix: Clean build: xcodebuild clean, then rebuild.
12.2 Build Failures
"No such module 'SomeFramework'"
What it means: Dependencies were not installed.
Fix: Ensure pod install or xcodebuild -resolvePackageDependencies runs before building.
"Command PhaseScriptExecution failed"
What it means: A Run Script build phase failed.
Fix: Install missing tools (brew install swiftlint), or make scripts executable (chmod +x script.sh).
12.3 TestFlight Upload Failures
"Authentication failed"
Fix: Use App Store Connect API key instead of Apple ID (Chapter 7.5).
"Missing compliance"
Fix: Add ITSAppUsesNonExemptEncryption = NO to Info.plist.
"Redundant binary upload"
Fix: Increment the build number.
12.4 GitHub Actions Issues
Workflow never triggers
Checklist:
- YAML file is in
.github/workflows/(exact path) - YAML syntax is valid
- Branch name matches the trigger
- File path filters match the changed files
"No space left on device"
Fix: Free space at the beginning of your workflow:
- name: Free disk space
run: |
sudo rm -rf /Library/Developer/CoreSimulator/Caches/*
sudo rm -rf ~/Library/Developer/Xcode/DerivedData/*
"Resource not accessible by integration"
Fix: Add permissions to your workflow:
permissions:
contents: write
Chapter 13: Complete Reference & Cheat Sheets
13.1 File Structure Reference
MyApp/
βββ .github/workflows/
β βββ test.yml β PR test workflow
β βββ staging.yml β Staging deploy workflow
β βββ production.yml β Production deploy workflow
βββ ci_scripts/ β Xcode Cloud scripts (if using)
β βββ ci_post_clone.sh
β βββ ci_post_xcodebuild.sh
βββ fastlane/
β βββ Appfile β App identity
β βββ Fastfile β Automation lanes
β βββ Matchfile β Code signing config
β βββ Snapfile β Screenshot config
β βββ .env β Local secrets (git-ignored)
β βββ metadata/ β App Store metadata
β β βββ en-US/
β β β βββ name.txt
β β β βββ description.txt
β β β βββ release_notes.txt
β β βββ review_information/
β βββ test_output/ β Test results (git-ignored)
βββ Configurations/ β Build configs (from companion guide)
β βββ Base/
β βββ Debug/
β βββ Staging/
β βββ Release/
β βββ Secrets/ β Git-ignored secret xcconfig files
βββ MyApp.xcodeproj/
β βββ xcshareddata/xcschemes/
β βββ MyApp-Dev.xcscheme
β βββ MyApp-Staging.xcscheme
β βββ MyApp-Production.xcscheme
βββ Gemfile β Ruby deps (Fastlane version)
βββ Gemfile.lock β Locked versions
βββ .gitignore
13.2 .gitignore for iOS CI/CD
# ββ Xcode ββ
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
DerivedData/
build/
*.ipa
*.dSYM.zip
*.dSYM
# ββ CocoaPods ββ
# Pods/ β Uncomment to ignore Pods directory
# ββ Fastlane ββ
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/
fastlane/.env
fastlane/.env.*
# ββ Secrets ββ
Configurations/Secrets/*.xcconfig
!Configurations/Secrets/*.xcconfig.template
# ββ Ruby ββ
vendor/bundle/
.bundle/
# ββ macOS ββ
.DS_Store
13.3 Fastlane Commands Cheat Sheet
# ββ SETUP ββ
bundle exec fastlane init # Initialize Fastlane
bundle exec fastlane match init # Initialize Match
bundle exec fastlane deliver init # Initialize metadata
bundle exec fastlane snapshot init # Initialize screenshots
# ββ CODE SIGNING ββ
bundle exec fastlane match development # Dev certs/profiles
bundle exec fastlane match appstore # Distribution certs/profiles
bundle exec fastlane match adhoc # Ad-hoc certs/profiles
bundle exec fastlane match nuke distribution # β οΈ Revoke ALL dist certs
# ββ BUILD & TEST ββ
bundle exec fastlane test # Run tests
bundle exec fastlane staging # Build + deploy staging
bundle exec fastlane production # Build + deploy production
# ββ APP STORE ββ
bundle exec fastlane deliver # Upload metadata only
bundle exec fastlane release # Submit for review
bundle exec fastlane snapshot # Capture screenshots
# ββ UTILITIES ββ
bundle exec fastlane lanes # List all lanes
bundle exec fastlane actions # List all actions
bundle exec fastlane action build_app # Help for specific action
bundle exec fastlane env # Debug environment info
13.4 xcodebuild Commands Cheat Sheet
# Build
xcodebuild build -workspace MyApp.xcworkspace -scheme MyApp-Dev
# Test
xcodebuild test -workspace MyApp.xcworkspace -scheme MyApp-Dev \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro'
# Archive
xcodebuild archive -workspace MyApp.xcworkspace -scheme MyApp-Production \
-archivePath ./build/MyApp.xcarchive
# Export IPA
xcodebuild -exportArchive -archivePath ./build/MyApp.xcarchive \
-exportPath ./build -exportOptionsPlist ExportOptions.plist
# Diagnostics
xcodebuild -showBuildSettings -scheme MyApp-Dev
xcodebuild -list
xcrun simctl list devices
13.5 The Complete Mental Model
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β YOUR CODE (Git) β
β β
β Push to feature/* βββΊ GitHub Actions: TEST ONLY β
β Merge to develop βββΊ GitHub Actions: TEST + STAGING β
β Tag v1.2.3 βββΊ GitHub Actions: TEST + PROD β
ββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β GITHUB ACTIONS (macOS Runner) β
β β
β 1. Checkout code β
β 2. Install deps (Ruby, CocoaPods/SPM) β
β 3. Code signing (Match ββ Cert Git repo) β
β 4. Build (xcodebuild via Fastlane) β
β 5. Test (xcodebuild test via Fastlane) β
β 6. Archive + Export IPA β
β 7. Upload to TestFlight / App Store β
β 8. Notify Slack β
ββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β APP STORE CONNECT β
β β
β TestFlight βββΊ QA testers install and test β
β App Store βββΊ Apple reviews βββΊ Public release β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
13.6 First-Time Setup Checklist
- [ ] Apple Developer Account β Paid ($99/year)
- [ ] Xcode Project β Configurations, Schemes, Targets set up
- [ ] Git Repository β Pushed to GitHub
- [ ] Gemfile β Created with Fastlane,
bundle installrun - [ ] Fastlane init β
bundle exec fastlane init(manual) - [ ] Appfile β Apple ID, Team ID, App Identifier filled in
- [ ] Certificate Repo β Private GitHub repo created
- [ ] Match init β Matchfile configured
- [ ] Match generate β
match development+match appstorerun - [ ] Fastfile β Lanes for test, staging, production written
- [ ] App Store Connect API Key β Created,
.p8downloaded - [ ] GitHub Secrets β All secrets configured
- [ ] Test Workflow β
.github/workflows/test.ymlcreated - [ ] Staging Workflow β
.github/workflows/staging.ymlcreated - [ ] Production Workflow β
.github/workflows/production.ymlcreated - [ ] .gitignore β Secrets, build artifacts, user data excluded
- [ ] Verify PR β Open test PR, confirm tests run
- [ ] Verify Staging β Merge to develop, confirm TestFlight upload
- [ ] Verify Production β Create tag, confirm TestFlight upload
- [ ] Slack β Webhook configured, notifications working
Final Words
iOS CI/CD is complex. Apple's code signing system, the macOS-only build requirement, and the App Store review process create layers of complexity that other platforms simply do not have.
But now you have a complete, step-by-step blueprint for setting it all up. Every chapter built on the previous one: you understand what CI/CD is and why iOS makes it hard (Chapter 1), you see the full pipeline (Chapter 2), you know how code signing works at a fundamental level (Chapter 3), you can write Fastlane automation (Chapter 4), you can manage team signing with Match (Chapter 5), you can automate tests (Chapter 6), you can wire it all into GitHub Actions (Chapter 7), you can deploy to TestFlight (Chapter 8) and the App Store (Chapter 9), you know Apple's alternative (Chapter 10), you have advanced techniques for real-world complexity (Chapter 11), you can troubleshoot when things break (Chapter 12), and you have cheat sheets for daily use (Chapter 13).
The key principles to remember: use Fastlane Match so code signing is shared and automatic, use App Store Connect API keys instead of Apple ID passwords for CI authentication, use separate workflows for different trigger events, cache aggressively to reduce build times, keep all secrets out of Git, and always test your pipeline changes in a PR before merging.
One git push. Everything else happens automatically. Welcome to the world of automated iOS delivery.
This guide accompanies the "iOS Xcode: Configurations, Schemes & Targets" guide. Together, they provide a complete foundation for professional iOS development infrastructure.