Mobile CI/CD is harder than web CI/CD: code signing certificates, provisioning profiles, and app store submission requirements create friction that slows down releases. Fastlane automates the mechanical work — code signing, building, uploading to TestFlight — while GitHub Actions provides the trigger and environment. Claude Code generates Fastlane lanes and GitHub Actions workflows that handle both iOS and Android.
This guide covers mobile CI/CD with Claude Code: Fastlane setup, GitHub Actions workflows, code signing, and beta/production release automation.
Fastlane Setup
CLAUDE.md for Mobile Projects
## React Native / Mobile CI/CD
- Platform: iOS 17+ / Android API 33+
- Framework: React Native 0.73+
- CI: GitHub Actions (macOS-14 for iOS, ubuntu-latest for Android)
- Distribution: Fastlane for both platforms
- Beta: TestFlight (iOS), Firebase App Distribution (Android)
- Production: App Store Connect (iOS), Google Play (Android)
## Fastlane conventions
- Appfile: contains app identifiers and team IDs
- Fastfile: lanes organized by platform + shared lanes
- Environment: secrets in GitHub Actions secrets, loaded via dotenv in local dev
- Match: handles iOS code signing (preferred over manual cert management)
- Versioning: bump version and build number in lane (not manual)
## Release process
- Feature branch → PR → auto-deploy to beta on merge to main
- Production release: tag v* → automatic App Store/Play Store submission
# fastlane/Appfile
app_identifier(ENV["APP_IDENTIFIER"] || "com.mycompany.myapp")
apple_id(ENV["APPLE_ID"])
team_id(ENV["TEAM_ID"])
itc_team_id(ENV["ITC_TEAM_ID"])
iOS Fastfile
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
before_all do
setup_ci if ENV['CI'] # Configure keychain for CI environment
end
desc "Sync code signing certificates"
lane :certificates do
match(
type: "appstore",
readonly: ENV['CI'], # CI reads existing certs; local can create
git_url: ENV['MATCH_GIT_URL'],
git_branch: "main",
app_identifier: ENV['APP_IDENTIFIER'],
)
end
desc "Run tests"
lane :test do
run_tests(
scheme: "MyApp",
devices: ["iPhone 15 Pro"],
reset_simulator: true,
)
end
desc "Build and submit to TestFlight"
lane :beta do
certificates
# Increment build number based on TestFlight's latest
build_number = latest_testflight_build_number + 1
increment_build_number(
xcodeproj: "ios/MyApp.xcodeproj",
build_number: build_number,
)
gym(
scheme: "MyApp",
workspace: "ios/MyApp.xcworkspace",
configuration: "Release",
export_method: "app-store",
output_directory: "ios/build",
output_name: "MyApp.ipa",
clean: true,
)
upload_to_testflight(
ipa: "ios/build/MyApp.ipa",
skip_waiting_for_build_processing: true, # Don't wait — proceed in CI
changelog: ENV['CHANGELOG'] || changelog_from_git_commits,
)
# Notify Slack/Discord on success
if ENV['SLACK_WEBHOOK_URL']
slack(
message: "✅ iOS beta build #{build_number} submitted to TestFlight",
webhook_url: ENV['SLACK_WEBHOOK_URL'],
)
end
end
desc "Submit to App Store"
lane :release do
certificates
# Get version from tag (e.g., "v2.3.1" → "2.3.1")
version = ENV['RELEASE_VERSION']&.delete_prefix('v')
increment_version_number(
xcodeproj: "ios/MyApp.xcodeproj",
version_number: version,
)
increment_build_number(
xcodeproj: "ios/MyApp.xcodeproj",
build_number: ENV['BUILD_NUMBER'] || Time.now.strftime('%Y%m%d%H%M'),
)
gym(
scheme: "MyApp",
workspace: "ios/MyApp.xcworkspace",
configuration: "Release",
export_method: "app-store",
)
upload_to_app_store(
submit_for_review: true,
automatic_release: false, # Wait for manual App Store review approval
force: true, # Skip interactive confirmation
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false,
},
)
end
end
Android Fastfile
platform :android do
desc "Build and submit to Firebase App Distribution"
lane :beta do
# Increment version code
version_code = google_play_track_version_codes(track: 'internal').first.to_i + 1
gradle(
task: "bundle",
build_type: "Release",
project_dir: "android/",
properties: {
"android.injected.signing.store.file" => ENV['KEYSTORE_FILE'],
"android.injected.signing.store.password" => ENV['KEYSTORE_PASSWORD'],
"android.injected.signing.key.alias" => ENV['KEY_ALIAS'],
"android.injected.signing.key.password" => ENV['KEY_PASSWORD'],
"versionCode" => version_code,
},
)
firebase_app_distribution(
app: ENV['FIREBASE_APP_ID_ANDROID'],
groups: "internal-testers, qa-team",
release_notes: ENV['CHANGELOG'] || changelog_from_git_commits,
service_credentials_file: ENV['FIREBASE_SERVICE_ACCOUNT_FILE'],
)
end
desc "Submit to Google Play"
lane :release do
gradle(
task: "bundle",
build_type: "Release",
project_dir: "android/",
properties: {
"android.injected.signing.store.file" => ENV['KEYSTORE_FILE'],
"android.injected.signing.store.password" => ENV['KEYSTORE_PASSWORD'],
"android.injected.signing.key.alias" => ENV['KEY_ALIAS'],
"android.injected.signing.key.password" => ENV['KEY_PASSWORD'],
},
)
upload_to_play_store(
track: 'internal', # internal → alpha → beta → production
aab: "android/app/build/outputs/bundle/release/app-release.aab",
)
end
end
GitHub Actions Workflows
Run tests on every PR, deploy to beta on merge to main,
and release to production when a version tag is pushed.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Install CocoaPods
run: cd ios && pod install --repo-update
- name: Run iOS tests
run: bundle exec fastlane ios test
test-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run Android unit tests
run: cd android && ./gradlew test
# .github/workflows/beta.yml
name: Beta Release
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
jobs:
beta-ios:
runs-on: macos-14
environment: beta # Requires approval in GitHub Environments
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # For changelog generation
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Install CocoaPods
run: cd ios && pod install
- name: Deploy to TestFlight
run: bundle exec fastlane ios beta
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ITC_TEAM_ID: ${{ secrets.APPLE_ITC_TEAM_ID }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
beta-android:
runs-on: ubuntu-latest
environment: beta
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/keystore.jks
- name: Deploy to Firebase App Distribution
run: bundle exec fastlane android beta
env:
KEYSTORE_FILE: android/keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
FIREBASE_APP_ID_ANDROID: ${{ secrets.FIREBASE_APP_ID_ANDROID }}
FIREBASE_SERVICE_ACCOUNT_FILE: ${{ runner.temp }}/service-account.json
# .github/workflows/release.yml
name: Production Release
on:
push:
tags:
- 'v*' # e.g., v2.3.1
jobs:
release-ios:
runs-on: macos-14
environment: production # Requires manual approval
steps:
- uses: actions/checkout@v4
# ... same setup as beta ...
- name: Submit to App Store
run: bundle exec fastlane ios release
env:
RELEASE_VERSION: ${{ github.ref_name }}
# ... same secrets as beta ...
Certificate Management with Match
Our iOS cert management is chaotic — different developers
have different certificates installed. Set up match.
# Initialize match (run once, creates the certificate repo)
bundle exec fastlane match init
# Prompts for: Git URL for certificate repo, app identifier
# Generate and store certificates
bundle exec fastlane match appstore # Production cert
bundle exec fastlane match development # Development cert
# Now all developers run:
bundle exec fastlane match development --readonly
# Downloads and installs certificates from the shared Git repo
Match encrypts certificates with a passphrase and stores them in a private Git repository. CI uses readonly: true (ENV[‘CI’]) — it only downloads existing certs, never creates new ones. This prevents CI from consuming certificate slots.
For the React Native code that runs in these mobile apps, see the React Native guide. For E2E testing of mobile apps using Detox before the CI/CD pipeline deploys them, see the mobile testing guide. The Claude Skills 360 bundle includes mobile CI/CD skill sets for Fastlane and App Store automation. Start with the free tier to generate Fastlane lanes for your mobile project.