Skip to content

Instantly share code, notes, and snippets.

@Sirelon
Last active June 11, 2026 14:18
Show Gist options
  • Select an option

  • Save Sirelon/5ef32d817c174d8934562fff88eab498 to your computer and use it in GitHub Desktop.

Select an option

Save Sirelon/5ef32d817c174d8934562fff88eab498 to your computer and use it in GitHub Desktop.
Setup Fastlane doc for AI and human

Fastlane Setup for Kotlin Multiplatform (KMP) Projects

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.


Part 1 — Information to Gather Before Starting

Before writing any code, ask the user to provide the following. Explain where each item is found.

iOS

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

Android

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

Encode the .p8 key file to Base64

Run this once and save the output. It goes into fastlane/.env later:

base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n'

Part 2 — Project Structure Overview

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.


Part 3 — Step-by-Step Integration

Step 1 — Install Ruby 3.3

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.3

Add 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.


Step 2 — Add .ruby-version and Gemfile to the project root

Create .ruby-version in the repo root:

3.3.11

Create Gemfile in the repo root:

source "https://rubygems.org"
gem "fastlane"

Install:

bundle install

This generates Gemfile.lock. Commit both Gemfile and Gemfile.lock — do not commit .ruby-version if it conflicts with an existing one.


Step 3 — Create the fastlane/ directory and its files

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)

Step 4 — Write fastlane/Appfile

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"] || "")

Step 5 — Write fastlane/Fastfile

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
end

If 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"

Step 6 — Set TEAM_ID in the iOS project

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.


Step 7 — Create fastlane/.env (secrets file)

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_CONTENT is the Base64-encoded content of the .p8 file (from the base64 command in Part 1).
  • Fastlane reads fastlane/.env automatically 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

Step 8 — Create a Google Play service account

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):

  1. Select the Firebase/GCP project linked to your app.
  2. Go to IAM & Admin > Service Accounts.
  3. Click Create Service Account.
  4. Name it (e.g., fastlane), click Create and Continue.
  5. Grant role: Service Account User (minimal; Play API permissions come from Play Console).
  6. Click Done.
  7. Click the service account, go to Keys tab.
  8. Click Add Key > Create new key > JSON. Download the .json file.

In Play Console (play.google.com/console):

  1. Go to Users and permissions > Invite new users.
  2. Enter the service account email (e.g., fastlane@your-project.iam.gserviceaccount.com).
  3. Grant access to the app with at least Release to production, exclude devices, and use Play App Signing permission (or "Release manager" role).
  4. Save.

Copy the downloaded JSON file:

cp ~/Downloads/your-project-xxxxxxxx.json fastlane/google-play-key.json

The Fastfile references this file as fastlane/google-play-key.json. This path is relative to the repo root. Keep it.


Step 9 — Update .gitignore

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.json

Both should show "untracked" or not appear at all. If they are already tracked, remove them:

git rm --cached fastlane/.env fastlane/google-play-key.json

Step 10 — Create scripts/ship.sh (version bump + ship)

This script increments the build number in both Android and iOS, then runs both Fastlane lanes.

mkdir -p scripts

Create 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 beta

Make it executable:

chmod +x scripts/ship.sh

If 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).


Step 11 — Run the lanes

Android only:

bundle exec fastlane android beta

iOS only:

bundle exec fastlane ios beta

Both (with version bump):

./scripts/ship.sh              # bump build number only
./scripts/ship.sh 1.4          # bump build number and set version name to 1.4

Part 4 — Worktree Considerations

Gitignored 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.


Part 5 — Known Issues

KMP Issue 1: Bundler environment leaks into xcodebuild build phases

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(...)
end

Do 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.


KMP Issue 2: Stale DerivedData causes SPM resolution failures

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.


Known Issue 3: googleauth gem PKCS#8 key error on OpenSSL 3.x

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


Known Issue 4: bundle exec nesting in scripts/ship.sh

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.


Part 6 — Quick Reference

Commands

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

Files committed to the repo

.ruby-version
Gemfile
Gemfile.lock
fastlane/Fastfile
fastlane/Appfile
scripts/ship.sh

Files that must NEVER be committed

fastlane/.env
fastlane/google-play-key.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment