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 Name | Description |
---|---|
APP_STORE_ISSUER_ID | Issuer ID from App Store Connect |
APP_STORE_KEY_ID | Key ID for API Key |
APP_STORE_PRIVATE_KEY | Base64-encoded .p8 API Key |
CERTIFICATES_FILE_BASE64 | Base64-encoded .p12 signing certificate |
CERTIFICATES_PASSWORD | Password for the .p12 certificate |
PROVISIONING_PROFILE_BASE64 | Base64-encoded .mobileprovision profile |
DEVELOPMENT_TEAM | Your Apple Developer Team ID |
β Step 1: Create a Certificate Signing Request (CSR)
We’ll generate a
.certSigningRequest
file via Keychain Access.
Steps:
- Open Keychain Access.
- Top menu β Keychain Access β Certificate Assistant β
Request a Certificate from a Certificate Authority… - Fill in your email address and select Saved to disk.
- Click Continue and save the file (e.g.,
certificate.csr
) to your local machine.



π Step 2: Create Apple Distribution Certificate
Steps:
- Go to Apple Developer Certificates
- Click the “+” icon to create a new certificate.
- Select “Apple Distribution” β Click Continue.
- Upload the
.certSigningRequest
file from the previous step. - Click Generate β Download the
.cer
file.



π§Ύ Step 3: Export .p12
Certificate from Keychain
Steps:
- Double-click the downloaded
.cer
file β it will open in Keychain Access. - In Keychain Access, go to Certificates β Find the certificate you just added.
- Right-click the certificate β Export.
- 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:
- Go to App Store Connect β
Users & Access β Keys tab under App Store Connect API. - Click “+” to generate a new key.
- Fill in:
- Name: e.g.,
fastlane-key
- Access: Select App Manager
- Name: e.g.,
- 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:
- In App Store Connect β Go to your app.
- Under General > App Information, find your Bundle ID.

π Step 6: Create Provisioning Profile
Steps:
- Go to Apple Developer Profiles
- Click “+” to create a new provisioning profile.
- Choose iOS App Development β Click Continue.
- Select your App ID (Bundle ID) β Click Continue.
- Select the certificate you created earlier.
- Select devices (for dev builds).
- 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
tomain
/develop
or on PRs tomain
- Sets up Node.js and Ruby
- Installs dependencies via
npm
andbundle
- 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