BlueWallet/fastlane/Fastfile
Marcos Rodriguez Velez f89200b52d Update Fastfile
2024-12-09 21:44:07 -04:00

595 lines
21 KiB
Ruby

# Define app identifiers once for reuse across lanes
def app_identifiers
[
"io.bluewallet.bluewallet",
"io.bluewallet.bluewallet.watch",
"io.bluewallet.bluewallet.watch.extension",
"io.bluewallet.bluewallet.Stickers",
"io.bluewallet.bluewallet.MarketWidget"
]
end
default_platform(:android)
project_root = File.expand_path("..", __dir__)
# ===========================
# Android Lanes
# ===========================
platform :android do
desc "Prepare the keystore file"
lane :prepare_keystore do
Dir.chdir(project_root) do
keystore_file_hex = ENV['KEYSTORE_FILE_HEX']
UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if keystore_file_hex.nil?
UI.message("Creating keystore from HEX...")
File.write("bluewallet-release-key.keystore.hex", keystore_file_hex)
sh("xxd -plain -revert bluewallet-release-key.keystore.hex > bluewallet-release-key.keystore") do |status|
UI.user_error!("Error reverting hex to keystore") unless status.success?
end
UI.message("Keystore created successfully.")
File.delete("bluewallet-release-key.keystore.hex")
end
end
desc "Update version, build number, and sign APK"
lane :update_version_build_and_sign_apk do
Dir.chdir(project_root) do
build_number = ENV['BUILD_NUMBER']
UI.user_error!("BUILD_NUMBER environment variable is missing") if build_number.nil?
# Extract versionName from build.gradle
version_name = sh("grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '\"'").strip
# Update versionCode in build.gradle
UI.message("Updating versionCode in build.gradle to #{build_number}...")
build_gradle_path = "android/app/build.gradle"
build_gradle_contents = File.read(build_gradle_path)
new_build_gradle_contents = build_gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}")
File.write(build_gradle_path, new_build_gradle_contents)
# Determine branch name
branch_name = ENV['GITHUB_HEAD_REF'] || `git rev-parse --abbrev-ref HEAD`.strip.gsub(/[\/\\:?*"<>|]/, '_')
branch_name = 'master' if branch_name.nil? || branch_name.empty?
# Define APK name based on branch
signed_apk_name = branch_name != 'master' ? "BlueWallet-#{version_name}-#{build_number}-#{branch_name}.apk" : "BlueWallet-#{version_name}-#{build_number}.apk"
# Build APK
UI.message("Building APK...")
sh("cd android && ./gradlew assembleRelease")
UI.message("APK build completed.")
# Define APK name based on branch
signed_apk_name = branch_name != 'master' ?
"BlueWallet-#{version_name}-#{build_number}-#{branch_name}".gsub(/[\/\\:?*"<>|]/, '_') + ".apk" :
"BlueWallet-#{version_name}-#{build_number}.apk"
# Define paths
unsigned_apk_path = "android/app/build/outputs/apk/release/app-release-unsigned.apk"
signed_apk_path = "android/app/build/outputs/apk/release/#{signed_apk_name}"
# Rename APK
if File.exist?(unsigned_apk_path)
UI.message("Renaming APK to #{signed_apk_name}...")
FileUtils.mv(unsigned_apk_path, signed_apk_path)
ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path)
else
UI.error("Unsigned APK not found at path: #{unsigned_apk_path}")
next
end
# Sign APK
UI.message("Signing APK with apksigner...")
apksigner_path = "#{ENV['ANDROID_HOME']}/build-tools/34.0.0/apksigner"
sh("#{apksigner_path} sign --ks #{project_root}/bluewallet-release-key.keystore --ks-pass=pass:#{ENV['KEYSTORE_PASSWORD']} #{signed_apk_path}")
UI.message("APK signed successfully: #{signed_apk_path}")
end
end
desc "Upload APK to BrowserStack and post result as PR comment"
lane :upload_to_browserstack_and_comment do
Dir.chdir(project_root) do
# Determine APK path
apk_path = ENV['APK_PATH']
if apk_path.nil? || apk_path.empty?
UI.message("No APK path provided, searching for APK...")
apk_path = `find ./ -name "*.apk"`.strip
UI.user_error!("No APK file found") if apk_path.nil? || apk_path.empty?
end
# Upload to BrowserStack
UI.message("Uploading APK to BrowserStack: #{apk_path}...")
upload_to_browserstack_app_live(
file_path: apk_path,
browserstack_username: ENV['BROWSERSTACK_USERNAME'],
browserstack_access_key: ENV['BROWSERSTACK_ACCESS_KEY']
)
# Extract BrowserStack URL
app_url = ENV['BROWSERSTACK_LIVE_APP_ID']
UI.user_error!("BrowserStack upload failed, no app URL returned") if app_url.nil? || app_url.empty?
# Prepare PR comment
apk_filename = File.basename(apk_path)
apk_download_url = ENV['APK_OUTPUT_PATH'] # Ensure this path is accessible
browserstack_hashed_id = app_url.gsub('bs://', '')
pr_number = ENV['GITHUB_PR_NUMBER']
comment_identifier = '### APK Successfully Uploaded to BrowserStack'
comment = <<~COMMENT
#{comment_identifier}
You can test it on the following devices:
- [Google Pixel 9 (Android 15)](https://app-live.browserstack.com/dashboard#os=android&os_version=15.0&device=Google+Pixel+8&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Google Pixel 8 (Android 14)](https://app-live.browserstack.com/dashboard#os=android&os_version=14.0&device=Google+Pixel+8&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Google Pixel 7 (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Google+Pixel+7&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Google Pixel 5 (Android 12)](https://app-live.browserstack.com/dashboard#os=android&os_version=12.0&device=Google+Pixel+5&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Google Pixel 3a (Android 9)](https://app-live.browserstack.com/dashboard#os=android&os_version=9.0&device=Google+Pixel+3a&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Z Fold 6 (Android 14)](https://app-live.browserstack.com/dashboard#os=android&os_version=14.0&device=Samsung+Galaxy+Z+Fold+6&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Z Fold 5 (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Samsung+Galaxy+Z+Fold+5&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Tab S9 (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Samsung+Galaxy+Tab+S9&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [Samsung Galaxy Note 9 (Android 8.1)](https://app-live.browserstack.com/dashboard#os=android&os_version=8.1&device=Samsung+Galaxy+Note+9&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
- [OnePlus 11R (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=OnePlus+11R&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true)
**Filename**: [#{apk_filename}](#{apk_download_url})
**BrowserStack App URL**: #{app_url}
COMMENT
# Delete Previous BrowserStack Comments
if pr_number
begin
repo = ENV['GITHUB_REPOSITORY'] # Format: "owner/repo"
repo_owner, repo_name = repo.split('/')
UI.message("Fetching existing comments for PR ##{pr_number}...")
comments_json = `gh api -X GET /repos/#{repo_owner}/#{repo_name}/issues/#{pr_number}/comments`
comments = JSON.parse(comments_json)
comments.each do |comment|
if comment['body'].start_with?(comment_identifier)
comment_id = comment['id']
UI.message("Deleting previous comment ID: #{comment_id}...")
`gh api -X DELETE /repos/#{repo_owner}/#{repo_name}/issues/comments/#{comment_id}`
UI.success("Deleted comment ID: #{comment_id}")
end
end
rescue => e
UI.error("Failed to delete previous comments: #{e.message}")
end
else
UI.important("No PR number found. Skipping deletion of previous comments.")
end
# Post New Comment to PR
if pr_number
begin
escaped_comment = comment.gsub("'", "'\\''")
sh("GH_TOKEN=#{ENV['GH_TOKEN']} gh pr comment #{pr_number} --body '#{escaped_comment}'")
UI.success("Posted new comment to PR ##{pr_number}")
rescue => e
UI.error("Failed to post comment to PR: #{e.message}")
end
else
UI.important("No PR number found. Skipping PR comment.")
end
end
end
# ===========================
# iOS Lanes
# ===========================
platform :ios do
desc "Register new devices from a file"
lane :register_devices_from_txt do
UI.message("Registering new devices from file...")
csv_path = "../../devices.txt" # Update this with the actual path to your file
# Register devices using the devices_file parameter
register_devices(
devices_file: csv_path
)
UI.message("Devices registered successfully.")
# Update provisioning profiles for all app identifiers
app_identifiers.each do |app_identifier|
match(
type: "development",
app_identifier: app_identifier,
readonly: false, # Regenerate provisioning profile if needed
force_for_new_devices: true,
clone_branch_directly: true
)
end
UI.message("Development provisioning profiles updated.")
end
desc "Create a temporary keychain"
lane :create_temp_keychain do
UI.message("Creating a temporary keychain...")
create_keychain(
name: "temp_keychain",
password: ENV["KEYCHAIN_PASSWORD"],
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: true
)
UI.message("Temporary keychain created successfully.")
end
desc "Synchronize certificates and provisioning profiles"
lane :setup_provisioning_profiles do
UI.message("Setting up provisioning profiles...")
platform = "ios"
# Iterate over app identifiers to fetch provisioning profiles
app_identifiers.each do |app_identifier|
match(
git_basic_authorization: ENV["GIT_ACCESS_TOKEN"],
git_url: ENV["GIT_URL"],
type: "appstore",
clone_branch_directly: true, # Skip if the branch already exists
platform: platform,
app_identifier: app_identifier,
team_id: ENV["ITC_TEAM_ID"],
team_name: ENV["ITC_TEAM_NAME"],
readonly: true,
keychain_name: "temp_keychain",
keychain_password: ENV["KEYCHAIN_PASSWORD"]
)
end
end
desc "Fetch development certificates and provisioning profiles for Mac Catalyst"
lane :fetch_dev_profiles_catalyst do
match(
type: "development",
platform: "catalyst",
app_identifier: app_identifiers,
readonly: true,
clone_branch_directly: true
)
end
desc "Fetch App Store certificates and provisioning profiles for Mac Catalyst"
lane :fetch_appstore_profiles_catalyst do
match(
type: "appstore",
platform: "catalyst",
app_identifier: app_identifiers,
readonly: true,
clone_branch_directly: true
)
end
desc "Setup provisioning profiles for Mac Catalyst"
lane :setup_catalyst_provisioning_profiles do
app_identifiers.each do |app_identifier|
match(
type: "development",
platform: "catalyst",
app_identifier: app_identifier,
readonly: false,
force_for_new_devices: true,
clone_branch_directly: true
)
match(
type: "appstore",
platform: "catalyst",
app_identifier: app_identifier,
readonly: false,
clone_branch_directly: true
)
end
end
desc "Clear derived data"
lane :clear_derived_data_lane do
UI.message("Clearing derived data...")
clear_derived_data
end
desc "Increment build number"
lane :increment_build_number_lane do
UI.message("Incrementing build number to current timestamp...")
# Set the new build number
increment_build_number(
xcodeproj: "ios/BlueWallet.xcodeproj",
build_number: ENV["NEW_BUILD_NUMBER"]
)
UI.message("Build number set to: #{ENV['NEW_BUILD_NUMBER']}")
end
desc "Install CocoaPods dependencies"
lane :install_pods do
UI.message("Installing CocoaPods dependencies...")
cocoapods(podfile: "ios/Podfile")
end
desc "Upload IPA to TestFlight"
lane :upload_to_testflight_lane do
branch_name = ENV['BRANCH_NAME'] || "unknown-branch"
last_commit_message = ENV['LATEST_COMMIT_MESSAGE'] || "No commit message found"
changelog = <<~CHANGELOG
Build Information:
CHANGELOG
# Include the branch name only if it is not 'master'
if branch_name != 'master'
changelog += <<~CHANGELOG
- Branch: #{branch_name}
CHANGELOG
end
changelog += <<~CHANGELOG
- Commit: #{last_commit_message}
CHANGELOG
ipa_path = ENV['IPA_OUTPUT_PATH']
if ipa_path.nil? || ipa_path.empty? || !File.exist?(ipa_path)
UI.user_error!("IPA file not found at path: #{ipa_path}")
end
UI.message("Uploading IPA to TestFlight from path: #{ipa_path}")
UI.message("Changelog:\n#{changelog}")
upload_to_testflight(
api_key_path: "./appstore_api_key.json",
ipa: ipa_path,
skip_waiting_for_build_processing: true,
changelog: changelog
)
UI.success("Successfully uploaded IPA to TestFlight!")
end
desc "Upload iOS source maps to Bugsnag"
lane :upload_bugsnag_sourcemaps do
bugsnag_api_key = ENV['BUGSNAG_API_KEY']
bugsnag_release_stage = ENV['BUGSNAG_RELEASE_STAGE'] || "production"
version = ENV['PROJECT_VERSION']
build_number = ENV['NEW_BUILD_NUMBER']
UI.user_error!("BUGSNAG_API_KEY environment variable is missing") if bugsnag_api_key.nil?
UI.user_error!("PROJECT_VERSION environment variable is missing") if version.nil?
UI.user_error!("NEW_BUILD_NUMBER environment variable is missing") if build_number.nil?
ios_sourcemap = "./ios/build/Build/Products/Release-iphonesimulator/main.jsbundle.map"
if File.exist?(ios_sourcemap)
UI.message("Uploading iOS source map to Bugsnag...")
bugsnag_sourcemaps_upload(
api_key: bugsnag_api_key,
source_map: ios_sourcemap,
minified_file: "./ios/main.jsbundle",
code_bundle_id: "#{version}-#{build_number}",
release_stage: bugsnag_release_stage,
app_version: version
)
UI.success("iOS source map uploaded successfully.")
else
UI.error("iOS source map not found at #{ios_sourcemap}")
end
end
desc "Build the iOS app"
lane :build_app_lane do
Dir.chdir(project_root) do
UI.message("Building the application from: #{Dir.pwd}")
workspace_path = File.join(project_root, "ios", "BlueWallet.xcworkspace")
export_options_path = File.join(project_root, "ios", "export_options.plist")
clear_derived_data_lane
begin
build_ios_app(
scheme: "BlueWallet",
workspace: workspace_path,
export_method: "app-store",
include_bitcode: false,
configuration: "Release",
skip_profile_detection: false,
include_symbols: true,
export_team_id: ENV["ITC_TEAM_ID"],
export_options: export_options_path,
output_directory: File.join(project_root, "ios", "build"),
output_name: "BlueWallet_#{ENV['PROJECT_VERSION']}_#{ENV['NEW_BUILD_NUMBER']}.ipa",
buildlog_path: File.join(project_root, "ios", "build_logs"),
silent: false,
clean: true
)
rescue => e
UI.user_error!("build_ios_app failed: #{e.message}")
end
# Use File.join to construct paths without extra slashes
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
if ipa_path && File.exist?(ipa_path)
UI.message("IPA successfully found at: #{ipa_path}")
ENV['IPA_OUTPUT_PATH'] = ipa_path
sh("echo 'IPA_OUTPUT_PATH=#{ipa_path}' >> $GITHUB_ENV") # Export for GitHub Actions
else
UI.user_error!("IPA not found after build_ios_app.")
end
end
end
end
# ===========================
# Global Lanes
# ===========================
desc "Deploy to TestFlight"
lane :deploy do |options|
UI.message("Starting deployment process...")
# Update WWDR Certificate
update_wwdr_certificate
# Setup App Store Connect API Key
setup_app_store_connect_api_key
# Setup Provisioning Profiles
setup_provisioning_profiles
# Clear Derived Data
clear_derived_data_lane
# Increment Build Number
increment_build_number_lane
# Install CocoaPods if not already installed
unless File.directory?("Pods")
install_pods
end
# Build the iOS App
build_app_lane
# Upload IPA to TestFlight
upload_to_testflight_lane
# Clean up and delete the temporary keychain
delete_keychain(name: "temp_keychain")
# Mark deployment as completed for the current commit
last_commit = last_git_commit
already_built_flag = ".already_built_#{last_commit[:sha]}"
File.write(already_built_flag, Time.now.to_s)
end
desc "Update 'What's New' section in App Store Connect for the 'Prepare for Submission' version"
lane :update_release_notes do |options|
require 'spaceship'
UI.message("Logging in to App Store Connect...")
Spaceship::ConnectAPI.login
app = Spaceship::ConnectAPI::App.find(app_identifiers.first)
UI.user_error!("Could not find the app with identifier: #{app_identifiers.first}") unless app
# Retry logic for fetching or creating the edit version
retries = 5
begin
prepare_version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
if prepare_version.nil?
UI.message("No version in 'Prepare for Submission' found. Creating a new version...")
latest_version = app.get_latest_version(platform: Spaceship::ConnectAPI::Platform::IOS)
new_version_number = (latest_version.version_string.to_f + 0.1).to_s
prepare_version = app.create_version!(platform: Spaceship::ConnectAPI::Platform::IOS, version_string: new_version_number)
UI.message("Created new version: #{new_version_number}")
else
UI.message("Found existing version in 'Prepare for Submission': #{prepare_version.version_string}")
end
rescue => e
retries -= 1
if retries > 0
delay = 20
UI.message("Cannot find edit app info... Retrying after #{delay} seconds (remaining: #{retries})")
sleep(delay)
retry
else
UI.user_error!("Failed to fetch or create the app version: #{e.message}")
end
end
# Extract existing metadata
localized_metadata = prepare_version.get_app_store_version_localizations
# Get enabled locales
enabled_locales = localized_metadata.map(&:locale)
# Define release notes
release_notes_text = options[:release_notes]
if release_notes_text.nil? || release_notes_text.strip.empty?
release_notes_path = "../release-notes.txt"
unless File.exist?(release_notes_path)
UI.error("Release notes file does not exist at path: #{release_notes_path}")
UI.user_error!("No release notes provided and no file found. Failing the lane.")
end
release_notes_text = File.read(release_notes_path)
end
# Define localized release notes
localized_release_notes = {
'en-US' => release_notes_text, # English (U.S.) - Primary
'ar-SA' => release_notes_text, # Arabic
'zh-Hans' => release_notes_text, # Chinese (Simplified)
'hr' => release_notes_text, # Croatian
'da' => release_notes_text, # Danish
'nl-NL' => release_notes_text, # Dutch
'fi' => release_notes_text, # Finnish
'fr-FR' => release_notes_text, # French
'de-DE' => release_notes_text, # German
'el' => release_notes_text, # Greek
'he' => release_notes_text, # Hebrew
'hu' => release_notes_text, # Hungarian
'it' => release_notes_text, # Italian
'ja' => release_notes_text, # Japanese
'ms' => release_notes_text, # Malay
'nb' => release_notes_text, # Norwegian
'pl' => release_notes_text, # Polish
'pt-BR' => release_notes_text, # Portuguese (Brazil)
'pt-PT' => release_notes_text, # Portuguese (Portugal)
'ro' => release_notes_text, # Romanian
'ru' => release_notes_text, # Russian
'es-MX' => release_notes_text, # Spanish (Mexico)
'es-ES' => release_notes_text, # Spanish (Spain)
'sv' => release_notes_text, # Swedish
'th' => release_notes_text, # Thai
}.select { |locale, _| enabled_locales.include?(locale) } # Only include enabled locales
# Review release notes updates
UI.message("Review the following release notes updates:")
localized_release_notes.each do |locale, notes|
UI.message("Locale: #{locale} - Notes: #{notes}")
end
unless options[:force_yes]
confirm = UI.confirm("Do you want to proceed with these release notes updates?")
UI.user_error!("User aborted the lane.") unless confirm
end
# Update release notes in App Store Connect
localized_release_notes.each do |locale, notes|
app_store_version_localization = localized_metadata.find { |loc| loc.locale == locale }
if app_store_version_localization
app_store_version_localization.update(attributes: { "whats_new" => notes })
else
UI.error("No localization found for locale #{locale}")
end
end
end
end