Skip to main content

Fastlane Deep Dive

Fastlane is Ruby-based automation for mobile release tasks: build, sign, upload, screenshot capture, metadata sync, beta distribution. It's the de-facto standard for serious Android release pipelines — GitHub Actions handles CI; Fastlane handles release plumbing.

Why Fastlane

Without Fastlane

Manual or custom

  • Build via ./gradlew bundleRelease + manual Play Console upload
  • Custom shell scripts for sign + upload
  • No screenshot automation
  • Metadata updated by hand in Play Console
  • Each CI re-implements upload logic
  • Fragile, undocumented, brittle
With Fastlane

Battle-tested

  • supply plugin uploads AAB + metadata + screenshots
  • Lanes encode release types (internal, beta, production)
  • screengrab automates per-device screenshots
  • Metadata as git-tracked text files
  • Reusable across team and CI
  • Plugin ecosystem (Slack, Firebase, Crashlytics)

Setup

# Install
gem install fastlane

# Or via Bundler (recommended for CI)
cat > Gemfile <<EOF
source "https://rubygems.org"
gem "fastlane"
EOF

bundle install

Initialize in your Android project:

fastlane init
# Pick "Manual setup" — we'll configure lanes ourselves

Creates:

fastlane/
├── Fastfile ← lane definitions
├── Appfile ← app-wide config
└── metadata/ ← release notes, descriptions per locale
└── android/
└── en-US/
├── full_description.txt
├── short_description.txt
├── title.txt
└── changelogs/
└── default.txt

The supply plugin — Play Console API

supply (built into Fastlane) talks to the Play Developer API.

Service account setup

  1. Google Cloud Console → IAM → Service Accounts → Create
  2. Grant Service Account User role
  3. Generate JSON key, download
  4. Play Console → Setup → API access → Grant access to the service account
  5. Permissions: Release manager (or finer per-track)
# fastlane/Appfile
json_key_file("../play-service-account.json")
package_name("com.myapp")

Internal track upload

# fastlane/Fastfile

default_platform(:android)

platform :android do

