All Articles
CategoryDevOps
Reading Time
28 min read
Published
2024-02-05
Word Count
2,457words

Grab a coffee — this one is a deep dive!

iOS CI/CD Pipeline: GitHub Actions and Fastlane

Summary

Automated build, test, and deployment with GitHub Actions, Fastlane, and Xcode Cloud. Complete iOS DevOps guide.

  • GitHub Actions offers 2000 min/month free, and Xcode Cloud offers 25 hours/month free.
  • Fastlane match syncs appstore/development certificates through a git repository.
  • -parallel-testing-enabled and random test order speed up flaky test detection.
  • Xcode Cloud pipelines are customized with ci_post_clone/ci_pre_xcodebuild/ci_post_xcodebuild scripts.
iOS CI/CD Pipeline: GitHub Actions and Fastlane

Hey! Today we're going through professional CI/CD pipeline setup for iOS projects from A to Z. By the end of this guide, you'll have a system that automatically builds, tests, and deploys to TestFlight whenever you push code.


Table of Contents


🎯 What You'll Learn in This Post

  • Setting up iOS CI/CD with GitHub Actions
  • Build automation with Fastlane
  • Managing code signing with Match
  • The Xcode Cloud alternative
  • Test automation and code coverage
  • Security best practices
  • Monitoring and alerting

📚 What Is CI/CD and Why Does It Matter?

CI (Continuous Integration): Automatic build and test on every commit

CD (Continuous Delivery/Deployment): Automatic TestFlight/App Store distribution

iOS CI/CD Tools Comparison

Tool
Cost
macOS Runner
Setup
GitHub Actions
2000 min/month free
Medium
Xcode Cloud
25 hours/month free
Easy
Bitrise
Limited free
Easy
CircleCI
Limited free
Medium
Jenkins
Free
Self-hosted
Hard
Fastlane
Free (local)
Local
Easy
💡 Golden Tip: Xcode Cloud is ideal to start, and the GitHub Actions + Fastlane combo is ideal to scale!

External Resources:


🏗️ GitHub Actions Setup

1. Basic Workflow Structure

