Skip to content

Instantly share code, notes, and snippets.

@khaledahmedelsayed
Last active December 29, 2025 15:02
Show Gist options
  • Select an option

  • Save khaledahmedelsayed/85f28ca54dd55cf03c28fc0ae1db3432 to your computer and use it in GitHub Desktop.

Select an option

Save khaledahmedelsayed/85f28ca54dd55cf03c28fc0ae1db3432 to your computer and use it in GitHub Desktop.
Fastlane Android CI/CD

Fastlane CI/CD Template (Android)

Sanitized example Fastfile for showcasing an Android CI/CD pipeline with Firebase App Distribution, Google Play, and Slack notifications.

Lanes Overview

Lane Purpose
buildAPKAndUploadToFirebase Builds an environment-specific APK, uploads to Firebase App Distribution. For release, also delegates AAB + Play upload (passing optional track).
buildAABAndUploadToGooglePlay Builds a signed AAB and uploads to specified Google Play track.
sendBuildUploadedMessageToSlack Fetches latest Firebase build and posts a Slack message with download link + release notes.
runFullPipeline Convenience lane: build APK (and AAB if release) then Slack notify; accepts optional track for Play upload.

Versioning Strategy

  • Version names follow x.y.z.w-suffix (suffix = environment, e.g. develop).
  • If version_name not passed, lane auto-increments last segment based on latest Firebase release or last git tag.
  • Google Play uploads strip the suffix (only x.y.z.w).
  • Workflows derive version automatically if not provided:
    • deployment: 1.0.0.<run_number>-<environment> fallback
    • release: from tag vX.Y.Z.W -> X.Y.Z.W-release (suffix added if missing) or fallback 1.0.0.<run_number>-release

Required Environment Variables / Secrets

Define these in GitHub repository secrets or local shell before running lanes:

FIREBASE_CLI_TOKEN                       # Firebase App Distribution auth (via firebase login:ci)
GOOGLE_FIREBASE_SERVICE_ACCOUNT_JSON_BASE64  # Base64 of service account JSON for Firebase distribution operations
GOOGLE_PLAY_CONSOLE_SERVICE_ACCOUNT_JSON_BASE64 # Base64 of service account JSON with Play Console access
ANDROID_DEBUG_KEYSTORE_BASE64            # (Optional) base64 of debug keystore; if absent uses default debug.keystore
ANDROID_KEYSTORE_BASE64                  # Production keystore (base64) for release/staging signing
ANDROID_KEY_STORE_PASSWORD               # Keystore password
ANDROID_KEY_ALIAS                        # Key alias
DEVELOP_FIREBASE_APP_ID                  # Firebase app ID for develop
STAGING_FIREBASE_APP_ID                  # Firebase app ID for staging
RELEASE_FIREBASE_APP_ID                  # Firebase app ID for release
SLACK_WEB_HOOK_URL                       # (Optional) for Slack notifications
ANDROID_PACKAGE_NAME                     # e.g. com.example.myapp

Workflows include a validation step; missing required secrets cause an immediate failure with a readable list.

Release Notes

Place human-readable notes in release_notes.txt at project root; they are attached to Firebase distribution and included in Slack message.

Sample Commands

# Build develop APK & upload
bundle exec fastlane buildAPKAndUploadToFirebase environment_name:develop

# Build release AAB & upload to Play internal track
bundle exec fastlane buildAABAndUploadToGooglePlay environment_name:release track:internal version_name:1.2.3.4-release

# Notify Slack (after build)
bundle exec fastlane sendBuildUploadedMessageToSlack environment_name:staging

# Full pipeline (build + notify) with internal track
bundle exec fastlane runFullPipeline environment_name:release track:internal

GitHub Actions

Two workflow examples in .github/workflows/:

  • deployment.yaml: triggers on push/PR to develop & staging, builds APKs via Fastlane, auto-generates a version name if not supplied (manual dispatch supported). Uploads APK artifact and optionally Slack notification.
  • release.yaml: triggers on v* tags or manual dispatch. Derives release version from tag or input, runs full pipeline (APK + AAB + Slack) passing chosen Google Play track (default internal). Artifacts for APK, AAB, and mapping are uploaded.

Example Tag Release

Push a tag: git tag v1.2.3.4 && git push origin v1.2.3.4 -> workflow sets version 1.2.3.4-release.

Manual Dispatch Release

Provide version_name:1.3.0.0-release track:production in workflow dispatch form.

