Purpose: These are instructions for an AI assistant to integrate Fastlane into a KMP project for distributing to the App Store (TestFlight) and Google Play Store (internal testing + production). Follow every step in order. Do not skip steps.
Before writing any code, ask the user to provide the following. Explain where each item is found.
| Item | What it is | Where to find it |
|---|---|---|
| Bundle ID | Reverse-DNS app identifier, e.g. com.company.appname |
Xcode > Target > General > Bundle Identifier |
| Apple Developer Team ID | 10-character alphanumeric string, e.g. 5MF89WU7WQ |
developer.apple.com > Account > Membership > Team ID |
| App Store Connect API Key ID | Short alphanumeric key ID, e.g. 4PJL8VQ3JG |
App Store Connect > Users and Access > Integrations > App Store Connect API > Key ID column |
| App Store Connect Issuer ID | UUID, e.g. 8873b38c-775e-4a3e-baf5-4e2bada765a8 |
Same page as the Key ID — shown once at the top of the Keys tab |
.p8 key file |
Private key file downloaded from App Store Connect | Download button next to the key on the same page (one-time download — if lost, generate a new key) |
| iOS Xcode project path | Path to the .xcodeproj file relative to repo root, e.g. iosApp/iosApp.xcodeproj |
Check the iOS directory in the KMP project |
| iOS scheme name | Xcode scheme used for archiving, e.g. iosApp |
Xcode > Product > Scheme menu, or inside *.xcodeproj/xcshareddata/xcschemes/ |
| TEAM_ID location in project | Where the iOS build reads the developer team | Usually an .xcconfig file, e.g. iosApp/Configuration/Config.xcconfig |
| Item | What it is | Where to find it |
|---|---|---|
| Package name | Same as bundle ID usually, e.g. com.company.appname |
androidApp/build.gradle.kts > applicationId |
| Android Gradle module | Gradle module name, e.g. :androidApp |
Module directory name prefixed with : |
| Google Play service account JSON | A .json key file for a GCP service account |
See Step 8 below — must be created in Google Cloud Console |
Run this once and save the output. It goes into fastlane/.env later:
base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n'A KMP project typically has one of two layouts:
Multi-module (most common):
project-root/
androidApp/
build.gradle.kts ← versionCode and versionName live here
iosApp/
iosApp.xcodeproj/
Configuration/
Config.xcconfig ← TEAM_ID, MARKETING_VERSION, CURRENT_PROJECT_VERSION
composeApp/ ← shared KMP code
build.gradle.kts
Single-module (no androidApp/ wrapper):
project-root/
composeApp/
build.gradle.kts ← versionCode and versionName live here
iosApp/
iosApp.xcodeproj/
Adjust all Gradle task references (:androidApp:bundleRelease) and file paths (androidApp/build.gradle.kts) to match the actual structure. If there is no separate Android module, the task is typically :composeApp:bundleRelease and the version lives in composeApp/build.gradle.kts.
The system Ruby (2.6 on macOS) is too old. Homebrew's latest Ruby (4.x) has OpenSSL 3 incompatibilities with the googleauth gem used by Fastlane. Use Ruby 3.3.
brew install ruby@3.3Add to ~/.zshrc (or ~/.bash_profile):
export PATH="/opt/homebrew/opt/ruby@3.3/bin:$PATH"Reload: source ~/.zshrc
Verify: ruby --version should show 3.3.x.
Create .ruby-version in the repo root:
3.3.11
Create Gemfile in the repo root:
source "https://rubygems.org"
gem "fastlane"Install:
bundle installThis generates Gemfile.lock. Commit both Gemfile and Gemfile.lock — do not commit .ruby-version if it conflicts with an existing one.
fastlane/
Fastfile ← lane definitions (committed)
Appfile ← app identifiers (committed)
.env ← secrets (gitignored, never committed)
google-play-key.json ← GCP service account key (gitignored, never committed)
Replace all placeholder values with real ones:
app_identifier("com.company.appname")
package_name("com.company.appname")
apple_id(ENV["APPLE_ID"] || "")
team_id(ENV["APPLE_TEAM_ID"] || "")
itc_team_id(ENV["APP_STORE_CONNECT_TEAM_ID"] || "")This is the complete Fastfile. Adjust the constants at the top to match the project.
default_platform(:ios)
APP_IDENTIFIER = "com.company.appname"
IOS_PROJECT = "iosApp/iosApp.xcodeproj"
IOS_SCHEME = "iosApp"
ANDROID_AAB = "androidApp/build/outputs/bundle/release/androidApp-release.aab"
GOOGLE_PLAY_KEY = "fastlane/google-play-key.json"
# ---------------------------------------------------------------------------
# iOS
# ---------------------------------------------------------------------------
platform :ios do
before_all do
next unless ENV["APP_STORE_CONNECT_API_KEY_ID"]
app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
key_content: ENV["APP_STORE_CONNECT_API_KEY_CONTENT"],
is_key_content_base64: true
)
end
desc "Build and upload a new beta to TestFlight"
lane :beta do
# IMPORTANT for KMP: Bundler.with_unbundled_env is mandatory.
# See Known Issues section for why.
Bundler.with_unbundled_env do
build_app(
project: IOS_PROJECT,
scheme: IOS_SCHEME,
export_method: "app-store",
xcargs: "-allowProvisioningUpdates"
)
end
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: ENV["CHANGELOG"] || "Bug fixes and improvements"
)
end
desc "Build and upload to App Store (does not submit for review)"
lane :release do
Bundler.with_unbundled_env do
build_app(
project: IOS_PROJECT,
scheme: IOS_SCHEME,
export_method: "app-store",
xcargs: "-allowProvisioningUpdates"
)
end
upload_to_app_store(
app_identifier: APP_IDENTIFIER,
submit_for_review: false,
automatic_release: false,
skip_metadata: true,
skip_screenshots: true
)
end
end
# ---------------------------------------------------------------------------
# Android
# ---------------------------------------------------------------------------
platform :android do
desc "Build release AAB and upload to Play Store internal testing"
lane :beta do
gradle(task: ":androidApp:bundleRelease")
upload_to_play_store(
track: "internal",
aab: ANDROID_AAB,
json_key: GOOGLE_PLAY_KEY # IMPORTANT: use json_key (file path), not json_key_data (env var)
)
end
desc "Promote the latest internal build to production on Play Store"
lane :release do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
json_key: GOOGLE_PLAY_KEY
)
end
desc "Build release AAB without uploading"
lane :build do
gradle(task: ":androidApp:bundleRelease")
end
endIf the Android app has no separate module (e.g., the app is in composeApp/), change:
gradle(task: ":composeApp:bundleRelease")ANDROID_AAB = "composeApp/build/outputs/bundle/release/composeApp-release.aab"
Open iosApp/Configuration/Config.xcconfig (or wherever the iOS build settings live). Add or update:
TEAM_ID=XXXXXXXXXX
Replace XXXXXXXXXX with the Apple Developer Team ID. This is required for automatic code signing. Without it, Xcode will fail to sign with error 259.
If the project does not use an .xcconfig file, set DEVELOPMENT_TEAM directly in iosApp/iosApp.xcodeproj/project.pbxproj — but the xcconfig approach is cleaner.
Never commit this file. Create it manually:
APP_STORE_CONNECT_API_KEY_ID=XXXXXXXXXX
APP_STORE_CONNECT_API_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
APP_STORE_CONNECT_API_KEY_CONTENT=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t...
APP_STORE_CONNECT_API_KEY_CONTENTis the Base64-encoded content of the.p8file (from thebase64command in Part 1).- Fastlane reads
fastlane/.envautomatically before running any lane.
Optionally add (only needed if Appfile uses apple_id):
APPLE_ID=your@apple.com
APPLE_TEAM_ID=XXXXXXXXXX
APP_STORE_CONNECT_TEAM_ID=XXXXXXXXXX
The "Setup > API access" page was removed from the Play Console UI. Use Google Cloud Console directly.
In Google Cloud Console (console.cloud.google.com):
- Select the Firebase/GCP project linked to your app.
- Go to IAM & Admin > Service Accounts.
- Click Create Service Account.
- Name it (e.g.,
fastlane), click Create and Continue. - Grant role: Service Account User (minimal; Play API permissions come from Play Console).
- Click Done.
- Click the service account, go to Keys tab.
- Click Add Key > Create new key > JSON. Download the
.jsonfile.
In Play Console (play.google.com/console):
- Go to Users and permissions > Invite new users.
- Enter the service account email (e.g.,
fastlane@your-project.iam.gserviceaccount.com). - Grant access to the app with at least Release to production, exclude devices, and use Play App Signing permission (or "Release manager" role).
- Save.
Copy the downloaded JSON file:
cp ~/Downloads/your-project-xxxxxxxx.json fastlane/google-play-key.jsonThe Fastfile references this file as fastlane/google-play-key.json. This path is relative to the repo root. Keep it.
Add these lines to .gitignore:
.env
.env.local
fastlane/google-play-key.json
Verify these are NOT tracked:
git status fastlane/.env fastlane/google-play-key.jsonBoth should show "untracked" or not appear at all. If they are already tracked, remove them:
git rm --cached fastlane/.env fastlane/google-play-key.jsonThis script increments the build number in both Android and iOS, then runs both Fastlane lanes.
mkdir -p scriptsCreate scripts/ship.sh:
#!/bin/bash
set -e
cd "$(dirname "$0")/.."
ANDROID_BUILD="androidApp/build.gradle.kts"
IOS_CONFIG="iosApp/Configuration/Config.xcconfig"
# Read current build number and increment
CURRENT=$(grep -o 'versionCode = [0-9]*' "$ANDROID_BUILD" | grep -o '[0-9]*')
NEW=$((CURRENT + 1))
sed -i '' "s/versionCode = $CURRENT/versionCode = $NEW/" "$ANDROID_BUILD"
sed -i '' "s/CURRENT_PROJECT_VERSION=$CURRENT/CURRENT_PROJECT_VERSION=$NEW/" "$IOS_CONFIG"
# Optionally bump marketing version: ./scripts/ship.sh 1.4
if [ -n "$1" ]; then
sed -i '' "s/versionName = \"[^\"]*\"/versionName = \"$1\"/" "$ANDROID_BUILD"
sed -i '' "s/MARKETING_VERSION=.*/MARKETING_VERSION=$1/" "$IOS_CONFIG"
echo "Version: $1, build: $CURRENT → $NEW"
else
echo "Build: $CURRENT → $NEW"
fi
bundle exec fastlane android beta
bundle exec fastlane ios betaMake it executable:
chmod +x scripts/ship.shIf there is no separate Android module, change ANDROID_BUILD to point to composeApp/build.gradle.kts.
If the iOS project does not use an xcconfig, omit the CURRENT_PROJECT_VERSION sed line and handle iOS versioning separately (e.g., via increment_build_number in the Fastfile).
Android only:
bundle exec fastlane android betaiOS only:
bundle exec fastlane ios betaBoth (with version bump):
./scripts/ship.sh # bump build number only
./scripts/ship.sh 1.4 # bump build number and set version name to 1.4Gitignored files (fastlane/.env and fastlane/google-play-key.json) are not present in git worktrees. Running Fastlane in a worktree will fail with auth errors.
Option A — Copy from main checkout before running:
cp /path/to/main-checkout/fastlane/.env fastlane/
cp /path/to/main-checkout/fastlane/google-play-key.json fastlane/Option B — Store credentials outside the repo (recommended for frequent worktree use):
Store both files at a stable absolute path, e.g. ~/.config/fastlane/your-app-name/.
Update GOOGLE_PLAY_KEY in the Fastfile:
GOOGLE_PLAY_KEY = File.expand_path("~/.config/fastlane/your-app-name/google-play-key.json")For the .env file, load it explicitly in before_all:
Dotenv.load(File.expand_path("~/.config/fastlane/your-app-name/.env"))Or simply set the three APP_STORE_CONNECT_* env vars in your shell profile.
scripts/ship.sh in a worktree: The script uses relative paths from the repo root and calls bundle exec fastlane, so it works in any worktree as long as the credential files are present.
Symptom: iOS build fails during the Kotlin framework compilation phase with:
Could not find fastlane-X.X.X, ... in locally installed gems (Bundler::GemNotFound)
Root cause: When Fastlane runs via bundle exec, it sets BUNDLE_GEMFILE, BUNDLE_PATH, and other Bundler environment variables. These inherit into xcodebuild's subprocess environment. KMP iOS projects have a build phase ("Compile Kotlin Framework") that runs Gradle. Gradle spawns Ruby processes, and Bundler's auto-discovery finds the project's Gemfile (by walking up the directory tree). Bundler then tries to load all Fastlane gems in the wrong context and fails.
Fix: Wrap every build_app call in Bundler.with_unbundled_env. This is the official Bundler API that strips all Bundler environment variables from the subprocess environment:
lane :beta do
Bundler.with_unbundled_env do
build_app(
project: IOS_PROJECT,
scheme: IOS_SCHEME,
export_method: "app-store",
xcargs: "-allowProvisioningUpdates"
)
end
upload_to_testflight(...)
endDo not simply delete Bundler env vars with ENV.delete(...) before calling build_app. That is insufficient because Bundler's Gemfile auto-discovery (walking up the directory tree) still finds the Gemfile in the project root.
Symptom: iOS build fails at the xcodebuild -resolvePackageDependencies step with:
"SomeFirebasePackage" couldn't be removed because you don't have permission to access it.
fatal: destination path '...firebase-ios-sdk-xxxxxxxx' already exists and is not an empty directory.
Exit status: 74
Root cause: Multiple DerivedData directories accumulate from prior builds. Some files within them become owned by root (from sudo-based operations), making them undeletable by the normal user.
Fix: Delete all stale DerivedData directories for the project with sudo:
sudo rm -rf ~/Library/Developer/Xcode/DerivedData/iosApp-*Replace iosApp with the actual scheme/project name. The next build will create a fresh DerivedData directory.
To avoid recurrence: Never run xcodebuild or Fastlane with sudo. All build operations should run as the regular user.
Symptom: Android upload fails with:
OpenSSL::PKey::RSAError: Neither PUB key nor PRIV key
Root cause: googleauth gem versions up to at least 1.17.0 use OpenSSL::PKey::RSA.new(private_key) to parse the service account's private key. Under OpenSSL 3.x (default on macOS with Homebrew Ruby), RSA keys in PKCS#8 format (which Google issues) require OpenSSL::PKey.read() instead.
Primary fix (strongly preferred): Use json_key (a file path) instead of json_key_data (the JSON content as a string/env var) in upload_to_play_store. The file path avoids the OpenSSL parsing entirely:
upload_to_play_store(
track: "internal",
aab: ANDROID_AAB,
json_key: "fastlane/google-play-key.json" # correct
# json_key_data: ENV["GOOGLE_PLAY_JSON"] # do not use — triggers the bug
)Secondary cause (avoid this also): Even if using json_key_data, loading complex JSON values via dotenv into env vars corrupts the private key through newline escaping. Another reason to prefer the file approach.
Fallback fix if you must use json_key_data: Patch the installed gem (not recommended for production — gem updates will revert it):
# In /path/to/gems/googleauth-X.X.X/lib/googleauth/service_account.rb
# Change:
OpenSSL::PKey::RSA.new(private_key)
# To:
OpenSSL::PKey.read(private_key)Find the gem path: bundle exec gem contents googleauth | grep service_account
scripts/ship.sh calls bundle exec fastlane android beta and then bundle exec fastlane ios beta from a plain shell. This works correctly — the outer call is from the shell (not from inside an existing bundle exec), so there is no nesting. The Bundler.with_unbundled_env wrapper in the Fastfile handles the inner xcodebuild subprocess for iOS.
Do not add bundle exec nesting (e.g., bundle exec ./scripts/ship.sh) — invoke the script directly.
| Action | Command |
|---|---|
| Upload Android to internal testing | bundle exec fastlane android beta |
| Upload iOS to TestFlight | bundle exec fastlane ios beta |
| Promote Android internal → production | bundle exec fastlane android release |
| Upload iOS to App Store (no submit) | bundle exec fastlane ios release |
| Build Android AAB only | bundle exec fastlane android build |
| Bump build + ship both | ./scripts/ship.sh |
| Bump version + build + ship both | ./scripts/ship.sh 1.4 |
.ruby-version
Gemfile
Gemfile.lock
fastlane/Fastfile
fastlane/Appfile
scripts/ship.sh
fastlane/.env
fastlane/google-play-key.json