BlueWallet/fastlane/Fastfile
2026-03-16 21:13:23 +00:00

1741 lines
68 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
def app_store_state_readable(state)
states = {
"DEVELOPER_REJECTED" => "Developer Rejected",
"PREPARE_FOR_SUBMISSION" => "Prepare for Submission",
"WAITING_FOR_REVIEW" => "Waiting for Review",
"IN_REVIEW" => "In Review",
"PENDING_DEVELOPER_RELEASE" => "Pending Developer Release",
"READY_FOR_SALE" => "Ready for Sale",
"REJECTED" => "Rejected",
"METADATA_REJECTED" => "Metadata Rejected"
}
states[state] || state
end
require 'securerandom'
default_platform(:android)
PROJECT_ROOT = File.expand_path("..", __dir__)
project_root = PROJECT_ROOT
module AndroidHelpers
module_function
def require_env!(keys)
missing = Array(keys).select { |key| ENV[key].nil? || ENV[key].empty? }
UI.user_error!("Missing required env vars: #{missing.join(', ')}") unless missing.empty?
end
def project_root
PROJECT_ROOT
end
def keystore_paths
{
hex: File.join(project_root, 'bluewallet-release-key.keystore.hex'),
file: File.join(project_root, 'android', 'bluewallet-release-key.keystore'),
}
end
def write_keystore_from_hex!(hex_value, paths)
UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if hex_value.nil? || hex_value.empty?
File.write(paths[:hex], hex_value)
Actions.sh("xxd -plain -revert #{paths[:hex]} > #{paths[:file]}") do |status|
UI.user_error!("Error reverting hex to keystore") unless status.success?
end
File.delete(paths[:hex])
end
def version_name_and_update_code!(build_gradle_path, build_number)
gradle_contents = File.read(build_gradle_path)
File.write(build_gradle_path, gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}"))
version_name = File.read(build_gradle_path)[/versionName\s+"([^"]+)"/, 1]
UI.user_error!("Failed to extract versionName from #{build_gradle_path}") if version_name.nil? || version_name.empty?
version_name
end
def branch_name
raw = ENV['GITHUB_HEAD_REF'] || ENV['GITHUB_REF_NAME'] || `git rev-parse --abbrev-ref HEAD`.strip
sanitized = raw.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
sanitized.empty? ? 'master' : sanitized
end
def apk_name(version_name, build_number, branch)
branch != 'master' ? "BlueWallet-#{version_name}-#{build_number}-#{branch}.apk" : "BlueWallet-#{version_name}-#{build_number}.apk"
end
def apksigner_path
sdk_root = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT']
Dir.glob(File.join(sdk_root.to_s, 'build-tools', '*', 'apksigner')).sort.last
end
def gradle_log_path
path = File.join(project_root, 'fastlane', 'logs', 'gradle-build.log')
FileUtils.mkdir_p(File.dirname(path))
path
end
def assemble_release!(log_path:)
Actions.sh("cd android && ./gradlew assembleRelease --no-daemon --stacktrace --console=plain | tee #{log_path}") do |status|
UI.user_error!("Gradle assembleRelease failed") unless status.success?
end
end
def resolve_apk_paths(version_name:, build_number:, branch_name:)
apk_dir = File.join(project_root, 'android', 'app', 'build', 'outputs', 'apk', 'release')
unsigned = File.join(apk_dir, 'app-release-unsigned.apk')
fallback = File.join(apk_dir, 'app-release.apk')
signed = File.join(apk_dir, apk_name(version_name, build_number, branch_name))
{ unsigned: unsigned, fallback: fallback, signed: signed }
end
def finalize_apk!(paths)
candidate = File.exist?(paths[:unsigned]) ? paths[:unsigned] : paths[:fallback]
UI.user_error!("Unsigned APK not found at path: #{paths[:unsigned]} or #{paths[:fallback]}") unless File.exist?(candidate)
FileUtils.mv(candidate, paths[:signed])
paths[:signed]
end
def sign_apk!(apk_path, keystore_path, keystore_password)
signer = apksigner_path
UI.user_error!("apksigner not found in Android build-tools") if signer.nil? || signer.empty?
Actions.sh("#{signer} sign --ks #{keystore_path} --ks-pass=pass:#{keystore_password} #{apk_path}")
end
def write_github_output(hash)
return unless ENV['GITHUB_OUTPUT'] && !ENV['GITHUB_OUTPUT'].empty?
File.open(ENV['GITHUB_OUTPUT'], 'a') do |f|
hash.each { |key, value| f.puts("#{key}=#{value}") }
end
end
def ensure_temp_credentials!(auto: false)
return unless auto
temp_dir = Dir.mktmpdir('bw-temp-keystore')
keystore_path = File.join(temp_dir, 'temp.keystore')
password = "temp#{SecureRandom.hex(6)}"
alias_name = "bluewallet-temp"
Actions.sh(
"keytool -genkeypair -v -keystore #{keystore_path} -storepass #{password} -keypass #{password} " \
"-alias #{alias_name} -keyalg RSA -keysize 2048 -validity 10000 " \
"-dname \"CN=Temp,O=BlueWallet,OU=CI,L=NY,ST=NY,C=US\""
) do |status|
UI.user_error!("Failed to create temporary keystore with keytool") unless status.success?
end
keystore_hex = File.binread(keystore_path).unpack1('H*')
ENV['KEYSTORE_FILE_HEX'] = keystore_hex
ENV['KEYSTORE_PASSWORD'] = password
end
end
# Add session caching for App Store Connect
def cached_app_store_connect_login
# Skip if already authenticated and token is not expired
if defined?(Spaceship::ConnectAPI) && Spaceship::ConnectAPI.token && !Spaceship::ConnectAPI.token.expired?
UI.message("Using existing App Store Connect session")
return true
end
UI.message("Logging in to App Store Connect...")
# Try API key authentication first
api_key_path = ENV["APPLE_API_KEY_PATH"] || "./appstore_api_key.json"
if File.exist?(api_key_path) && ENV["APPLE_API_KEY_ID"] && ENV["APPLE_API_ISSUER_ID"]
UI.message("Using API key authentication for App Store Connect")
api_key = app_store_connect_api_key(
key_id: ENV["APPLE_API_KEY_ID"],
issuer_id: ENV["APPLE_API_ISSUER_ID"],
key_filepath: api_key_path,
duration: 1200, # 20 minute session
in_house: false
)
# Store the API key in lane context for reuse
ENV["SPACESHIP_CONNECT_API_KEY"] = api_key
# Force App Store Connect API to use this key
Spaceship::ConnectAPI.token = api_key
UI.success("Successfully authenticated with App Store Connect API Key")
return true
elsif ENV["FASTLANE_USER"] && ENV["FASTLANE_PASSWORD"]
UI.message("Using username/password from environment variables")
# Use credentials from environment variables
Spaceship::ConnectAPI.login(
use_portal: true,
use_tunes: true,
portal_team_id: ENV["TEAM_ID"],
tunes_team_id: ENV["ITC_TEAM_ID"]
)
UI.success("Successfully authenticated with Apple ID")
return true
else
UI.message("Using interactive username/password authentication")
# Last resort - interactive login
Spaceship::ConnectAPI.login(
use_portal: true,
use_tunes: true
)
UI.success("Successfully authenticated with Apple ID")
return true
end
end
before_all do |lane, options|
if ENV['SKIP_APP_STORE_CONNECT_AUTH'] == '1'
UI.message('Skipping App Store Connect authentication (SKIP_APP_STORE_CONNECT_AUTH=1)')
next
end
skip_auth_lanes = ['register_devices_from_txt', 'build_catalyst_app_lane', 'install_pods', 'clear_derived_data_lane']
lane_name = lane.to_s
should_skip_auth = skip_auth_lanes.any? { |skip_lane| lane_name == skip_lane || lane_name.end_with?(" #{skip_lane}") }
# Check if we need App Store Connect for this lane
unless should_skip_auth
begin
# Try to authenticate once at the beginning
require 'spaceship'
cached_app_store_connect_login
rescue => ex
UI.error("Authentication failed: #{ex.message}")
# Continue anyway as some lanes might not need authentication
end
end
end
# ===========================
# Android Lanes
# ===========================
platform :android do
desc "Prepare the keystore file"
lane :prepare_keystore do
Dir.chdir(PROJECT_ROOT) do
paths = AndroidHelpers.keystore_paths
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], paths)
UI.message("Keystore created successfully.")
end
end
desc "Update version, build number, and sign APK"
lane :update_version_build_and_sign_apk do |options|
Dir.chdir(PROJECT_ROOT) do
AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials])
AndroidHelpers.require_env!(%w[BUILD_NUMBER KEYSTORE_PASSWORD KEYSTORE_FILE_HEX])
keystore_paths = AndroidHelpers.keystore_paths
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths)
build_gradle_path = File.join('android', 'app', 'build.gradle')
version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, ENV['BUILD_NUMBER'])
branch = AndroidHelpers.branch_name
apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: ENV['BUILD_NUMBER'], branch_name: branch)
UI.message("Building APK...")
AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path)
UI.message("APK build completed.")
signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths)
ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path)
UI.message("Signing APK with apksigner...")
AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD'])
UI.message("APK signed successfully: #{signed_apk_path}")
FileUtils.rm_f(keystore_paths[:file])
end
end
desc "Build and sign release APK"
lane :build_release_apk do |options|
Dir.chdir(PROJECT_ROOT) do
# Allow caller to pass a build_number; otherwise fall back to env or timestamp
build_number = options[:build_number] || ENV['BUILD_NUMBER'] || Time.now.to_i.to_s
ENV['BUILD_NUMBER'] = build_number
AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials])
AndroidHelpers.require_env!(%w[KEYSTORE_FILE_HEX KEYSTORE_PASSWORD])
keystore_paths = AndroidHelpers.keystore_paths
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths)
build_gradle_path = File.join('android', 'app', 'build.gradle')
version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, build_number)
branch = AndroidHelpers.branch_name
UI.message("Building release APK...")
AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path)
apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: build_number, branch_name: branch)
signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths)
UI.message("Signing APK with apksigner...")
AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD'])
UI.success("APK signed successfully: #{signed_apk_path}")
FileUtils.rm_f(keystore_paths[:file])
apk_absolute_path = File.expand_path(signed_apk_path)
ENV['APK_OUTPUT_PATH'] = apk_absolute_path
ENV['APK_VERSION_NAME'] = version_name
ENV['APK_VERSION_CODE'] = build_number
AndroidHelpers.write_github_output(
apk_output_path: apk_absolute_path,
apk_version_name: version_name,
apk_version_code: build_number,
)
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
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
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']
)
app_url = ENV['BROWSERSTACK_LIVE_APP_ID']
UI.user_error!("BrowserStack upload failed, no app URL returned") if app_url.nil? || app_url.empty?
apk_filename = File.basename(apk_path)
apk_download_url = ENV['APK_OUTPUT_PATH']
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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
- [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&browser=chrome)
**Filename**: [#{apk_filename}](#{apk_download_url})
**BrowserStack App URL**: #{app_url}
COMMENT
if pr_number
begin
repo = ENV['GITHUB_REPOSITORY']
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
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
end
# ===========================
# iOS Lanes
# ===========================
platform :ios do
# Add helper methods for error handling and retries
def ensure_env_vars(vars)
vars.each do |var|
UI.user_error!("#{var} environment variable is missing") if ENV[var].nil? || ENV[var].empty?
end
end
def log_success(message)
UI.success("#{message}")
end
def log_error(message)
UI.error("#{message}")
end
# Method to safely call actions with retry logic
def with_retry(max_attempts = 3, action_name = "")
attempts = 0
begin
attempts += 1
yield
rescue => e
if attempts < max_attempts
wait_time = 10 * attempts
log_error("Attempt #{attempts}/#{max_attempts} for #{action_name} failed: #{e.message}")
UI.message("Retrying in #{wait_time} seconds...")
sleep(wait_time)
retry
else
log_error("#{action_name} failed after #{max_attempts} attempts: #{e.message}")
raise e
end
end
end
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
required_vars = ["GIT_ACCESS_TOKEN", "GIT_URL", "ITC_TEAM_ID", "ITC_TEAM_NAME", "KEYCHAIN_PASSWORD"]
ensure_env_vars(required_vars)
UI.message("Setting up provisioning profiles...")
# Iterate over app identifiers to fetch provisioning profiles
app_identifiers.each do |app_identifier|
with_retry(3, "Fetching provisioning profile for #{app_identifier}") do
UI.message("Fetching provisioning profile for #{app_identifier}...")
match(
git_basic_authorization: ENV["GIT_ACCESS_TOKEN"],
git_url: ENV["GIT_URL"],
type: "appstore",
clone_branch_directly: true,
platform: "ios",
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"]
)
log_success("Successfully fetched provisioning profile for #{app_identifier}")
end
end
log_success("All provisioning profiles set up")
end
# Only these targets support Mac Catalyst (watch/stickers do not)
def catalyst_app_identifiers
[
"io.bluewallet.bluewallet",
"io.bluewallet.bluewallet.MarketWidget"
]
end
desc "Fetch development certificates and provisioning profiles for Mac Catalyst"
lane :fetch_dev_profiles_catalyst do
match(
type: "development",
platform: "catalyst",
app_identifier: catalyst_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: catalyst_app_identifiers,
readonly: true,
clone_branch_directly: true
)
end
desc "Create provisioning profiles for Mac Catalyst (first-time setup)"
lane :setup_catalyst_provisioning_profiles do
catalyst_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",
try_repo_update_on_error: true,
repo_update: true,
clean_install: true)
end
desc "Build Mac Catalyst app, handle code signing, create DMG with multilingual README, and set GitHub outputs"
lane :build_catalyst_app_lane do
Dir.chdir(project_root) do
UI.message("Building Mac Catalyst application from: #{Dir.pwd}")
workspace_path = File.join(project_root, "ios", "BlueWallet.xcworkspace")
derived_data_path = File.join(project_root, "ios", "build", "catalyst-derived-data")
output_dir = File.join(project_root, "ios", "build", "catalyst-output")
if ENV['SKIP_CLEAR_DERIVED_DATA'] == '1'
UI.message('Skipping clear_derived_data_lane (SKIP_CLEAR_DERIVED_DATA=1)')
else
clear_derived_data_lane
end
FileUtils.mkdir_p(derived_data_path)
FileUtils.mkdir_p(output_dir)
# Only these targets support Mac Catalyst
catalyst_identifiers = [
"io.bluewallet.bluewallet",
"io.bluewallet.bluewallet.MarketWidget"
]
has_signing_creds = ENV['CATALYST_SIGNING_IDENTITY'] && !ENV['CATALYST_SIGNING_IDENTITY'].empty? &&
ENV['CATALYST_TEAM_ID'] && !ENV['CATALYST_TEAM_ID'].empty?
has_match_creds = ENV['GIT_URL'] && !ENV['GIT_URL'].empty? &&
ENV['GIT_ACCESS_TOKEN'] && !ENV['GIT_ACCESS_TOKEN'].empty?
should_sign = has_signing_creds && has_match_creds && ENV['CATALYST_SKIP_CODESIGNING'] != '1'
signing_identity = ENV['CATALYST_SIGNING_IDENTITY'] || "Apple Distribution"
xcargs_str = "ARCHS=arm64 ONLY_ACTIVE_ARCH=YES"
if should_sign
UI.message("Setting up Mac Catalyst provisioning profiles via match...")
team_id = ENV['CATALYST_TEAM_ID']
match_readonly = ENV['MATCH_READONLY'] != 'false'
# Create/fetch provisioning profiles for catalyst targets
catalyst_identifiers.each do |app_id|
match(
type: "appstore",
platform: "catalyst",
app_identifier: app_id,
team_id: team_id,
git_url: ENV['GIT_URL'],
git_basic_authorization: ENV['GIT_ACCESS_TOKEN'],
readonly: match_readonly,
clone_branch_directly: true,
keychain_name: ENV['KEYCHAIN_NAME'] || "login",
keychain_password: ENV['KEYCHAIN_PASSWORD'] || ""
)
end
xcargs_str += " DEVELOPMENT_TEAM=#{team_id}"
xcargs_str += " CODE_SIGN_IDENTITY=\"#{signing_identity}\""
xcargs_str += " CODE_SIGN_STYLE=Manual"
# Set provisioning profile specifiers per catalyst target
catalyst_identifiers.each do |app_id|
profile_name = "match AppStore #{app_id} catalyst"
# Convert bundle ID to xcodebuild target setting key
# e.g., io.bluewallet.bluewallet -> PROVISIONING_PROFILE_SPECIFIER for that target
xcargs_str += " PROVISIONING_PROFILE_SPECIFIER_#{app_id.gsub('.', '_')}=\"#{profile_name}\""
end
UI.success("Provisioning profiles configured for Mac Catalyst")
else
# Disable code signing entirely so xcodebuild doesn't look for provisioning profiles
xcargs_str += " CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO"
UI.message("No signing credentials provided — building without code signing")
end
build_app(
scheme: "BlueWallet",
workspace: workspace_path,
configuration: "Release",
destination: "generic/platform=macOS,variant=Mac Catalyst",
xcargs: xcargs_str,
clean: true,
skip_codesigning: !should_sign,
skip_package_ipa: true,
derived_data_path: derived_data_path,
buildlog_path: File.join(project_root, "ios", "build_logs")
)
archive_path = lane_context[SharedValues::XCODEBUILD_ARCHIVE]
if archive_path.nil? || archive_path.empty?
archive_path = Dir.glob(File.join(Dir.home, "Library/Developer/Xcode/Archives/**/*.xcarchive")).max_by { |path| File.mtime(path) }
end
candidate_paths = []
candidate_paths << File.join(archive_path, "Products/Applications/BlueWallet.app") if archive_path
candidate_paths << Dir.glob(File.join(derived_data_path, "Build/Products/Release-maccatalyst/*.app")).first
candidate_paths << Dir.glob(File.join(derived_data_path, "Build/Intermediates.noindex/ArchiveIntermediates/BlueWallet/BuildProductsPath/Release-maccatalyst/*.app")).first
catalyst_app_path = candidate_paths.compact.find { |path| File.exist?(path) }
UI.user_error!("Mac Catalyst app was not found after build") if catalyst_app_path.nil?
UI.success("Mac Catalyst app found at: #{catalyst_app_path}")
if should_sign && ENV['CATALYST_SIGNING_IDENTITY'] && !ENV['CATALYST_SIGNING_IDENTITY'].empty?
UI.message("Re-signing app with: #{signing_identity}")
sh("codesign",
"--deep",
"--force",
"--options", "runtime",
"--sign", signing_identity,
catalyst_app_path)
sh("codesign",
"--verify",
"--deep",
"--strict",
catalyst_app_path)
UI.success("App signed and verified")
else
# Ad-hoc sign so macOS doesn't treat the app as "damaged"
UI.message("Ad-hoc signing app to avoid Gatekeeper quarantine issues...")
sh("codesign",
"--deep",
"--force",
"--sign", "-",
catalyst_app_path)
UI.success("App ad-hoc signed")
end
dmg_path = File.join(output_dir, "BlueWallet-Mac-Catalyst.dmg")
UI.message("Creating DMG at: #{dmg_path}")
dmg_staging = File.join(output_dir, "dmg-staging")
FileUtils.rm_rf(dmg_staging)
FileUtils.mkdir_p(dmg_staging)
FileUtils.cp_r(catalyst_app_path, dmg_staging)
sh("ln", "-s", "/Applications", "#{dmg_staging}/Applications")
# Add README with first-launch instructions
readme_path = File.join(dmg_staging, "README - Read Before Opening.txt")
File.write(readme_path, <<~README)
BlueWallet for macOS
ENGLISH
INSTALLATION: Drag "BlueWallet.app" into "Applications".
FIRST LAUNCH macOS may show a warning. To open:
Right-click the app Open click "Open" in the dialog.
Or: System Settings Privacy & Security Open Anyway.
Or (Terminal): xattr -cr /Applications/BlueWallet.app
You only need to do this once.
ESPAÑOL
INSTALACIÓN: Arrastra "BlueWallet.app" a "Aplicaciones".
PRIMER INICIO macOS puede mostrar una advertencia. Para abrir:
Haz clic derecho en la app Abrir clic en "Abrir".
O: Ajustes del Sistema Privacidad y Seguridad Abrir igualmente.
O (Terminal): xattr -cr /Applications/BlueWallet.app
Solo necesitas hacerlo una vez.
"BlueWallet.app" "应用程序"
macOS
"打开"
xattr -cr /Applications/BlueWallet.app
PORTUGUÊS
INSTALAÇÃO: Arraste "BlueWallet.app" para "Aplicativos".
PRIMEIRA ABERTURA o macOS pode exibir um aviso. Para abrir:
Clique com o botão direito Abrir clique em "Abrir".
Ou: Ajustes do Sistema Privacidade e Segurança Abrir Mesmo Assim.
Ou (Terminal): xattr -cr /Applications/BlueWallet.app
Você precisa fazer isso uma vez.
РУССКИЙ
УСТАНОВКА: Перетащите "BlueWallet.app" в "Программы".
ПЕРВЫЙ ЗАПУСК macOS может показать предупреждение. Чтобы открыть:
Нажмите правой кнопкой Открыть нажмите «Открыть».
Или: Системные настройки Конфиденциальность Подтвердить открытие.
Или (Терминал): xattr -cr /Applications/BlueWallet.app
Это нужно сделать только один раз.
BlueWallet.app
macOS
xattr -cr /Applications/BlueWallet.app
DEUTSCH
INSTALLATION: Ziehe BlueWallet.app" in den Ordner „Programme".
ERSTER START macOS zeigt möglicherweise eine Warnung. Zum Öffnen:
Rechtsklick auf die App Öffnen Öffnen" klicken.
• Oder: Systemeinstellungen → Datenschutz & Sicherheit → Dennoch öffnen.
• Oder (Terminal): xattr -cr /Applications/BlueWallet.app
Dies ist nur beim ersten Mal nötig.
═══ FRANÇAIS ═══════════════════════════════════════════════════
INSTALLATION : Glissez « BlueWallet.app » dans « Applications ».
PREMIER LANCEMENT — macOS peut afficher un avertissement. Pour ouvrir :
• Clic droit sur l'app → Ouvrir → cliquez sur « Ouvrir ».
• Ou : Réglages Système → Confidentialité et sécurité → Ouvrir quand même.
• Ou (Terminal) : xattr -cr /Applications/BlueWallet.app
Cette opération n'est nécessaire qu'une seule fois.
═══ العربية ════════════════════════════════════════════════════
التثبيت: اسحب "BlueWallet.app" إلى مجلد "التطبيقات".
التشغيل الأول — قد يعرض macOS تحذيرًا. لفتح التطبيق:
• انقر بزر الماوس الأيمن → افتح → انقر "فتح" في مربع الحوار.
• أو: إعدادات النظام → الخصوصية والأمان → فتح على أي حال.
• أو (الطرفية): xattr -cr /Applications/BlueWallet.app
تحتاج للقيام بذلك مرة واحدة فقط.
────────────────────────────────────────────────────────────────
https://bluewallet.io
README
UI.message("Added multilingual README with first-launch instructions to DMG")
FileUtils.rm_f(dmg_path)
sh("hdiutil", "create", "-volname", "BlueWallet", "-srcfolder", dmg_staging, "-ov", "-format", "UDZO", dmg_path)
UI.user_error!("DMG was not created at #{dmg_path}") unless File.exist?(dmg_path)
UI.success("DMG created at: #{dmg_path}")
FileUtils.rm_rf(dmg_staging)
ENV['CATALYST_APP_PATH'] = catalyst_app_path
ENV['CATALYST_DMG_PATH'] = dmg_path
if ENV['GITHUB_OUTPUT']
File.open(ENV['GITHUB_OUTPUT'], 'a') do |f|
f.puts "catalyst_app_path=#{catalyst_app_path}"
f.puts "catalyst_dmg_path=#{dmg_path}"
end
end
UI.success("macOS app built at: #{catalyst_app_path}")
UI.success("macOS DMG at: #{dmg_path}")
end
end
desc "Upload Mac Catalyst app to TestFlight"
lane :upload_catalyst_to_testflight do
Dir.chdir(project_root) do
# Locate the xcarchive
archive_path = lane_context[SharedValues::XCODEBUILD_ARCHIVE]
if archive_path.nil? || archive_path.empty?
archive_path = Dir.glob(File.join(Dir.home, "Library/Developer/Xcode/Archives/**/*.xcarchive")).max_by { |path| File.mtime(path) }
end
UI.user_error!("No xcarchive found for TestFlight upload") if archive_path.nil? || !File.exist?(archive_path)
UI.message("Using archive: #{archive_path}")
output_dir = File.join(project_root, "ios", "build", "catalyst-output")
FileUtils.mkdir_p(output_dir)
# Export the archive as a .pkg for Mac Catalyst
team_id = ENV['CATALYST_TEAM_ID'] || ENV['TEAM_ID']
export_plist_path = File.join(output_dir, "ExportOptions.plist")
# Build provisioning profiles mapping for manual signing
profiles_xml = catalyst_app_identifiers.map do |app_id|
profile_name = "match AppStore #{app_id} catalyst"
"\t\t\t<key>#{app_id}</key>\n\t\t\t<string>#{profile_name}</string>"
end.join("\n")
File.write(export_plist_path, <<~PLIST)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>destination</key>
<string>upload</string>
<key>teamID</key>
<string>#{team_id}</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
#{profiles_xml}
</dict>
</dict>
</plist>
PLIST
pkg_path = File.join(output_dir, "BlueWallet.pkg")
sh("xcodebuild",
"-exportArchive",
"-archivePath", archive_path,
"-exportOptionsPlist", export_plist_path,
"-exportPath", output_dir)
# Find the exported pkg
exported_pkg = Dir.glob(File.join(output_dir, "*.pkg")).first
UI.user_error!("No .pkg found after export") if exported_pkg.nil? || !File.exist?(exported_pkg)
UI.success("Exported pkg: #{exported_pkg}")
# Build changelog
branch_name = ENV['BRANCH_NAME'] || "unknown-branch"
last_commit_message = ENV['LATEST_COMMIT_MESSAGE'] || "No commit message found"
changelog = "Build Information:\n"
changelog += "- Branch: #{branch_name}\n" if branch_name != 'master'
changelog += "- Commit: #{last_commit_message}\n"
# Upload to TestFlight
upload_to_testflight(
api_key_path: "./appstore_api_key.json",
pkg: exported_pkg,
skip_waiting_for_build_processing: true,
changelog: changelog
)
UI.success("Successfully uploaded Mac Catalyst app to TestFlight!")
end
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
UI.message("\033[1;34m==================== FASTLANE BUILD DEBUG ====================\033[0m")
# Comprehensive environment check
UI.message("\033[1;36mEnvironment Analysis:\033[0m")
UI.message(" Project Root: #{project_root}")
UI.message(" Current Directory: #{Dir.pwd}")
UI.message(" Ruby Version: #{RUBY_VERSION}")
UI.message(" Fastlane Version: #{Fastlane::VERSION}")
UI.message(" Build Mode: Release (App Store)")
# Ensure we're using the correct Xcode installation
UI.message("\033[1;36mXcode Configuration:\033[0m")
begin
sh("sudo xcode-select -s /Applications/Xcode.app") rescue nil
xcode_path = sh('xcode-select -p', log: false).strip
xcode_version = sh('xcodebuild -version', log: false).strip
UI.message(" Active Xcode Path: #{xcode_path}")
UI.message(" Xcode Version: #{xcode_version}")
# Check if Xcode path is valid
if File.exist?(File.join(xcode_path, "usr/bin/xcodebuild"))
UI.success(" \033[1;32mXcode installation is valid\033[0m")
else
UI.error(" \033[1;31mXcode installation appears invalid\033[0m")
end
rescue => e
UI.error(" \033[1;31mError checking Xcode: #{e.message}\033[0m")
end
# Project structure analysis
UI.message("\033[1;36mProject Structure Analysis:\033[0m")
%w[
ios/BlueWallet.xcworkspace
ios/BlueWallet.xcodeproj
ios/export_options.plist
ios/Podfile
ios/Podfile.lock
].each do |file_path|
full_path = File.join(project_root, file_path)
if File.exist?(full_path)
UI.message(" \033[1;32m#{file_path} exists\033[0m")
if file_path.end_with?('.plist')
UI.message(" Content preview:")
content = File.read(full_path).lines.first(10).join.strip
UI.message(" #{content[0..200]}...")
end
else
UI.error(" \033[1;31m#{file_path} missing\033[0m")
end
end
# Environment variables check
UI.message("\033[1;36mEnvironment Variables:\033[0m")
%w[PROJECT_VERSION NEW_BUILD_NUMBER].each do |var|
value = ENV[var]
if value && !value.empty?
UI.message(" \033[1;32m#{var}: #{value}\033[0m")
else
UI.error(" \033[1;31m#{var}: Not set or empty\033[0m")
end
end
# Determine which iOS version to use
UI.message("\033[1;36miOS Version Analysis:\033[0m")
ios_version = determine_ios_version
UI.message(" Selected iOS version: #{ios_version}")
UI.message("\033[1;36mBuild Configuration:\033[0m")
UI.message(" Workspace: #{workspace_path}")
UI.message(" Export options: #{export_options_path}")
UI.message(" Output directory: #{File.join(project_root, 'ios', 'build')}")
UI.message(" Build type: Release (generic/platform=iOS)")
# Comprehensive destination analysis
UI.message("\033[1;33mBuild Destinations Analysis:\033[0m")
begin
destinations_output = sh("xcodebuild -workspace '#{workspace_path}' -scheme BlueWallet -showdestinations", log: false)
UI.message(" Available destinations:")
destinations_output.lines.each_with_index do |line, index|
UI.message(" #{index + 1}. #{line.strip}") if line.strip.length > 0
end
# Analyze destination types
ios_destinations = destinations_output.scan(/platform:iOS[^}]*/).length
catalyst_destinations = destinations_output.scan(/Mac Catalyst/).length
UI.message(" \033[1;36mDestination Summary:\033[0m")
UI.message(" iOS destinations: #{ios_destinations}")
UI.message(" Mac Catalyst destinations: #{catalyst_destinations}")
if ios_destinations > 0
UI.success(" \033[1;32miOS destinations available for release build\033[0m")
else
UI.important(" \033[1;33mNo iOS destinations found - may need to create simulators\033[0m")
end
rescue => e
UI.error(" \033[1;31mFailed to get destinations: #{e.message}\033[0m")
destinations_output = "Failed to get destinations: #{e.message}"
end
# Simulator runtime check for development/debugging
UI.message("\033[1;33mSimulator Runtime Check (for debugging):\033[0m")
begin
runtimes = sh("xcrun simctl list runtimes", log: false)
ios_runtimes = runtimes.scan(/iOS ([0-9.]+)/).flatten
UI.message(" Available iOS runtimes: #{ios_runtimes.join(', ')}")
devices = sh("xcrun simctl list devices iOS", log: false)
iphone_count = devices.scan(/iPhone/).length
UI.message(" Available iPhone simulators: #{iphone_count}")
rescue => e
UI.error(" \033[1;31mError checking simulators: #{e.message}\033[0m")
end
# Define the IPA output path before building
ipa_directory = File.join(project_root, "ios", "build")
ipa_name = "BlueWallet_#{ENV['PROJECT_VERSION']}_#{ENV['NEW_BUILD_NUMBER']}.ipa"
ipa_path = File.join(ipa_directory, ipa_name)
UI.message("\033[1;36mBuild Output Configuration:\033[0m")
UI.message(" IPA Directory: #{ipa_directory}")
UI.message(" IPA Name: #{ipa_name}")
UI.message(" Full IPA Path: #{ipa_path}")
# Ensure build directory exists
FileUtils.mkdir_p(ipa_directory) unless Dir.exist?(ipa_directory)
begin
UI.message("🚀 Starting iOS Build Process...")
UI.message(" Build Parameters:")
UI.message(" Scheme: BlueWallet")
UI.message(" Workspace: #{workspace_path}")
UI.message(" Export Method: app-store")
UI.message(" Export Options: #{export_options_path}")
UI.message(" Output Directory: #{ipa_directory}")
UI.message(" Output Name: #{ipa_name}")
UI.message(" Build Logs: #{File.join(project_root, 'ios', 'build_logs')}")
UI.message(" Destination: generic/platform=iOS")
# Pre-build validation
UI.message("🔍 Pre-Build Validation:")
# Check workspace
if File.exist?(workspace_path)
UI.success(" ✅ Workspace exists")
else
UI.user_error!(" ❌ Workspace not found: #{workspace_path}")
end
# Check export options
if File.exist?(export_options_path)
UI.success(" ✅ Export options exist")
# Read and validate export options
begin
export_content = File.read(export_options_path)
UI.message(" Export options preview:")
export_content.lines.first(5).each { |line| UI.message(" #{line.strip}") }
rescue => e
UI.error(" ⚠️ Could not read export options: #{e.message}")
end
else
UI.user_error!(" ❌ Export options not found: #{export_options_path}")
end
build_ios_app(
scheme: "BlueWallet",
workspace: workspace_path,
export_method: "app-store",
export_options: export_options_path,
output_directory: ipa_directory,
output_name: ipa_name,
buildlog_path: File.join(project_root, "ios", "build_logs")
# Removed explicit destination - let Xcode determine the best destination for release builds
)
UI.success("\033[1;32miOS release build completed successfully!\033[0m")
rescue => build_error
UI.error("\033[1;31miOS release build failed!\033[0m")
UI.error(" Error Class: #{build_error.class}")
UI.error(" Error Message: #{build_error.message}")
UI.error(" \033[1;31mError Backtrace:\033[0m")
build_error.backtrace.first(5).each { |line| UI.error(" #{line}") }
UI.message("\033[1;33mPost-Build Debugging:\033[0m")
# Check for partial build artifacts
build_logs_dir = File.join(project_root, "ios", "build_logs")
if Dir.exist?(build_logs_dir)
UI.message(" \033[1;36mBuild logs directory contents:\033[0m")
Dir.entries(build_logs_dir).each do |file|
next if file.start_with?('.')
file_path = File.join(build_logs_dir, file)
size = File.size(file_path) rescue 0
UI.message(" #{file} (#{size} bytes)")
end
end
# Check for xcarchive files
archives = Dir.glob(File.join(Dir.home, "Library/Developer/Xcode/Archives/**/*.xcarchive"))
if archives.any?
UI.message(" \033[1;36mRecent archives found:\033[0m")
archives.last(3).each { |archive| UI.message(" #{archive}") }
end
# If iOS build fails, check if we can build using available destinations
if destinations_output.include?("Any iOS Device")
UI.message("Retrying build without explicit destination...")
build_ios_app(
scheme: "BlueWallet",
workspace: workspace_path,
export_method: "app-store",
export_options: export_options_path,
output_directory: ipa_directory,
output_name: ipa_name,
buildlog_path: File.join(project_root, "ios", "build_logs")
)
else
UI.user_error!("build_ios_app failed: #{build_error.message}")
end
end
# Check for IPA path from both our defined path and fastlane's context
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH] || ipa_path
# Ensure the directory exists
FileUtils.mkdir_p(File.dirname(ipa_path)) unless Dir.exist?(File.dirname(ipa_path))
if ipa_path && File.exist?(ipa_path)
UI.message("IPA successfully found at: #{ipa_path}")
else
# Try to find any IPA file as fallback
Dir.chdir(project_root) do
fallback_ipa = Dir.glob("**/*.ipa").first
if fallback_ipa
ipa_path = File.join(project_root, fallback_ipa)
UI.message("Found fallback IPA at: #{ipa_path}")
else
UI.user_error!("No IPA file found after build")
end
end
end
# Set both environment variable and GitHub Actions output
ENV['IPA_OUTPUT_PATH'] = ipa_path
# Set both standard output format and the newer GITHUB_OUTPUT format
sh("echo 'ipa_output_path=#{ipa_path}' >> $GITHUB_OUTPUT") if ENV['GITHUB_OUTPUT']
sh("echo ::set-output name=ipa_output_path::#{ipa_path}")
# Also write path to a file that can be read by subsequent steps
ipa_path_file = "#{ipa_directory}/ipa_path.txt"
File.write(ipa_path_file, ipa_path)
UI.success("Saved IPA path to: #{ipa_path_file}")
end
end
desc "Delete temporary keychain"
lane :delete_temp_keychain do
UI.message("Deleting temporary keychain...")
delete_keychain(
name: "temp_keychain"
) if File.exist?(File.expand_path("~/Library/Keychains/temp_keychain-db"))
UI.message("Temporary keychain deleted successfully.")
end
# Helper method to determine which iOS version to use
# Updated for macOS-15 compatibility (defaults to iOS 17.5 for broader compatibility)
private_lane :determine_ios_version do
UI.message("\033[1;33mDetermining iOS Version for Release Build:\033[0m")
begin
runtimes_output = sh("xcrun simctl list runtimes 2>&1", log: false)
UI.message(" \033[1;32mSuccessfully retrieved simulator runtimes\033[0m")
# Debug: Show all runtimes
UI.message(" \033[1;36mAll available runtimes:\033[0m")
runtimes_output.lines.first(10).each_with_index do |line, index|
UI.message(" #{index + 1}. #{line.strip}")
end
rescue => e
UI.error(" \033[1;31mFailed to get simulator runtimes: #{e.message}\033[0m")
runtimes_output = ""
end
if runtimes_output.include?("iOS")
UI.message(" \033[1;36miOS runtimes detected\033[0m")
begin
ios_versions = runtimes_output.scan(/iOS ([0-9.]+)/)
.flatten
.map { |v| Gem::Version.new(v) }
.sort
.reverse
UI.message(" \033[1;36mParsed iOS versions:\033[0m")
ios_versions.each_with_index do |version, index|
UI.message(" #{index + 1}. iOS #{version}")
end
if ios_versions.any?
latest_version = ios_versions.first.to_s
UI.success(" \033[1;32mSelected iOS version for release: #{latest_version}\033[0m")
# Additional validation
if Gem::Version.new(latest_version) >= Gem::Version.new("17.0")
UI.success(" \033[1;32mVersion is compatible for release builds (17.0+)\033[0m")
else
UI.important(" \033[1;33mVersion is older than 17.0, may have compatibility issues\033[0m")
end
latest_version
else
UI.important(" \033[1;33mNo iOS versions could be parsed from runtime output\033[0m")
UI.message(" \033[1;36mUsing fallback version for release: 17.5\033[0m")
"17.5"
end
rescue => e
UI.error(" \033[1;31mError parsing iOS versions: #{e.message}\033[0m")
UI.message(" \033[1;36mUsing fallback version for release: 17.5\033[0m")
"17.5"
end
else
UI.important(" \033[1;33mNo iOS runtimes found in simulator list\033[0m")
UI.message(" \033[1;36mRuntime output preview:\033[0m")
runtimes_output.lines.first(5).each { |line| UI.message(" #{line.strip}") }
UI.message(" \033[1;36mUsing fallback version for release: 17.5\033[0m")
"17.5"
end
end
end
# ===========================
# Global Lanes
# ===========================
desc "Deploy to TestFlight"
lane :deploy do |options|
UI.message("Starting deployment process...")
update_wwdr_certificate
setup_app_store_connect_api_key
setup_provisioning_profiles
clear_derived_data_lane
increment_build_number_lane
unless File.directory?("Pods")
install_pods
end
build_app_lane
upload_to_testflight_lane
delete_keychain(name: "temp_keychain")
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 release notes for App Store versions (iOS or Mac Catalyst)"
lane :release_notes do |options|
require 'spaceship'
app = Spaceship::ConnectAPI::App.find(app_identifiers.first)
UI.user_error!("Could not find the app with identifier: #{app_identifiers.first}") unless app
platform_option = options[:platform]
if platform_option
platform = case platform_option.to_s.downcase
when "ios"
UI.message("Using platform from options: iOS")
Spaceship::ConnectAPI::Platform::IOS
when "catalyst", "mac_catalyst", "mac-catalyst"
UI.message("Using platform from options: Mac Catalyst")
UI.message("Using Spaceship::ConnectAPI::Platform::MAC_OS for Mac Catalyst")
Spaceship::ConnectAPI::Platform::MAC_OS
else
UI.user_error!("Invalid platform option: #{platform_option}")
end
else
platform_selection = UI.select("Select platform for release notes:", ["iOS", "Mac Catalyst"])
platform = case platform_selection
when "iOS"
UI.message("Selected platform: iOS")
Spaceship::ConnectAPI::Platform::IOS
when "Mac Catalyst"
UI.message("Selected platform: Mac Catalyst")
UI.message("Using Spaceship::ConnectAPI::Platform::MAC_OS for Mac Catalyst")
Spaceship::ConnectAPI::Platform::MAC_OS
else
UI.user_error!("Invalid platform selection")
end
end
retries = 5
begin
rejected_version = nil
UI.message("Checking for Developer Rejected version for platform: #{platform}")
begin
filter = {
appStoreState: "DEVELOPER_REJECTED",
platformString: platform.to_s
}
app.get_app_store_versions(filter: filter).each do |version|
rejected_version = version
UI.message("Found rejected version: #{version.version_string}")
break
end
rescue => e
UI.error("Error fetching Developer Rejected versions: #{e.message}")
UI.message("Debug info: Platform type: #{platform.class}, Value: #{platform}")
end
if rejected_version
UI.success("Found 'Developer Rejected' version: #{rejected_version.version_string}. This will be the target for updates.")
prepare_version = rejected_version
else
UI.message("No Developer Rejected version found. Checking for version in edit mode or waiting for review...")
begin
prepare_version = app.get_edit_app_store_version(platform: platform)
if prepare_version
UI.message("Found version in edit mode: #{prepare_version.version_string}")
else
UI.message("No version in edit mode found")
end
rescue => e
UI.error("Error fetching edit app store version: #{e.message}")
prepare_version = nil
end
if prepare_version.nil?
UI.message("Checking for version in Waiting for Review status...")
begin
waiting_filter = {
platformString: platform.to_s,
appStoreState: "WAITING_FOR_REVIEW"
}
waiting_versions = app.get_app_store_versions(filter: waiting_filter)
if waiting_versions && !waiting_versions.empty?
prepare_version = waiting_versions.first
UI.success("Found version in Waiting for Review status: #{prepare_version.version_string}")
else
UI.message("No version in Waiting for Review status found")
end
rescue => e
UI.error("Error fetching Waiting for Review versions: #{e.message}")
end
end
if prepare_version.nil?
UI.message("Looking for any in-flight version...")
begin
all_versions = app.get_app_store_versions(filter: { platformString: platform.to_s })
UI.message("Found #{all_versions.count} versions for platform #{platform}:")
all_versions.each do |version|
state = app_store_state_readable(version.app_store_state)
UI.message(" - Version: #{version.version_string}, State: #{state}")
end
editable_states = ["PREPARE_FOR_SUBMISSION", "WAITING_FOR_REVIEW", "REJECTED", "METADATA_REJECTED", "DEVELOPER_REJECTED"]
editable_version = all_versions.find { |v| editable_states.include?(v.app_store_state) }
if editable_version
prepare_version = editable_version
UI.success("Using editable version: #{prepare_version.version_string} (#{app_store_state_readable(prepare_version.app_store_state)})")
elsif all_versions.count > 0
latest_version = all_versions.sort_by { |v| Gem::Version.new(v.version_string) }.last
UI.message("Latest version: #{latest_version.version_string} (#{app_store_state_readable(latest_version.app_store_state)})")
end
rescue => e
UI.error("Error listing versions: #{e.message}")
end
end
if prepare_version.nil?
UI.message("No editable version found.")
create_new_version = UI.confirm("Would you like to create a new version?")
if create_new_version
begin
UI.message("Fetching latest version for platform: #{platform}")
latest_version = app.get_latest_version(platform: platform)
if latest_version
UI.message("Latest version: #{latest_version.version_string}")
new_version_number = (latest_version.version_string.to_f + 0.1).round(1).to_s
else
UI.message("No latest version found. Using 1.0 as base")
new_version_number = "1.0"
end
UI.message("Creating new version: #{new_version_number} for platform: #{platform_selection || platform_option}")
prepare_version = app.create_version!(platform: platform, version_string: new_version_number)
UI.message("Created new version: #{new_version_number}")
rescue => e
UI.error("Failed to create new version: #{e.message}")
UI.user_error!("Failed to create version. Make sure your app is configured for Mac Catalyst in App Store Connect.")
end
else
UI.user_error!("No editable version found and user chose not to create one. Aborting.")
end
else
UI.message("Using version #{prepare_version.version_string} in state: #{app_store_state_readable(prepare_version.app_store_state)}")
end
end
rescue => e
retries -= 1
if retries > 0
delay = 20
UI.message("Cannot find app version info... Retrying after #{delay} seconds (remaining: #{retries})")
UI.error("Error details: #{e.message}")
sleep(delay)
retry
else
UI.user_error!("Failed to fetch or create the app version: #{e.message}")
end
end
localized_metadata = prepare_version.get_app_store_version_localizations
enabled_locales = localized_metadata.map(&:locale)
release_notes_text = options[:release_notes]
if release_notes_text.nil? || release_notes_text.strip.empty?
existing_release_notes = nil
en_us_localization = localized_metadata.find { |loc| loc.locale == 'en-US' }
if en_us_localization && en_us_localization.whats_new && !en_us_localization.whats_new.strip.empty?
existing_release_notes = en_us_localization.whats_new
UI.success("Found existing release notes in App Store Connect!")
else
localized_metadata.each do |loc|
if loc.whats_new && !loc.whats_new.strip.empty?
existing_release_notes = loc.whats_new
UI.success("Found existing release notes in App Store Connect for locale: #{loc.locale}")
break
end
end
end
ios_release_notes_path = "metadata/ios/en-US/release_notes.txt"
project_release_notes_path = "../release-notes.txt"
ios_notes_exist = File.exist?(ios_release_notes_path)
project_notes_exist = File.exist?(project_release_notes_path)
options_list = []
if existing_release_notes
options_list << "View/Edit existing App Store notes"
end
options_list += [
"Enter manually",
"Use clipboard content"
]
if project_notes_exist
options_list << "Use release-notes.txt file"
end
if ios_notes_exist
options_list << "Use iOS metadata release notes"
end
selection = UI.select("Select a source for release notes:", options_list)
case selection
when "View/Edit existing App Store notes"
UI.message("Existing release notes:")
UI.message("-" * 50)
UI.message(existing_release_notes)
UI.message("-" * 50)
edit_choice = UI.select("Do you want to edit these notes or use as-is?:", [
"Use as-is",
"Edit notes"
])
if edit_choice == "Edit notes"
require 'tempfile'
temp_file = Tempfile.new('release_notes')
temp_file.write(existing_release_notes)
temp_file.close
editor = ENV['EDITOR'] || 'nano'
system("#{editor} #{temp_file.path}")
release_notes_text = File.read(temp_file.path)
temp_file.unlink
UI.message("Edited release notes:")
UI.message("-" * 50)
UI.message(release_notes_text.length > 500 ? "#{release_notes_text[0..500]}..." : release_notes_text)
UI.message("-" * 50)
unless UI.confirm("Use these edited notes?")
UI.user_error!("User canceled edited notes. Aborting.")
end
else
release_notes_text = existing_release_notes
end
when "Enter manually"
release_notes_text = UI.input("Enter the release notes:")
if release_notes_text.nil? || release_notes_text.strip.empty?
UI.user_error!("No release notes provided. Aborting.")
end
when "Use clipboard content"
require 'open3'
stdout, stderr, status = Open3.capture3("pbpaste")
if !status.success? || stdout.strip.empty?
UI.user_error!("Failed to get clipboard content or clipboard is empty")
end
UI.message("Clipboard content preview:")
UI.message("-" * 50)
UI.message(stdout.length > 500 ? "#{stdout[0..500]}..." : stdout)
UI.message("-" * 50)
unless UI.confirm("Use this clipboard content for release notes?")
UI.user_error!("User canceled clipboard content usage. Aborting.")
end
release_notes_text = stdout
when "Use iOS metadata release notes"
release_notes_text = File.read(ios_release_notes_path)
UI.message("iOS metadata release notes preview:")
UI.message("-" * 50)
UI.message(release_notes_text.length > 500 ? "#{release_notes_text[0..500]}..." : release_notes_text)
UI.message("-" * 50)
unless UI.confirm("Use this content from iOS metadata release notes?")
UI.user_error!("User canceled file content usage. Aborting.")
end
when "Use release-notes.txt file"
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.txt file found. Aborting.")
end
release_notes_text = File.read(release_notes_path)
UI.message("release-notes.txt content preview:")
UI.message("-" * 50)
UI.message(release_notes_text.length > 500 ? "#{release_notes_text[0..500]}..." : release_notes_text)
UI.message("-" * 50)
unless UI.confirm("Use this content from release-notes.txt?")
UI.user_error!("User canceled file content usage. Aborting.")
end
end
end
if release_notes_text.nil? || release_notes_text.strip.empty?
UI.user_error!("No release notes content available. Aborting.")
end
localized_release_notes = {
'en-US' => release_notes_text,
'ar-SA' => release_notes_text,
'zh-Hans' => release_notes_text,
'hr' => release_notes_text,
'da' => release_notes_text,
'nl-NL' => release_notes_text,
'fi' => release_notes_text,
'fr-FR' => release_notes_text,
'de-DE' => release_notes_text,
'el' => release_notes_text,
'he' => release_notes_text,
'hu' => release_notes_text,
'it' => release_notes_text,
'ja' => release_notes_text,
'ms' => release_notes_text,
'nb' => release_notes_text,
'no' => release_notes_text,
'pl' => release_notes_text,
'pt-BR' => release_notes_text,
'pt-PT' => release_notes_text,
'ro' => release_notes_text,
'ru' => release_notes_text,
'es-MX' => release_notes_text,
'es-ES' => release_notes_text,
'sv' => release_notes_text,
'th' => release_notes_text,
}
if platform == Spaceship::ConnectAPI::Platform::MAC_OS
UI.message("Mac Catalyst selected - using only en-US localization")
localized_release_notes = { 'en-US' => release_notes_text }
end
localized_release_notes = localized_release_notes.select { |locale, _| enabled_locales.include?(locale) }
UI.message("Review the following release notes updates:")
localized_release_notes.each do |locale, notes|
UI.message("Locale: #{locale} - Notes: #{notes}")
end
force_yes = options && options.is_a?(Hash) && options[:force_yes] == true
unless 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
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