yaml
1# .github/workflows/ios-ci.yml
2name: iOS CI/CD Pipeline
3 
4on:
5 push:
6 branches: [main, develop]
7 pull_request:
8 branches: [main]
9 # Manual trigger
10 workflow_dispatch:
11 inputs:
12 deploy_target:
13 description: 'Deploy target'
14 required: true
15 default: 'testflight'
16 type: choice
17 options:
18 - testflight
19 - app-store
20 
21# Cancel concurrent runs
22concurrency:
23 group: ${{ github.workflow }}-${{ github.ref }}
24 cancel-in-progress: true
25 
26env:
27 XCODE_VERSION: '15.2'
28 SCHEME: 'MyApp'
29 PROJECT: 'MyApp.xcodeproj'
30 # Or for xcworkspace:
31 # WORKSPACE: 'MyApp.xcworkspace'
32 
33jobs:
34 # Job 1: Lint & Format Check
35 lint:
36 runs-on: macos-14
37 steps:
38 - uses: actions/checkout@v4
39 
40 - name: Install SwiftLint
41 run: brew install swiftlint
42 
43 - name: Run SwiftLint
44 run: swiftlint --strict --reporter github-actions-logging
45 
46 - name: Check Swift Format
47 run: |
48 brew install swift-format
49 swift-format lint --recursive Sources/ Tests/
50 
51 # Job 2: Build & Test
52 build-and-test:
53 needs: lint
54 runs-on: macos-14
55 timeout-minutes: 30
56 
57 steps:
58 - name: Checkout
59 uses: actions/checkout@v4
60 with:
61 fetch-depth: 0 # Full history for versioning
62 
63 - name: Setup Xcode
64 uses: maxim-lobanov/setup-xcode@v1
65 with:
66 xcode-version: ${{ env.XCODE_VERSION }}
67 
68 # SPM Cache
69 - name: Cache SPM
70 uses: actions/cache@v4
71 with:
72 path: |
73 ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
74 .build
75 key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
76 restore-keys: |
77 ${{ runner.os }}-spm-
78 
79 # Ruby/Bundler for Fastlane
80 - name: Setup Ruby
81 uses: ruby/setup-ruby@v1
82 with:
83 ruby-version: '3.2'
84 bundler-cache: true
85 
86 # Build
87 - name: Build for Testing
88 run: |
89 set -o pipefail
90 xcodebuild build-for-testing \
91 -scheme "${{ env.SCHEME }}" \
92 -destination 'platform=iOS Simulator,name=iPhone 15 Pro' \
93 -configuration Debug \
94 -derivedDataPath DerivedData \
95 CODE_SIGNING_ALLOWED=NO \
96 | xcpretty --color
97 
98 # Unit Tests
99 - name: Run Unit Tests
100 run: |
101 set -o pipefail
102 xcodebuild test-without-building \
103 -scheme "${{ env.SCHEME }}" \
104 -destination 'platform=iOS Simulator,name=iPhone 15 Pro' \
105 -derivedDataPath DerivedData \
106 -resultBundlePath TestResults.xcresult \
107 -enableCodeCoverage YES \
108 | xcpretty --color --report junit --output test-results.xml
109 
110 # UI Tests (optional)
111 - name: Run UI Tests
112 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
113 run: |
114 xcodebuild test \
115 -scheme "${{ env.SCHEME }}UITests" \
116 -destination 'platform=iOS Simulator,name=iPhone 15 Pro' \
117 -derivedDataPath DerivedData \
118 | xcpretty
119 
120 # Upload test results
121 - name: Upload Test Results
122 uses: actions/upload-artifact@v4
123 if: always()
124 with:
125 name: test-results
126 path: |
127 TestResults.xcresult
128 test-results.xml
129 retention-days: 14
130 
131 # Code Coverage
132 - name: Generate Coverage Report
133 run: |
134 xcrun xccov view --report TestResults.xcresult > coverage.txt
135 cat coverage.txt
136 
137 - name: Upload Coverage to Codecov
138 uses: codecov/codecov-action@v4
139 with:
140 xcode: true
141 xcode_archive_path: TestResults.xcresult
142 
143 # Job 3: Deploy to TestFlight
144 deploy-testflight:
145 needs: build-and-test
146 runs-on: macos-14
147 if: github.ref == 'refs/heads/main' && github.event_name == 'push'
148 environment: production
149 
150 steps:
151 - uses: actions/checkout@v4
152 
153 - uses: maxim-lobanov/setup-xcode@v1
154 with:
155 xcode-version: ${{ env.XCODE_VERSION }}
156 
157 - uses: ruby/setup-ruby@v1
158 with:
159 ruby-version: '3.2'
160 bundler-cache: true
161 
162 # App Store Connect API Key
163 - name: Setup ASC API Key
164 env:
165 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
166 ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
167 ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
168 run: |
169 mkdir -p ~/.appstoreconnect/private_keys
170 echo "$ASC_KEY_CONTENT" > ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8
171 
172 # Deploy via Fastlane
173 - name: Deploy to TestFlight
174 env:
175 MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
176 MATCH_GIT_PRIVATE_KEY: ${{ secrets.MATCH_GIT_PRIVATE_KEY }}
177 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
178 ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
179 FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
180 run: |
181 bundle exec fastlane ios beta
182 
183 # Slack/Discord notification
184 - name: Notify Success
185 if: success()
186 uses: slackapi/slack-github-action@v1
187 with:
188 payload: |
189 {
190 "text": "✅ New build deployed to TestFlight!",
191 "blocks": [
192 {
193 "type": "section",
194 "text": {
195 "type": "mrkdwn",
196 "text": "*MyApp* - Build #${{ github.run_number }} deployed to TestFlight"
197 }
198 }
199 ]
200 }
201 env:
202 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

🔧 Fastlane Setup

2. Fastfile Configuration

ruby
1# fastlane/Fastfile
2default_platform(:ios)
3 
4# ═══════════════════════════════════════════
5# CONSTANTS
6# ═══════════════════════════════════════════
7APP_IDENTIFIER = "com.mycompany.myapp"
8TEAM_ID = "XXXXXXXXXX"
9ITC_TEAM_ID = "XXXXXXXXXX"
10 
11# ═══════════════════════════════════════════
12# iOS Platform
13# ═══════════════════════════════════════════
14platform :ios do
15 
16 # -----------------------------------------
17 # Before All
18 # -----------------------------------------
19 before_all do
20 # Keychain setup in CI environment
21 setup_ci if ENV['CI']
22 
23 # Version info
24 UI.message("🚀 Running Fastlane on: #{lane_context[:PLATFORM_NAME]}")
25 end
26 
27 # -----------------------------------------
28 # Error Handler
29 # -----------------------------------------
30 error do |lane, exception|
31 slack(
32 message: "❌ Fastlane failed: #{exception.message}",
33 success: false,
34 slack_url: ENV['SLACK_WEBHOOK']
35 ) if ENV['SLACK_WEBHOOK']
36 end
37 
38 # ═══════════════════════════════════════════
39 # LANES
40 # ═══════════════════════════════════════════
41 
42 # -----------------------------------------
43 # Test Lane
44 # -----------------------------------------
45 desc "Run all tests"
46 lane :test do
47 scan(
48 scheme: "MyApp",
49 device: "iPhone 15 Pro",
50 clean: true,
51 code_coverage: true,
52 output_types: "junit,html",
53 output_directory: "./fastlane/test_output",
54 result_bundle: true,
55 xcargs: "-skipPackagePluginValidation"
56 )
57 end
58 
59 # -----------------------------------------
60 # Beta Lane (TestFlight)
61 # -----------------------------------------
62 desc "Build and deploy to TestFlight"
63 lane :beta do |options|
64 # Ensure clean state
65 ensure_git_status_clean unless options[:skip_git_check]
66 
67 # Increment build number
68 increment_build_number(
69 build_number: ENV['GITHUB_RUN_NUMBER'] || latest_testflight_build_number + 1
70 )
71 
72 # Code signing
73 match(
74 type: "appstore",
75 readonly: is_ci,
76 app_identifier: APP_IDENTIFIER
77 )
78 
79 # Build
80 build_ios_app(
81 scheme: "MyApp",
82 configuration: "Release",
83 export_method: "app-store",
84 export_options: {
85 provisioningProfiles: {
86 APP_IDENTIFIER => "match AppStore #{APP_IDENTIFIER}"
87 }
88 },
89 xcargs: "-skipPackagePluginValidation",
90 include_bitcode: false,
91 output_directory: "./build",
92 output_name: "MyApp.ipa"
93 )
94 
95 # Upload to TestFlight
96 upload_to_testflight(
97 api_key: app_store_connect_api_key,
98 skip_waiting_for_build_processing: true,
99 changelog: changelog_from_git_commits(
100 commits_count: 10,
101 merge_commit_filtering: "only_include_merges"
102 )
103 )
104 
105 # Tag release
106 add_git_tag(
107 tag: "build/#{lane_context[SharedValues::BUILD_NUMBER]}"
108 )
109 
110 # Notify
111 slack(
112 message: "🚀 New build uploaded to TestFlight!",
113 success: true,
114 slack_url: ENV['SLACK_WEBHOOK'],
115 default_payloads: [:git_branch, :git_author]
116 ) if ENV['SLACK_WEBHOOK']
117 end
118 
119 # -----------------------------------------
120 # Release Lane (App Store)
121 # -----------------------------------------
122 desc "Deploy to App Store"
123 lane :release do |options|
124 # Version bump
125 version = options[:version] || prompt(text: "Enter version number:")
126 increment_version_number(version_number: version)
127 increment_build_number(build_number: 1)
128 
129 # Build
130 match(type: "appstore", readonly: true)
131 
132 build_ios_app(
133 scheme: "MyApp",
134 configuration: "Release",
135 export_method: "app-store"
136 )
137 
138 # Upload with metadata
139 upload_to_app_store(
140 api_key: app_store_connect_api_key,
141 skip_screenshots: true,
142 skip_metadata: false,
143 precheck_include_in_app_purchases: false,
144 submit_for_review: false,
145 automatic_release: false,
146 force: true
147 )
148 
149 # Commit and push
150 commit_version_bump(
151 message: "Release v#{version}",
152 xcodeproj: "MyApp.xcodeproj"
153 )
154 
155 add_git_tag(tag: "v#{version}")
156 push_to_git_remote
157 end
158 
159 # -----------------------------------------
160 # Certificates Lane
161 # -----------------------------------------
162 desc "Sync certificates"
163 lane :sync_certs do
164 match(type: "development", readonly: false)
165 match(type: "appstore", readonly: false)
166 end
167 
168 # -----------------------------------------
169 # Screenshots Lane
170 # -----------------------------------------
171 desc "Capture screenshots"
172 lane :screenshots do
173 capture_screenshots(
174 workspace: "MyApp.xcworkspace",
175 scheme: "MyAppUITests",
176 devices: [
177 "iPhone 15 Pro Max",
178 "iPhone SE (3rd generation)",
179 "iPad Pro (12.9-inch) (6th generation)"
180 ],
181 languages: ["en-US", "tr"],
182 output_directory: "./fastlane/screenshots"
183 )
184 
185 frame_screenshots(
186 path: "./fastlane/screenshots",
187 silver: true
188 )
189 end
190 
191 # ═══════════════════════════════════════════
192 # PRIVATE LANES
193 # ═══════════════════════════════════════════
194 
195 private_lane :app_store_connect_api_key do
196 app_store_connect_api_key(
197 key_id: ENV["ASC_KEY_ID"],
198 issuer_id: ENV["ASC_ISSUER_ID"],
199 key_filepath: "~/.appstoreconnect/private_keys/AuthKey_#{ENV['ASC_KEY_ID']}.p8",
200 in_house: false
201 )
202 end
203end

