Automating iOS Builds with GitHub Actions and Fastlane: A Complete Guide

In this post, I’ll walk you through how I set up a CI/CD pipeline for an iOS project using GitHub Actions and Fastlane, complete with code signing, TestFlight deployment, and build artifact uploads on failure. This setup ensures every push to main or develop runs a full build and automatically ships to TestFlight β€” saving time and reducing human error.


πŸ“¦ Project Overview

  • Platform: iOS (React Native project)
  • CI: GitHub Actions
  • Distribution: TestFlight
  • Signing: Manual (certificate + provisioning profile)
  • Tooling: Fastlane, CocoaPods, npm, Xcode CLI

πŸ›  Prerequisites

Before setting this up, make sure:

  • Your app builds locally via Xcode.
  • You have an Apple Developer account.
  • You’ve exported and base64-encoded the necessary credentials (more below).

πŸ” GitHub Secrets Setup

You’ll need to add these secrets in your GitHub repo:

Secret NameDescription
APP_STORE_ISSUER_IDIssuer ID from App Store Connect
APP_STORE_KEY_IDKey ID for API Key
APP_STORE_PRIVATE_KEYBase64-encoded .p8 API Key
CERTIFICATES_FILE_BASE64Base64-encoded .p12 signing certificate
CERTIFICATES_PASSWORDPassword for the .p12 certificate
PROVISIONING_PROFILE_BASE64Base64-encoded .mobileprovision profile
DEVELOPMENT_TEAMYour Apple Developer Team ID

βœ… Step 1: Create a Certificate Signing Request (CSR)

We’ll generate a .certSigningRequest file via Keychain Access.

Steps:

  1. Open Keychain Access.
  2. Top menu β†’ Keychain Access β†’ Certificate Assistant β†’
    Request a Certificate from a Certificate Authority…
  3. Fill in your email address and select Saved to disk.
  4. Click Continue and save the file (e.g., certificate.csr) to your local machine.

πŸ” Step 2: Create Apple Distribution Certificate

Steps:

  1. Go to Apple Developer Certificates
  2. Click the “+” icon to create a new certificate.
  3. Select “Apple Distribution” β†’ Click Continue.
  4. Upload the .certSigningRequest file from the previous step.
  5. Click Generate β†’ Download the .cer file.

🧾 Step 3: Export .p12 Certificate from Keychain

Steps:

  1. Double-click the downloaded .cer file β€” it will open in Keychain Access.
  2. In Keychain Access, go to Certificates β†’ Find the certificate you just added.
  3. Right-click the certificate β†’ Export.
  4. Save it as a .p12 file, and add a password when prompted.


πŸ— Step 4: Create App Store Connect API Key

This is used for Fastlane to interact with App Store Connect (e.g., for uploads).

Steps:

  1. Go to App Store Connect β†’
    Users & Access β†’ Keys tab under App Store Connect API.
  2. Click “+” to generate a new key.
  3. Fill in:
    • Name: e.g., fastlane-key
    • Access: Select App Manager
  4. Click Generate and download the .p8 file.

Also, note the following values (you’ll need them later):

  • Issuer ID
  • Key ID

πŸ“¦ Step 5: Get App Bundle ID

Steps:

  1. In App Store Connect β†’ Go to your app.
  2. Under General > App Information, find your Bundle ID.

πŸ“„ Step 6: Create Provisioning Profile

Steps:

  1. Go to Apple Developer Profiles
  2. Click “+” to create a new provisioning profile.
  3. Choose iOS App Development β†’ Click Continue.
  4. Select your App ID (Bundle ID) β†’ Click Continue.
  5. Select the certificate you created earlier.
  6. Select devices (for dev builds).
  7. Name the profile β†’ Click Generate β†’ Download the .mobileprovision file.

πŸ” Step 7: Convert Certificates to Base64

You now have:

  • certificate.p12
  • AuthKey_XXXX.p8
  • YourApp.mobileprovision

Convert them to base64:

πŸ“ Project Structure

In your Gemfile:

gem 'fastlane', '~> 2.217'

Here’s the simplified overview of the Fastfile logic:

default_platform(:ios)

platform :ios do
  desc "Build and sign the app for release"
  lane :build_release do
    # Debug information
    puts "πŸ” Debug Information:"
    puts "Working directory: #{Dir.pwd}"
    puts "Xcode project path: #{File.expand_path('../App/App.xcodeproj')}"
    puts "Xcode project exists: #{File.exist?('../App/App.xcodeproj')}"
    puts "Development Team: #{ENV['DEVELOPMENT_TEAM']}"
    puts "App Store Key ID: #{ENV['APP_STORE_KEY_ID']}"
    puts "App Store Issuer ID: #{ENV['APP_STORE_ISSUER_ID']}"
    
    app_store_connect_api_key(
      key_id: ENV["APP_STORE_KEY_ID"],
      issuer_id: ENV["APP_STORE_ISSUER_ID"],
      key_filepath: File.expand_path("~/.private_keys/AuthKey_#{ENV['APP_STORE_KEY_ID']}.p8"),
      in_house: false
    )

    # Verify Xcode project exists before proceeding
    unless File.exist?('../App/App.xcodeproj')
      UI.user_error!("❌ Xcode project not found at ../App/App.xcodeproj")
    end
    
    puts "βœ… Xcode project found, updating code signing settings..."
    
    # Verify provisioning profile exists
    provisioning_profile_path = File.expand_path("~/Library/MobileDevice/Provisioning Profiles/Amanda.mobileprovision")
    unless File.exist?(provisioning_profile_path)
      UI.user_error!("❌ Provisioning profile not found at #{provisioning_profile_path}")
    end
    puts "βœ… Provisioning profile found"
    
    # Debug certificate information
    puts "πŸ” Available certificates:"
    sh("security find-identity -v -p codesigning build.keychain")
    
    # Debug provisioning profile details
    puts "πŸ” Provisioning profile details:"
    sh("security cms -D -i ~/Library/MobileDevice/Provisioning\\ Profiles/Amanda.mobileprovision | grep -E '(Name|TeamName|TeamIdentifier|AppIDName|DeveloperCertificates)'")
    
    # Verify scheme exists
    begin
      sh("cd ../App && xcodebuild -project App.xcodeproj -scheme App -list")
      puts "βœ… Scheme exists and is accessible"
    rescue => e
      puts "⚠️  Could not verify scheme: #{e.message}"
      puts "This might be normal if the scheme doesn't exist yet"
    end
    
    update_code_signing_settings(
      use_automatic_signing: false,
      team_id: ENV["DEVELOPMENT_TEAM"],
      code_sign_identity: "Apple Distribution",
      profile_name: "Amanda", # Make sure this matches your provisioning profile name
      path: "/Users/runner/work/hvac-cool-quotes/hvac-cool-quotes/ios/App/App.xcodeproj" # Full absolute path
    )

    # Build number is managed manually in project.pbxproj
    # Update CURRENT_PROJECT_VERSION before each build

    build_app(
      workspace: "App/App.xcworkspace",
      scheme: "App",
      configuration: "Release",
      export_method: "app-store",
      export_options: {
        teamID: ENV["DEVELOPMENT_TEAM"],
        method: "app-store",
        signingStyle: "manual",
        provisioningProfiles: {
          "app.lovable.IosBuild" => "Amanda" # Ensure this matches the provisioning profile name
        }
      },
      clean: true,
      codesigning_identity: "Apple Distribution"
    )
  end

  desc "Build and sign the app for debugging"
  lane :build_debug do
    update_code_signing_settings(
      use_automatic_signing: false,
      team_id: ENV["DEVELOPMENT_TEAM"],
      code_sign_identity: "Apple Distribution",
      profile_name: "Amanda",
      path: "/Users/runner/work/hvac-cool-quotes/hvac-cool-quotes/ios/App/App.xcodeproj" # Full absolute path
    )

    build_app(
      workspace: "App/App.xcworkspace",
      scheme: "App",
      configuration: "Debug",
      export_method: "development",
      export_options: {
        teamID: ENV["DEVELOPMENT_TEAM"],
        method: "development",
        signingStyle: "manual",
        provisioningProfiles: {
          "app.lovable.IosBuild" => "Amanda"
        }
      },
      clean: true,
      codesigning_identity: "Apple Distribution"
    )
  end

  desc "Submit to TestFlight"
  lane :beta do
    build_release
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      skip_submission: true,
      changelog: "Bug fixes and improvements"
    )
  end

  desc "Submit to App Store"
  lane :release do
    build_release
    upload_to_app_store
  end

  error do |lane, exception|
    puts "❌ Error in lane #{lane}: #{exception.message}"
  end