desc "Build and upload to internal track"
lane :internal do
gradle(
task: "bundle",
build_type: "Release",
properties: {
"android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
"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",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
release_status: "completed",
skip_upload_metadata: false,
skip_upload_changelogs: false,
skip_upload_images: false,
skip_upload_screenshots: false
)
end
end

Run:

bundle exec fastlane internal

Output:

[18:42:03]: -------------
[18:42:03]: --- Step: gradle ---
[18:42:03]: -------------
[18:42:03]: $ ./gradlew bundle ...
[18:43:25]: ▸ BUILD SUCCESSFUL
[18:43:25]: -------------
[18:43:25]: --- Step: upload_to_play_store ---
[18:43:25]: -------------
[18:43:25]: Uploading new release...
[18:43:42]: Successfully uploaded

Promotion lanes

After QA validates internal, promote to closed beta:

desc "Promote internal → alpha"
lane :promote_to_alpha do
upload_to_play_store(
track: "internal",
track_promote_to: "alpha",
skip_upload_aab: true, # promotion only, no new bundle
skip_upload_metadata: true,
skip_upload_changelogs: false # update release notes
)
end

desc "Promote alpha → beta"
lane :promote_to_beta do
upload_to_play_store(
track: "alpha",
track_promote_to: "beta",
skip_upload_aab: true,
skip_upload_changelogs: false
)
end

desc "Promote to production (staged rollout)"
lane :promote_to_production do |options|
upload_to_play_store(
track: options[:from] || "beta",
track_promote_to: "production",
rollout: options[:rollout] || "0.05",
skip_upload_aab: true,
skip_upload_changelogs: false
)
end

desc "Halt production rollout"
lane :halt do
upload_to_play_store(
track: "production",
rollout: "0.0",
skip_upload_aab: true,
skip_upload_metadata: true
)
end

desc "Increase rollout percentage"
lane :increase_rollout do |options|
upload_to_play_store(
track: "production",
rollout: options[:to] || "0.10",
skip_upload_aab: true,
skip_upload_metadata: true
)
end

Usage

bundle exec fastlane promote_to_production rollout:0.05
# ... monitor for 24h ...
bundle exec fastlane increase_rollout to:0.10
# ... another 24h ...
bundle exec fastlane increase_rollout to:0.25
# ... and so on until 1.0

Or in an emergency:

bundle exec fastlane halt

Drops production rollout to 0% — existing installs unaffected, new installs revert to previous version.


Metadata as files

fastlane/metadata/android/
├── en-US/
│ ├── title.txt "My App"
│ ├── short_description.txt "80-character marketing line"
│ ├── full_description.txt "Up to 4000 characters of marketing copy"
│ ├── video.txt "https://youtube.com/watch?v=..."
│ ├── images/
│ │ ├── icon.png 512×512
│ │ ├── featureGraphic.png 1024×500
│ │ ├── phoneScreenshots/
│ │ │ ├── 01_home.png
│ │ │ ├── 02_search.png
│ │ │ └── 03_detail.png
│ │ └── sevenInchScreenshots/
│ │ └── ...
│ └── changelogs/
│ ├── default.txt
│ └── 1234.txt versionCode-specific
├── es-ES/
│ └── ...
└── hi-IN/
└── ...

Edit text files. Commit. Run fastlane internal. Play Console updated.

Designers can update screenshots via PR — git-tracked changes, reviewable, revertable.

Per-version changelog

# fastlane/metadata/android/en-US/changelogs/default.txt
- Added dark mode
- Fixed crash when applying expired discount codes
- 30% faster checkout

default.txt applies to all version codes unless {versionCode}.txt exists.


Screenshots automation — screengrab

For test-driven screenshots:

// app/src/androidTest/kotlin/.../ScreenshotsTest.kt
@RunWith(AndroidJUnit4::class)
class ScreenshotsTest {

@get:Rule val composeRule = createAndroidComposeRule<MainActivity>()

@get:Rule val localeRule = LocaleTestRule()

@Test fun screenshots() {
Screengrab.screenshot("01_home")

composeRule.onNodeWithText("Search").performClick()
Screengrab.screenshot("02_search")

composeRule.onNodeWithText("Wireless Earbuds").performClick()
Screengrab.screenshot("03_detail")
}
}
# fastlane/Screengrabfile
package_name "com.myapp"
use_tests_in_packages [ "com.myapp.screenshots" ]
locales ["en-US", "es-ES", "hi-IN", "ar"]
bundle exec fastlane screengrab

Captures screenshots per locale, copies to fastlane/metadata/android/{locale}/images/phoneScreenshots/. Next upload_to_play_store ships them.


Slack / notifications

desc "Internal release with Slack notification"
lane :internal_with_slack do
internal

slack(
message: "📱 Internal release ready: #{lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]}",
success: true,
slack_url: ENV["SLACK_WEBHOOK"],
payload: {
"Version" => last_git_tag,
"Branch" => `git rev-parse --abbrev-ref HEAD`.strip,
"Build time" => Time.now.to_s
}
)
end

error do |lane, exception|
slack(
message: "🚨 Lane `#{lane}` failed",
success: false,
slack_url: ENV["SLACK_WEBHOOK"],
attachment_properties: { fields: [ { title: "Error", value: exception.message } ] }
)
end

The error do block fires on any lane failure — Slack alerts your team without manual intervention.


Firebase App Distribution (beta to internal testers)

desc "Build + push to Firebase App Distribution"
lane :distribute do
gradle(task: "assemble", build_type: "Staging")

firebase_app_distribution(
app: ENV["FIREBASE_APP_ID"],
groups: "qa-team, designers",
release_notes: changelog_from_git_commits(
between: [last_git_tag, "HEAD"],
pretty: "- %s"
),
apk_path: lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH],
service_credentials_file: ENV["FIREBASE_SERVICE_ACCOUNT_PATH"]
)
end

Testers get an email + install link. No Play Console hassle for pre-alpha builds.


Crashlytics upload

desc "Upload mapping file to Crashlytics"
lane :upload_mapping do
upload_symbols_to_crashlytics(
binary_path: "app/build/outputs/mapping/release/mapping.txt",
app_id: ENV["FIREBASE_APP_ID"]
)
end

Run as part of release lane — Crashlytics de-obfuscates stack traces using the mapping file.


Tagging git on release

desc "Production release with git tag"
lane :production do
internal # reuses the internal lane

# Tag the commit
version = sh("../gradlew", "-q", "printVersion").strip
add_git_tag(tag: "v#{version}")
push_git_tags

upload_to_play_store(
track: "internal",
track_promote_to: "production",
rollout: "0.05"
)

slack(message: "🚀 v#{version} → production 5%", success: true)
end
// app/build.gradle.kts — expose version to Fastlane
tasks.register("printVersion") {
doLast {
println(android.defaultConfig.versionName)
}
}

Every production release leaves a git tag — audit trail correlated with Play Store version.


Secrets management

Locally — .env file

# fastlane/.env (gitignored)
KEYSTORE_PATH=/Users/me/secrets/release.keystore
KEYSTORE_PASSWORD=...
KEY_ALIAS=upload
KEY_PASSWORD=...
SLACK_WEBHOOK=https://hooks.slack.com/...
FIREBASE_SERVICE_ACCOUNT_PATH=/Users/me/secrets/firebase.json
bundle exec fastlane internal --env=staging

Loads from fastlane/.env.staging.

CI — GitHub Actions secrets

# .github/workflows/release.yml
- name: Decode secrets
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
PLAY_SA_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
echo "$KEYSTORE_BASE64" | base64 -d > release.keystore
echo "$PLAY_SA_JSON" > play-service-account.json

