# CI/CD Xcode Cloud, fastlane, and automated testing and deployment. ## Xcode Cloud ### Setup 1. Enable in Xcode: Product > Xcode Cloud > Create Workflow 2. Configure in App Store Connect ### Basic Workflow ```yaml # 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`: ```bash #!/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`: ```bash #!/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_URL` - `SENTRY_DSN` - Secrets (automatically masked) Access in build: ```swift let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String ``` ## Fastlane ### Installation ```bash # Install brew install fastlane # Or via bundler bundle init echo 'gem "fastlane"' >> Gemfile bundle install ``` ### Fastfile `fastlane/Fastfile`: ```ruby 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`: ```ruby git_url("https://github.com/yourcompany/certificates") storage_mode("git") type("appstore") app_identifier(["com.yourcompany.app"]) username("developer@yourcompany.com") ``` Setup: ```bash # Initialize fastlane match init # Generate certificates fastlane match appstore fastlane match development ``` ### Appfile `fastlane/Appfile`: ```ruby 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`: ```yaml 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 ```yaml - 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 ```ruby # 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 ```bash #!/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 ```bash 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 ```bash # Generate coverage xcodebuild test \ -enableCodeCoverage YES \ -resultBundlePath TestResults.xcresult # Export coverage report xcrun xccov view --report --json TestResults.xcresult > coverage.json ``` ### Slack Notifications ```ruby # 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 1. App Store Connect > Users and Access > Keys 2. Generate Key with App Manager role 3. Download `.p8` file ### Fastlane Configuration `fastlane/Appfile`: ```ruby # 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 ```bash 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 ```yaml # 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 ```ruby run_tests( devices: ["iPhone 16", "iPad Pro (12.9-inch)"], parallel_testing: true, concurrent_workers: 4 ) ``` ### Conditional Deploys ```yaml # Only deploy on version tags on: push: tags: - 'v*' ```