3. Matchfile (Code Signing)

ruby
1# fastlane/Matchfile
2git_url("[email protected]:mycompany/ios-certificates.git")
3storage_mode("git")
4 
5type("appstore")
6app_identifier(["com.mycompany.myapp"])
7username("[email protected]")
8 
9# Team settings
10team_id("XXXXXXXXXX")
11team_name("My Company")
12 
13# For CI
14readonly(is_ci)
15 
16# 🐣 Easter Egg: Hidden bypass
17# Set ENV['MATCH_SKIP_CONFIRM']='1' to skip confirmation

🔐 Secrets Management

4. GitHub Secrets Setup

yaml
1# Required secrets (Settings > Secrets > Actions):
2 
3# App Store Connect API
4ASC_KEY_ID: "XXXXXXXXXX"
5ASC_ISSUER_ID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
6ASC_KEY_CONTENT: |
7 -----BEGIN PRIVATE KEY-----
8 MIGTAgEAMB....
9 -----END PRIVATE KEY-----
10 
11# Match (Code Signing)
12MATCH_PASSWORD: "your-encryption-password"
13MATCH_GIT_PRIVATE_KEY: |
14 -----BEGIN OPENSSH PRIVATE KEY-----
15 xxxxx...
16 -----END OPENSSH PRIVATE KEY-----
17 
18# Notifications
19SLACK_WEBHOOK: "https://hooks.slack.com/services/..."

5. Keychain Management