end

Here’s the simplified overview of the Appfile logic:


# Your app's bundle identifier - MUST match your provisioning profile
app_identifier("org.reactjs.native.example.CrisisApplication")

# Your Apple Developer Team ID
team_id(ENV["DEVELOPMENT_TEAM"])

# For App Store Connect API (recommended)
# No need to specify apple_id when using API key

πŸ§ͺ GitHub Actions Workflow

Here’s what the workflow does:

  • Triggers on push to main/develop or on PRs to main
  • Sets up Node.js and Ruby
  • Installs dependencies via npm and bundle
  • Sets up Xcode
  • Installs CocoaPods dependencies
  • Decodes and installs Apple API keys and certs
  • Builds and deploys via fastlane beta
  • Uploads logs if the build fails
  • Cleans up sensitive artifacts after execution

name: iOS Build and Deploy to TestFlight

on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
workflow_dispatch:

jobs:
build-and-deploy:
runs-on: macos-latest

name: iOS Build and Deploy to TestFlight

on:
  push:
    branches: [ build ]

jobs:
  build-and-deploy:
    runs-on: macos-latest
    
    steps:
    - name: Checkout Repository
      uses: actions/checkout@v4
      
    - name: Setup Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: '3.0'
        bundler-cache: true
        
    - name: Install JS Dependencies
      run: |
        npm ci
        
    - name: Build Web Assets
      run: |
        npm run build
        
    - name: Install Ruby Dependencies
      run: |
        bundle install
        bundle exec fastlane --version
        
    - name: Setup Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: latest-stable
        
    - name: Install CocoaPods Dependencies
      working-directory: ios/App
      run: |
        bundle exec pod install --repo-update
        
    - name: Sync Web Assets to iOS
      run: |
        npx cap sync ios
        
    - name: Reinstall CocoaPods Dependencies
      working-directory: ios/App
      run: |
        bundle exec pod deintegrate
        bundle exec pod install --repo-update
        
    - name: Clean Xcode Project
      working-directory: ios/App
      run: |
        xcodebuild clean -workspace App.xcworkspace -scheme App
        
    - name: Verify Workspace and Pods
      working-directory: ios/App
      run: |
        echo "=== Checking workspace ==="
        ls -la App.xcworkspace/
        echo "=== Checking Pods ==="
        ls -la Pods/
        echo "=== Checking Capacitor in Pods ==="
        find Pods/ -name "*Capacitor*" -type d
        
    - name: Create App Store Connect API Key
      run: |
        mkdir -p ~/.private_keys
        echo "${{ secrets.APP_STORE_PRIVATE_KEY }}" | base64 --decode > ~/.private_keys/AuthKey_${{ secrets.APP_STORE_KEY_ID }}.p8
        chmod 600 ~/.private_keys/AuthKey_${{ secrets.APP_STORE_KEY_ID }}.p8
        
    - name: Setup Keychain and Import Certificates
      env:
        CERTIFICATES_FILE_BASE64: ${{ secrets.CERTIFICATES_FILE_BASE64 }}
        CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }}
        KEYCHAIN_PASSWORD: "tempPass1234"
      run: |
        echo "Creating temporary keychain..."
        security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
        security set-keychain-settings -t 3600 -u build.keychain
        security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
        security default-keychain -s build.keychain
    
        echo "Decoding certificate from base64..."
        echo "$CERTIFICATES_FILE_BASE64" | base64 --decode > cert.p12
    
        echo "Checking certificate file..."
        ls -l cert.p12
        file cert.p12
    
        echo "Importing certificate into keychain..."
        security import cert.p12 -k build.keychain -P "$CERTIFICATES_PASSWORD" -A -T /usr/bin/codesign
    
        echo "Setting key partition list..."
        security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
    
        echo "Listing keychains and identities..."
        security list-keychains -s build.keychain login.keychain
        security find-identity -v -p codesigning build.keychain

    - name: Import Provisioning Profile
      run: |
        mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
        echo "${{ secrets.PROVISIONING_PROFILE_BASE64 }}" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/Amanda.mobileprovision
    
        # Verify provisioning profile
        echo "=== Provisioning Profile Details ==="
        security cms -D -i ~/Library/MobileDevice/Provisioning\ Profiles/Amanda.mobileprovision | grep -E "(Name|TeamName|TeamIdentifier|AppIDName)" || echo "Could not extract profile details"
        
        # List all provisioning profiles
        echo "=== All Provisioning Profiles ==="
        ls -la ~/Library/MobileDevice/Provisioning\ Profiles/

    - name: Debug Working Directory and Structure
      run: |
        echo "=== Current working directory ==="
        pwd
        echo "=== Root directory contents ==="
        ls -la
        echo "=== iOS directory structure ==="
        ls -la ios/
        echo "=== iOS/App directory structure ==="
        ls -la ios/App/
        echo "=== Fastlane directory structure ==="
        ls -la ios/fastlane/
        echo "=== Xcode project exists ==="
        [ -d "ios/App/App.xcodeproj" ] && echo "βœ… Xcode project found" || echo "❌ Xcode project not found"

    - name: Verify Xcode Project Path
      run: |
        echo "Checking for App.xcodeproj in ios/App/"
        if [ ! -d "ios/App/App.xcodeproj" ]; then
          echo "❌ Xcode project not found at ios/App/App.xcodeproj"
          echo "Available files in ios/App/:"
          ls -la ios/App/
          exit 1
        fi
        echo "βœ… Xcode project found!"
        
    - name: Verify Xcode Project Structure
      working-directory: ios
      run: |
        echo "=== Xcode Project Structure ==="
        ls -la App/
        echo "=== Xcode Project Contents ==="
        ls -la App/App.xcodeproj/
        
    - name: Create Xcode Scheme
      working-directory: ios/App
      run: |
        echo "Creating Xcode scheme..."
        xcodebuild -project App.xcodeproj -scheme App -list || echo "Scheme might not exist, will be created during build"
        
    - name: Build and Deploy to TestFlight
      working-directory: ios
      env:
        APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }}
        APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}
        DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
        FASTLANE_DISABLE_COLORS: 1
        FASTLANE_SKIP_UPDATE_CHECK: 1
        MATCH_KEYCHAIN_NAME: build.keychain
        MATCH_KEYCHAIN_PASSWORD: ""
      run: |
        echo "=== Starting Fastlane build ==="
        echo "Working directory: $(pwd)"
        echo "Fastlane version:"
        bundle exec fastlane --version
        
        # Debug environment variables
        echo "=== Environment Variables ==="
        echo "DEVELOPMENT_TEAM: $DEVELOPMENT_TEAM"
        echo "APP_STORE_KEY_ID: $APP_STORE_KEY_ID"
        echo "APP_STORE_ISSUER_ID: $APP_STORE_ISSUER_ID"
        
        # Ensure Fastlane is up-to-date
        bundle exec fastlane update_fastlane
        
        # Run Fastlane beta lane
        bundle exec fastlane beta
        
    - name: Upload Xcode Build Log (if failed)
      if: failure()
      run: |
        echo "=== Checking for build logs ==="
        if [ -f ~/Library/Logs/gym/app.lovable.IosBuild.log ]; then
          echo "=== BUILD LOG ==="
          cat ~/Library/Logs/gym/app.lovable.IosBuild.log
        else
          echo "No build log found at expected location"
          echo "Searching for any log files:"
          find ~/Library/Logs -name "*.log" -type f 2>/dev/null || true
        fi
        
    - name: Clean up
      if: always()
      run: |
        echo "Cleaning up sensitive data..."
        rm -rf ~/.private_keys
        rm -rf ~/certificates
        rm -f cert.p12
        security delete-keychain build.keychain || true
        
    - name: Upload Build Artifacts (if failed)
      if: failure()
      uses: actions/upload-artifact@v4
      with:
        name: build-logs
        path: |
          ~/Library/Logs/gym/
          ios/fastlane/logs/
          *.log

πŸ§ͺ Testing the Pipeline

You can manually trigger the workflow from GitHub’s Actions tab using workflow_dispatch. Or, push a commit to main or develop to see it run automatically.

If something goes wrong, check the uploaded artifacts for logs under the β€œbuild-logs” section.


βœ… Summary

By combining Fastlane with GitHub Actions, I now have:

  • Fully automated iOS build + TestFlight deploy
  • Secure secrets management
  • Logs and artifacts for easy debugging
  • Manual code signing setup that works reliably

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top