# 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 UI.user_error!("Failed to extract versionName from build.gradle") if version_name.nil? || version_name.empty? # 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 and sanitize it branch_name = ENV['GITHUB_HEAD_REF'] || `git rev-parse --abbrev-ref HEAD`.strip branch_name = branch_name.gsub(/[^a-zA-Z0-9_-]/, '_') # Replace non-alphanumeric characters with underscore 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" # 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}" # Build APK UI.message("Building APK...") sh("cd android && ./gradlew assembleRelease --no-daemon") UI.message("APK build completed.") # 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 = Dir.glob("#{ENV['ANDROID_HOME']}/build-tools/*/apksigner").sort.last UI.user_error!("apksigner not found in Android build-tools") if apksigner_path.nil? || apksigner_path.empty? 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 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