9.1 KiB
9.1 KiB
CI/CD
Xcode Cloud, fastlane, and automated testing and deployment.
Xcode Cloud
Setup
- Enable in Xcode: Product > Xcode Cloud > Create Workflow
- Configure in App Store Connect
Basic Workflow
# Configured in Xcode Cloud UI
Workflow: Build and Test
Start Conditions:
- Push to main
- Pull Request to main
Actions:
- Build
- Test (iOS Simulator)
Post-Actions:
- Notify (Slack)
Custom Build Scripts
.ci_scripts/ci_post_clone.sh:
#!/bin/bash
set -e
# Install dependencies
brew install swiftlint
# Generate files
cd $CI_PRIMARY_REPOSITORY_PATH
./scripts/generate-assets.sh
.ci_scripts/ci_pre_xcodebuild.sh:
#!/bin/bash
set -e
# Run SwiftLint
swiftlint lint --strict --reporter json > swiftlint-report.json || true
# Check for errors
if grep -q '"severity": "error"' swiftlint-report.json; then
echo "SwiftLint errors found"
exit 1
fi
Environment Variables
Set in Xcode Cloud:
API_BASE_URLSENTRY_DSN- Secrets (automatically masked)
Access in build:
let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String
Fastlane
Installation
# Install
brew install fastlane
# Or via bundler
bundle init
echo 'gem "fastlane"' >> Gemfile
bundle install
Fastfile
fastlane/Fastfile:
default_platform(:ios)
platform :ios do
desc "Run tests"
lane :test do
run_tests(
scheme: "MyApp",
device: "iPhone 16",
code_coverage: true
)
end
desc "Build and upload to TestFlight"
lane :beta do
# Increment build number
increment_build_number(
build_number: latest_testflight_build_number + 1
)
# Build
build_app(
scheme: "MyApp",
export_method: "app-store"
)
# Upload
upload_to_testflight(
skip_waiting_for_build_processing: true
)
# Notify
slack(
message: "New build uploaded to TestFlight!",
slack_url: ENV["SLACK_URL"]
)
end
desc "Deploy to App Store"
lane :release do
# Ensure clean git
ensure_git_status_clean
# Build
build_app(
scheme: "MyApp",
export_method: "app-store"
)
# Upload
upload_to_app_store(
submit_for_review: true,
automatic_release: true,
force: true,
precheck_include_in_app_purchases: false
)
# Tag
add_git_tag(
tag: "v#{get_version_number}"
)
push_git_tags
end
desc "Sync certificates and profiles"
lane :sync_signing do
match(
type: "appstore",
readonly: true
)
match(
type: "development",
readonly: true
)
end
desc "Take screenshots"
lane :screenshots do
capture_screenshots(
scheme: "MyAppUITests"
)
frame_screenshots(
white: true
)
end
end
Match (Code Signing)
fastlane/Matchfile:
git_url("https://github.com/yourcompany/certificates")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.app"])
username("developer@yourcompany.com")
Setup:
# Initialize
fastlane match init
# Generate certificates
fastlane match appstore
fastlane match development
Appfile
fastlane/Appfile:
app_identifier("com.yourcompany.app")
apple_id("developer@yourcompany.com")
itc_team_id("123456")
team_id("ABCDEF1234")
GitHub Actions
Basic Workflow
.github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Cache SPM
uses: actions/cache@v3
with:
path: |
~/Library/Caches/org.swift.swiftpm
.build
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
- name: Build
run: |
xcodebuild build \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
CODE_SIGNING_REQUIRED=NO
- name: Test
run: |
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult \
CODE_SIGNING_REQUIRED=NO
- name: Upload Results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: TestResults.xcresult
deploy:
needs: test
runs-on: macos-14
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install Fastlane
run: brew install fastlane
- name: Deploy to TestFlight
env:
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_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
run: fastlane beta
Code Signing in CI
- name: Import Certificate
env:
CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
security import certificate.p12 \
-k build.keychain \
-P "$CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
# Allow codesign access
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
- name: Install Provisioning Profile
env:
PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "$PROVISIONING_PROFILE_BASE64" | base64 --decode > profile.mobileprovision
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
Version Management
Automatic Versioning
# In Fastfile
lane :bump_version do |options|
# Get version from tag or parameter
version = options[:version] || git_tag_last_match(pattern: "v*").gsub("v", "")
increment_version_number(
version_number: version
)
increment_build_number(
build_number: number_of_commits
)
end
Semantic Versioning Script
#!/bin/bash
# scripts/bump-version.sh
TYPE=$1 # major, minor, patch
CURRENT=$(agvtool what-marketing-version -terse1)
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
case $TYPE in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
agvtool new-marketing-version $NEW_VERSION
echo "Version bumped to $NEW_VERSION"
Test Reporting
JUnit Format
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult
# Convert to JUnit
xcrun xcresulttool get --format json --path TestResults.xcresult > results.json
# Use xcresult-to-junit or similar tool
Code Coverage
# Generate coverage
xcodebuild test \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult
# Export coverage report
xcrun xccov view --report --json TestResults.xcresult > coverage.json
Slack Notifications
# In Fastfile
after_all do |lane|
slack(
message: "Successfully deployed to TestFlight",
success: true,
default_payloads: [:git_branch, :git_author]
)
end
error do |lane, exception|
slack(
message: "Build failed: #{exception.message}",
success: false
)
end
App Store Connect API
Key Setup
- App Store Connect > Users and Access > Keys
- Generate Key with App Manager role
- Download
.p8file
Fastlane Configuration
fastlane/Appfile:
# Use API Key instead of password
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_filepath: "./AuthKey.p8",
in_house: false
)
Upload with altool
xcrun altool --upload-app \
--type ios \
--file build/MyApp.ipa \
--apiKey $KEY_ID \
--apiIssuer $ISSUER_ID
Best Practices
Secrets Management
- Never commit secrets to git
- Use environment variables or secret managers
- Rotate keys regularly
- Use match for certificate management
Build Caching
# Cache derived data
- uses: actions/cache@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: ${{ runner.os }}-build-${{ hashFiles('**/*.swift') }}
Parallel Testing
run_tests(
devices: ["iPhone 16", "iPad Pro (12.9-inch)"],
parallel_testing: true,
concurrent_workers: 4
)
Conditional Deploys
# Only deploy on version tags
on:
push:
tags:
- 'v*'