1741 lines
68 KiB
Ruby
1741 lines
68 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
|
||
|
||
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ê só 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
|