- name: Run Fastlane
env:
KEYSTORE_PATH: ${{ github.workspace }}/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
SUPPLY_JSON_KEY: ${{ github.workspace }}/play-service-account.json
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: bundle exec fastlane internal

Never commit secrets

.gitignore — minimum:

fastlane/.env*
*.keystore
*service-account*.json

Add git-secrets or detect-secrets pre-commit hook to block accidents.


Plugins

Fastlane has 800+ community plugins. Common Android ones:

fastlane add_plugin firebase_app_distribution
fastlane add_plugin upload_symbols_to_crashlytics
fastlane add_plugin appcenter
fastlane add_plugin run_tests_app
fastlane add_plugin android_versioning

fastlane/Pluginfile:

gem 'fastlane-plugin-firebase_app_distribution'
gem 'fastlane-plugin-versioning_android'

Multi-flavor support

desc "Internal release for a specific flavor"
lane :internal do |options|
flavor = options[:flavor] || "prod"

gradle(
task: "bundle#{flavor.capitalize}",
build_type: "Release",
properties: { ... }
)

upload_to_play_store(
package_name: "com.myapp.#{flavor}",
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]
)
end
bundle exec fastlane internal flavor:enterprise
bundle exec fastlane internal flavor:consumer

For multi-app repos (white-label, region-specific apps), one lane covers all flavors.


Versioning

desc "Bump versionCode and version name"
lane :bump_version do |options|
android_set_version_code(
version_code: options[:code] || (android_get_version_code.to_i + 1)
)
android_set_version_name(
version_name: options[:name] || android_get_version_name
)

git_commit(path: "app/build.gradle.kts", message: "chore: bump version to #{options[:name]}")
add_git_tag(tag: "v#{options[:name]}")
push_to_git_remote
end
bundle exec fastlane bump_version name:2.5.0

Auto-increments versionCode, tags, pushes. Combined with a CI hook on tag push → triggers the release lane.


Testing Fastlane locally before CI

# Dry run — no actual upload
bundle exec fastlane internal --verbose

# Test all metadata uploads work
bundle exec fastlane validate_play_store_json_key

Validate the lane works on your machine before pushing CI changes.


Common patterns — full Fastfile

default_platform(:android)

platform :android do

before_all do
ensure_git_status_clean
ensure_git_branch(branch: "main|release/.*")
end

desc "Run tests"
lane :test do
gradle(task: "testDebugUnitTest")
gradle(task: "detekt")
gradle(task: "lint")
end

desc "Build signed AAB"
lane :build do
test
gradle(
task: "bundle",
build_type: "Release",
properties: signing_props
)
end

desc "Internal track release"
lane :internal do
build
upload_to_play_store(track: "internal")
slack(message: "📱 Internal release shipped", success: true)
end

desc "Promote to production at staged rollout"
lane :production do |options|
rollout = options[:rollout] || "0.05"
upload_to_play_store(
track: "internal",
track_promote_to: "production",
rollout: rollout,
skip_upload_aab: true
)
add_git_tag(tag: "v#{android_get_version_name}-rollout-#{rollout}")
push_git_tags
end

desc "Halt rollout"
lane :halt do
upload_to_play_store(
track: "production",
rollout: "0.0",
skip_upload_aab: true,
skip_upload_metadata: true
)
slack(message: "🚨 Production rollout HALTED", success: false)
end

error do |lane, exception|
slack(
message: "Fastlane `#{lane}` failed: #{exception.message}",
success: false
)
end
end

def signing_props
{
"android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["KEY_PASSWORD"]
}
end

Common anti-patterns

Fastlane anti-patterns

Problems

  • Hardcoded paths and credentials in Fastfile
  • No before_all sanity checks (dirty git status)
  • Single mega-lane doing everything
  • No error handler (silent failures)
  • Metadata edits straight in Play Console (lost)
  • Re-implementing supply manually
Best practices

Solid releases

  • Env vars + bundler-managed secrets
  • before_all { ensure_git_status_clean; ensure_git_branch }
  • Small lanes composed via `build` + `internal` + `production`
  • error do block notifies Slack
  • Metadata in fastlane/metadata, git-tracked
  • Use supply + maintained plugins

Key takeaways

Practice exercises

  1. 01

    Build internal lane

    Write a Fastfile with an `internal` lane that builds, signs, and uploads to Play internal track. Run locally end-to-end.

  2. 02

    Metadata sync

    Move release notes + screenshots to fastlane/metadata. Commit. Run fastlane internal and verify Play Console updates.

  3. 03

    Promotion + halt

    Write promote_to_production rollout:0.05 and halt lanes. Test the halt by promoting then halting — verify production rollout returns to 0.

  4. 04

    Slack on failure

    Add error do block + Slack webhook. Cause a deliberate failure (wrong keystore password); verify Slack alerts.

  5. 05

    CI integration

    Wire Fastlane into a GitHub Actions tag-triggered release workflow. Push a tag; confirm AAB lands on Play internal.

Next

Return to Module 17 Overview or continue to Module 18 — Observability.