Security Notes

  • Never commit keystore or service account JSON; only base64 in secrets.
  • Ensure Slack webhook is scoped to a channel you intend.
  • Rotate tokens regularly.

Adapting

Rename placeholders (MyApp, @team) and adjust environment naming convention if you use different suffixes.

Troubleshooting

Issue Fix
Firebase latest release not found Ensure FIREBASE_CLI_TOKEN is valid and app ID secret matches branch environment.
Google Play upload fails auth Confirm Play service account has required permissions and JSON matches the app.
Version name rejected by Play Strip environment suffix or ensure semantic segments count is correct.
Slack lane fails silently Verify SLACK_WEB_HOOK_URL secret set and not empty.

License

Use, modify, and share freely. Remove proprietary names if adapting from a private repo.

# =============================
# Fastlane CI/CD Example (Sanitized Template)
# This Fastfile was adapted from a production Android app and sanitized for public sharing.
# Replace placeholders like `MyApp`, `@team`, package name, and environment variable names with your own values.
# All sensitive data (keystores, service account JSON, app/package IDs, Slack webhook) must be supplied via
# environment variables / GitHub Actions secrets โ€“ never commit them.
# Use `bundle install` then always run lanes with `bundle exec fastlane <lane>`.
# =============================
# Make sure to have bundler and fastlane installed and then run "bundle install", and always use "bundle exec" not fastlane directly
# Examples for the following lanes (handled default values for no arguments) if you want to trigger manually (i.e not from Github Actions)
# bundle exec fastlane buildAPKAndUploadToFirebase environment_name:develop version_name:1.0.0.1-develop
# bundle exec fastlane buildAABAndUploadToGooglePlay environment_name:release version_name:1.0.0.0-release track:internal
# bundle exec fastlane sendBuildUploadedMessageToSlack version_name:1.0.0.0-staging
# bundle exec fastlane runFullPipeline
# bundle exec fastlane runFullPipeline version_name:1.2.3-release track:internal
default_platform(:android)
platform :android do
private_lane :get_incremented_version_name do |options|
# Fetch the latest release from Firebase App Distribution
appId = options[:appId]
environment = get_environment(environment_name: options[:environment_name], version_name: options[:version_name])
latest_release = firebase_app_distribution_get_latest_release(
app: appId,
firebase_cli_token: ENV['FIREBASE_CLI_TOKEN']
)
latest_version_name = (latest_release && latest_release[:displayVersion]) || last_git_tag || "1.0.0-#{environment}"
# Assuming the version name pattern is x.y.z.w-suffix
# Split the version name into components
components = latest_version_name.split('-')
version_numbers = components[0].split('.')
# Increment the last number
version_numbers[-1] = (version_numbers[-1].to_i + 1).to_s
# Reassemble the version name
new_version_name = version_numbers.join('.') + (components.length > 1 ? "-#{components[1]}" : "")
puts "New version name: #{new_version_name}"
new_version_name
end
private_lane :get_environment do |options|
# get the environment from the options or from sent version_name suffix or from last git tag suffix or default to 'develop'
version_name_env = options[:version_name]&.split("-")&.[](1)
last_tag_env = last_git_tag&.split("-")[1]
environment = options[:environment_name] || version_name_env || last_tag_env || "develop"
puts "Environment: #{environment}"
environment
end
private_lane :get_app_id do |options|
environment = get_environment(environment_name: options[:environment_name], version_name: options[:version_name])
app_id = ENV["#{environment.upcase}_FIREBASE_APP_ID"]
puts "App ID for environment '#{environment}': #{app_id}"
app_id
end
private_lane :generate_service_account_file do |options|
env_var_name = options[:env_var_name]
service_account_base64 = ENV[env_var_name]
if service_account_base64
filename = env_var_name.downcase.gsub('_', '-') + '.json'
file_path = File.join("#{File.dirname(Dir.pwd)}", filename)
begin
File.open(file_path, 'w') do |file|
file.write(Base64.decode64(service_account_base64))
end
UI.success("Service account file generated at: #{file_path}")
# Return the path to the file
file_path
rescue => e
UI.user_error!("Failed to create service account file: #{e.message}")
end
else
UI.user_error!("Service account base64 not found in environment variable '#{env_var_name}'.")
end
end
desc "Build AAB and upload it to Google Play Console"
lane :buildAABAndUploadToGooglePlay do |options|
environment = get_environment(environment_name: options[:environment_name], version_name: options[:version_name])
new_version_name = options[:version_name]
# Production keystore for AAB builds
keystore_path = File.join("#{File.dirname(Dir.pwd)}", "keystore")
UI.message("Checking if production keystore file exists at path: #{keystore_path}")
unless File.exist?(keystore_path)
if ENV["ANDROID_KEYSTORE_BASE64"]
UI.important("Production keystore not found. Decoding from base64 string...")
keystore_base64 = ENV['ANDROID_KEYSTORE_BASE64']
begin
decoded_content = Base64.decode64(keystore_base64)
File.open(keystore_path, 'wb') do |file|
file.write(decoded_content)
end
if File.exist?(keystore_path)
UI.success("Production keystore successfully decoded and saved to #{keystore_path}.")
else
UI.user_error!("Failed to decode and save the production keystore. File was not created.")
end
rescue => e
UI.user_error!("Failed to decode production keystore. Error: #{e.message}")
end
else
UI.user_error!("Production keystore base64 string not found in environment variables, and the keystore file does not exist at #{keystore_path}. Ensure you have set ANDROID_KEYSTORE_BASE64 in your secrets.")
end
else
UI.success("Production keystore file exists. Skipping decoding from base64 string.")
end
# Generate the Google service account JSON file needed for Google Play Console operations
google_console_service_account_file_path = generate_service_account_file(env_var_name: 'GOOGLE_PLAY_CONSOLE_SERVICE_ACCOUNT_JSON_BASE64')
# Remove any suffix from version_name for Google Play (only use x.y.z.w)
clean_play_version_name = new_version_name.to_s.split('-').first
# Configure gradle properties for production signing
gradle_properties = {
"android.injected.version.name" => clean_play_version_name,
"android.injected.signing.store.file" => keystore_path,
"android.injected.signing.store.password" => ENV["ANDROID_KEY_STORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["ANDROID_KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["ANDROID_KEY_STORE_PASSWORD"]
}
# Build AAB for release environment
gradle(
task: "bundle",
build_type: environment,
properties: gradle_properties
)
# Upload the AAB to Google Play Console
supply(
track: options[:track] || options[:track] || "production",
package_name: ENV["ANDROID_PACKAGE_NAME"],
json_key: google_console_service_account_file_path,
aab: "app/build/outputs/bundle/#{environment}/app-#{environment}.aab",
release_status: "completed",
version_name: clean_play_version_name,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
skip_upload_apk: true,
mapping: "app/build/outputs/mapping/#{environment}/mapping.txt",
)
UI.success(" AAB for release environment built and uploaded to Google Play Console successfully!")
end
desc "Build APK and upload it on Firebase app distribution"
lane :buildAPKAndUploadToFirebase do |options|
environment = get_environment(environment_name: options[:environment_name], version_name: options[:version_name])
appId = get_app_id(environment_name: environment)
new_version_name = options[:version_name] || get_incremented_version_name(appId: appId, environment_name: environment, version_name: options[:version_name])
# Determine which keystore to use based on environment
if environment == "staging" || environment == "release"
# Production keystore for staging/release environments
keystore_path = File.join("#{File.dirname(Dir.pwd)}", "keystore")
UI.message("Checking if production keystore file exists at path: #{keystore_path}")
unless File.exist?(keystore_path)
if ENV["ANDROID_KEYSTORE_BASE64"]
UI.important("Production keystore not found. Decoding from base64 string...")
keystore_base64 = ENV['ANDROID_KEYSTORE_BASE64']
begin
decoded_content = Base64.decode64(keystore_base64)
File.open(keystore_path, 'wb') do |file|
file.write(decoded_content)
end
if File.exist?(keystore_path)
UI.success("Production keystore successfully decoded and saved to #{keystore_path}.")
else
UI.user_error!("Failed to decode and save the production keystore. File was not created.")
end
rescue => e
UI.user_error!("Failed to decode production keystore. Error: #{e.message}")
end
else
UI.user_error!("Production keystore base64 string not found in environment variables, and the keystore file does not exist at #{keystore_path}. Ensure you have set ANDROID_KEYSTORE_BASE64 in your secrets.")
end
else
UI.success("Production keystore file exists. Skipping decoding from base64 string.")
end
# Configure gradle properties for production signing
gradle_properties = {
"android.injected.version.name" => new_version_name,
"android.injected.signing.store.file" => keystore_path,
"android.injected.signing.store.password" => ENV["ANDROID_KEY_STORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["ANDROID_KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["ANDROID_KEY_STORE_PASSWORD"]
}
else
# Debug keystore for other environments
keystore_debug_path = File.join("#{File.dirname(Dir.pwd)}", "debug.keystore")
if ENV["ANDROID_DEBUG_KEYSTORE_BASE64"]
UI.important("Decoding debug keystore from base64 string...")
debug_keystore_base64 = ENV['ANDROID_DEBUG_KEYSTORE_BASE64']
begin
decoded_content = Base64.decode64(debug_keystore_base64)
File.open(keystore_debug_path, 'wb') do |file|
file.write(decoded_content)
end
if File.exist?(keystore_debug_path)
UI.success("Debug keystore successfully decoded and saved to #{keystore_debug_path}.")
else
UI.user_error!("Failed to decode and save the debug keystore. File was not created.")
end
rescue => e
UI.user_error!("Failed to decode debug keystore. Error: #{e.message}")
end
else
UI.important("Debug keystore base64 string not found in environment variables / GitHub secrets. Using default debug keystore.")
keystore_debug_path = "#{ENV['HOME']}/.android/debug.keystore"
end
# Configure gradle properties for debug signing
gradle_properties = {
"android.injected.version.name" => new_version_name,
"android.injected.signing.store.file" => keystore_debug_path,
"android.injected.signing.store.password" => "android",
"android.injected.signing.key.alias" => "androiddebugkey",
"android.injected.signing.key.password" => "android"
}
end
# Build a new APK for the given environment and gradle properties
gradle(
task: "assemble",
build_type: environment,
properties: gradle_properties,
flags: "--stacktrace"
)
# Generate the Google service account JSON file needed for Firebase operations
firebase_service_account_file_path = generate_service_account_file(env_var_name: 'GOOGLE_FIREBASE_SERVICE_ACCOUNT_JSON_BASE64')
# Upload the APK to Firebase App Distribution with given release notes in release_notes.txt
firebase_app_distribution(
app: appId,
service_credentials_file: firebase_service_account_file_path,
release_notes_file: "release_notes.txt",
android_artifact_type: "APK",
android_artifact_path: "app/build/outputs/apk/#{environment}/app-#{environment}.apk",
groups: "testing"
)
# For release environment, additionally build and upload AAB
if environment == "release"
buildAABAndUploadToGooglePlay(
environment_name: environment,
version_name: new_version_name,
track: options[:track]
)
end
end
desc "Send a Slack message (from Android Slack App) notifying that a new build has been uploaded"
lane :sendBuildUploadedMessageToSlack do |options|
environment = get_environment(environment_name: options[:environment_name], version_name: options[:version_name])
appId = get_app_id(environment_name: environment)
latest_release = firebase_app_distribution_get_latest_release(
app: appId,
firebase_cli_token: ENV['FIREBASE_CLI_TOKEN']
)
latest_version_name = latest_release[:displayVersion]
version = options[:version_name] || latest_version_name
# Get the release ID for constructing the download link
# Extract the release ID from the name field (format: "projects/xxx/apps/xxx/releases/RELEASE_ID")
release_name = latest_release[:name]
release_id = release_name.split('/').last
download_url = "https://appdistribution.firebase.google.com/testerapps/#{appId}/releases/#{release_id}"
# Read release notes file from parent dir (project root dir)
release_notes_content = File.read(File.join("#{File.dirname(Dir.pwd)}", "release_notes.txt"))
# Construct the sanitized, generic message to send
message = "Hello @team\n" \
"New MyApp #{environment.capitalize} Build #{version} :android: uploaded to Firebase App Distribution\n\n" \
"You can download it <#{download_url}|here>\n\n" \
"Release Notes :memo: :\n\n" \
"#{release_notes_content}"
slack(
message: message,
default_payloads: [],
link_names: true,
slack_url: ENV["SLACK_WEB_HOOK_URL"]
)
end
desc "Run the complete deployment pipeline (build and upload then notify). Accepts optional 'track' for Google Play uploads when release environment."
lane :runFullPipeline do |options|
environment = get_environment(environment_name: options[:environment_name], version_name: options[:version_name])
# Step 1: Build and upload to Firebase (APK for all environments, plus AAB for release)
buildAPKAndUploadToFirebase(
environment_name: environment,
version_name: options[:version_name],
track: options[:track]
)
# Step 2: Send notification to Slack
sendBuildUploadedMessageToSlack(
environment_name: environment,
version_name: options[:version_name]
)
puts "๐Ÿš€ Deployment pipeline completed successfully! ๐Ÿš€"
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment