|
# ============================= |
|
# 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 |