ruby
1# fastlane/actions/setup_ci_keychain.rb
2# Custom action for CI keychain
3 
4module Fastlane
5 module Actions
6 class SetupCiKeychainAction < Action
7 def self.run(params)
8 keychain_name = "fastlane_ci_keychain"
9 keychain_password = SecureRandom.base64(32)
10 
11 # Create keychain
12 create_keychain(
13 name: keychain_name,
14 password: keychain_password,
15 default_keychain: true,
16 unlock: true,
17 timeout: 3600,
18 lock_when_sleeps: false
19 )
20 
21 # Return for cleanup
22 { name: keychain_name, password: keychain_password }
23 end
24 end
25 end
26end

🧪 Test Automation

6. Test Matrix Workflow

yaml
1# .github/workflows/test-matrix.yml
2name: Test Matrix
3 
4on:
5 pull_request:
6 branches: [main, develop]
7 
8jobs:
9 test-matrix:
10 strategy:
11 fail-fast: false
12 matrix:
13 os: [macos-13, macos-14]
14 xcode: ['15.0', '15.2']
15 destination:
16 - 'platform=iOS Simulator,name=iPhone 15,OS=17.2'
17 - 'platform=iOS Simulator,name=iPhone 14,OS=16.4'
18 
19 runs-on: ${{ matrix.os }}
20 name: Xcode ${{ matrix.xcode }} - ${{ matrix.destination }}
21 
22 steps:
23 - uses: actions/checkout@v4
24 
25 - uses: maxim-lobanov/setup-xcode@v1
26 with:
27 xcode-version: ${{ matrix.xcode }}
28 
29 - name: Run Tests
30 run: |
31 xcodebuild test \
32 -scheme "MyApp" \
33 -destination '${{ matrix.destination }}' \
34 -resultBundlePath "Results-${{ matrix.xcode }}.xcresult" \
35 CODE_SIGNING_ALLOWED=NO
36 
37 - name: Upload Results
38 uses: actions/upload-artifact@v4
39 with:
40 name: test-results-${{ matrix.xcode }}
41 path: "*.xcresult"

📊 Code Quality & Metrics

7. Quality Gates

yaml
1# .github/workflows/quality.yml
2name: Code Quality
3 
4on: [pull_request]
5 
6jobs:
7 quality-gate:
8 runs-on: macos-14
9 
10 steps:
11 - uses: actions/checkout@v4
12 with:
13 fetch-depth: 0
14 
15 # SwiftLint
16 - name: SwiftLint
17 run: |
18 brew install swiftlint
19 swiftlint --strict --reporter json > swiftlint-report.json
20 
21 # Complexity Analysis
22 - name: Analyze Complexity
23 run: |
24 brew install periphery
25 periphery scan --format json > periphery-report.json
26 
27 # Dead Code Detection
28 - name: Dead Code Check
29 run: |
30 periphery scan --format xcode
31 
32 # Dependency Check
33 - name: Check Outdated Dependencies
34 run: |
35 swift package show-dependencies --format json
36 
37 # Size Analysis
38 - name: Binary Size Check
39 run: |
40 xcodebuild -scheme MyApp -configuration Release archive \
41 -archivePath MyApp.xcarchive \
42 CODE_SIGNING_ALLOWED=NO
43 
44 # Check size
45 du -sh MyApp.xcarchive/Products/Applications/MyApp.app
46 
47 # Report
48 - name: Generate Report
49 run: |
50 echo "## Code Quality Report" >> $GITHUB_STEP_SUMMARY
51 echo "- SwiftLint issues: $(cat swiftlint-report.json | jq length)" >> $GITHUB_STEP_SUMMARY

Reader Reward

markdown
# 🎁 iOS CI/CD PRODUCTION CHECKLIST
 
## GitHub Actions Setup
- [ ] Workflow files created (.github/workflows/)
- [ ] macOS runner version selected
- [ ] Xcode version pinned
- [ ] SPM cache enabled
- [ ] Concurrent run cancellation enabled
 
## Fastlane Setup
- [ ] Fastfile created
- [ ] Matchfile configured
- [ ] Appfile created
- [ ] Gemfile dependencies defined
- [ ] .env.default file created
 
## Code Signing
- [ ] Match repository created
- [ ] Development certificate sync
- [ ] App Store certificate sync
- [ ] Provisioning profiles sync
- [ ] CI keychain automation
 
## Secrets
- [ ] ASC_KEY_ID
- [ ] ASC_ISSUER_ID
- [ ] ASC_KEY_CONTENT
- [ ] MATCH_PASSWORD
- [ ] MATCH_GIT_PRIVATE_KEY
- [ ] SLACK_WEBHOOK (optional)
 
## Testing
- [ ] Unit test lane
- [ ] UI test lane
- [ ] Code coverage reporting
- [ ] Test matrix (multiple iOS versions)
- [ ] Snapshot testing
 
## Deployment
- [ ] TestFlight deployment
- [ ] App Store deployment
- [ ] Changelog generation
- [ ] Git tagging
- [ ] Release notes
 
## Monitoring
- [ ] Slack notifications
- [ ] Build time tracking
- [ ] Test coverage trends
- [ ] Error alerting
 
## Security
- [ ] Secrets rotation schedule
- [ ] Access audit
- [ ] Dependency vulnerability scan
- [ ] Code signing audit

<a id="cicd-xcode-cloud"></a>

⚡ The Xcode Cloud Alternative

8. Xcode Cloud Setup

swift
1// ci_scripts/ci_post_clone.sh
2#!/bin/sh
3 
4set -e
5 
6echo "🔧 Post-clone setup..."
7 
8# Install dependencies
9if [ -f "Brewfile" ]; then
10 brew bundle install
11fi
12 
13# Install Ruby gems
14if [ -f "Gemfile" ]; then
15 bundle install
16fi
17 
18# SwiftLint
19if command -v swiftlint &> /dev/null; then
20 swiftlint --strict
21fi
22 
23echo "✅ Post-clone complete"
24 
25// ci_scripts/ci_pre_xcodebuild.sh
26#!/bin/sh
27 
28set -e
29 
30echo "🏗️ Pre-build setup..."
31 
32# Increment build number
33if [ "$CI_XCODE_CLOUD" = "TRUE" ]; then
34 agvtool new-version -all $CI_BUILD_NUMBER
35fi
36 
37echo "✅ Pre-build complete"
38 
39// ci_scripts/ci_post_xcodebuild.sh
40#!/bin/sh
41 
42set -e
43 
44if [ "$CI_XCODEBUILD_EXIT_CODE" != "0" ]; then
45 echo "❌ Build failed with code: $CI_XCODEBUILD_EXIT_CODE"
46 exit 1
47fi
48 
49echo "✅ Build succeeded"
50 
51# Upload dSYMs to Crashlytics
52if [ "$CI_XCODEBUILD_ACTION" = "archive" ]; then
53 echo "📤 Uploading dSYMs..."
54 # Firebase Crashlytics upload script
55fi

💡 Pro Tips and Best Practices

  1. Cache Everything - Cache SPM, CocoaPods, and DerivedData.
  1. Parallel Jobs - Run lint, build, and test in parallel.
  1. Fail Fast - Stop on the first error instead of wasting time.
  1. Use macOS 14 - M1 runners are 3x faster.
  1. Self-hosted Runner - Set up your own Mac mini for heavy projects.
  1. Artifact Retention - Keep test results for 14 days; more is unnecessary.
  1. Environment Separation - Use separate workflows for staging/production.
  1. Secrets Rotation - Rotate API keys every 90 days.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

📖 Conclusion

CI/CD is essential to modern iOS development. With the GitHub Actions + Fastlane combo, you can set up a pipeline at a professional level.

Remember: 1-2 days of investment upfront saves hundreds of hours afterward!

See you in the next post! 🚀


_Stuck setting up CI/CD? Reach out on Twitter!_

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Tags

#CI/CD#Fastlane#GitHub Actions#Xcode Cloud#DevOps#automation
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share