iOS/fastlane/Fastfile

630 lines
19 KiB
Ruby

# Customise this file, documentation can be found here:
# https://github.com/fastlane/fastlane/tree/master/docs
# All available actions: https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Actions.md
# can also be listed using the `fastlane actions` command
# Change the syntax highlighting to Ruby
# All lines starting with a # are ignored when running `fastlane`
# If you want to automatically update fastlane if a new version is available:
# update_fastlane
# This is the minimum version number required.
# Update this, if you use features of a newer version
fastlane_version '2.137.0'
default_platform :ios
before_all do
setup
end
private_lane :setup do
ENV['FASTLANE_USER'] = ENV.fetch('HOMEASSISTANT_APPLE_ID', nil)
ENV['DELIVER_USERNAME'] = ENV.fetch('HOMEASSISTANT_APPLE_ID', nil)
ENV['FASTLANE_TEAM_ID'] = ENV.fetch('HOMEASSISTANT_TEAM_ID', nil)
ENV['FL_NOTARIZE_ASC_PROVIDER'] = ENV.fetch('HOMEASSISTANT_TEAM_ID', nil)
ENV['PILOT_APPLE_ID'] = ENV.fetch('HOMEASSISTANT_APPLE_ID', nil)
ENV['SIGH_USERNAME'] = ENV.fetch('HOMEASSISTANT_APPLE_ID', nil)
ENV['FL_NOTARIZE_USERNAME'] = ENV.fetch('HOMEASSISTANT_APPLE_ID', nil)
ENV['LOKALISE_API_TOKEN'] = ENV.fetch('HOMEASSISTANT_LOKALIZE_TOKEN', nil)
ENV['LOKALISE_PROJECT_ID'] = ENV.fetch('HOMEASSISTANT_LOKALIZE_PROJECT_ID', nil)
ENV['LOKALISE_PROJECT_ID_FRONTEND'] = ENV.fetch('HOMEASSISTANT_LOKALIZE_PROJECT_FRONTEND', nil)
ENV['LOKALISE_PROJECT_ID_CORE'] = ENV.fetch('HOMEASSISTANT_LOKALIZE_PROJECT_CORE', nil)
ENV['PILOT_ITC_PROVIDER'] = ENV.fetch('HOMEASSISTANT_APP_STORE_CONNECT_TEAM_ID', nil)
ENV['FASTLANE_ITC_TEAM_ID'] = ENV.fetch('HOMEASSISTANT_APP_STORE_CONNECT_TEAM_ID', nil)
ENV['SIGH_USERNAME'] = ENV.fetch('HOMEASSISTANT_APPLE_ID', nil)
ENV['SIGH_PROFILE_PATHS'] = '../Configuration/Provisioning'
app_store_connect_api_key if ENV['APP_STORE_CONNECT_API_KEY_KEY']
end
lane :lint do
sh('cd .. ; Pods/SwiftFormat/CommandLineTool/swiftformat --config .swiftformat --lint --quiet .')
sh('cd .. ; Pods/SwiftLint/swiftlint lint --config .swiftlint.yml --quiet .')
sh('cd .. ; bundle exec rubocop --config .rubocop.yml')
end
lane :autocorrect do
sh('../Pods/SwiftFormat/CommandLineTool/swiftformat ..')
sh('bundle exec rubocop -a ..')
end
lane :download_provisioning_profiles do
%w[ios catalyst macos].each do |platform|
sh(
'bundle', 'exec', 'fastlane', 'sigh', 'repair',
'-p', platform,
'-b', ENV.fetch('HOMEASSISTANT_TEAM_ID', nil),
'-u', ENV.fetch('SIGH_USERNAME', nil)
)
sh(
'bundle', 'exec', 'fastlane', 'sigh', 'download_all',
'-p', platform,
'-o', '../Configuration/Provisioning/',
'-b', ENV.fetch('HOMEASSISTANT_TEAM_ID', nil),
'-u', ENV.fetch('SIGH_USERNAME', nil)
)
end
end
lane :import_provisioning_profiles do
directory = '../Configuration/Provisioning'
Dir.children(directory).each do |file|
next if file.start_with?('.')
install_provisioning_profile(path: File.expand_path(File.join(directory, file)))
end
end
lane :update_dsyms do
directory = File.expand_path('dSYMs')
FileUtils.mkdir_p directory
download_dsyms(
after_uploaded_date: Date.today.prev_day(7).iso8601,
app_identifier: 'io.robbie.HomeAssistant',
output_directory: directory
)
FileUtils.rm_r directory
end
desc 'Update the test cases from the fcm repo'
lane :update_notification_test_cases do
bundle_directory = File.expand_path('../Tests/Shared/notification_test_cases.bundle')
zip_file = Tempfile.new(['archive', '.zip'])
FileUtils.rm_rf bundle_directory
FileUtils.mkdir_p bundle_directory
begin
archive_url = 'https://github.com/home-assistant/mobile-apps-fcm-push/archive/refs/heads/master.zip'
unzip_path = 'mobile-apps-fcm-push-master/functions/test/fixtures/legacy/*.json'
sh("curl -L #{archive_url} -o #{zip_file.path}")
sh("unzip -j #{zip_file.path} -d #{bundle_directory} '#{unzip_path}'")
ensure
zip_file.unlink
end
end
desc 'Generate proper icons for all build trains'
lane :icons do
appicon(appicon_path: 'Sources/App/Resources/Assets.xcassets', appicon_image_file: 'icons/dev.png',
appicon_name: 'AppIcon.dev.appiconset', appicon_devices: %i[ipad iphone ios_marketing macos])
appicon(appicon_path: 'Sources/App/Resources/Assets.xcassets', appicon_image_file: 'icons/beta.png',
appicon_name: 'AppIcon.beta.appiconset', appicon_devices: %i[ipad iphone ios_marketing macos])
appicon(appicon_path: 'Sources/App/Resources/Assets.xcassets', appicon_image_file: 'icons/release.png',
appicon_devices: %i[ipad iphone ios_marketing macos])
appicon(appicon_path: 'WatchApp/Assets.xcassets', appicon_image_file: 'icons/dev.png',
appicon_name: 'WatchIcon.dev.appiconset', appicon_devices: %i[watch watch_marketing])
appicon(appicon_path: 'WatchApp/Assets.xcassets', appicon_image_file: 'icons/beta.png',
appicon_name: 'WatchIcon.beta.appiconset', appicon_devices: %i[watch watch_marketing])
appicon(appicon_path: 'WatchApp/Assets.xcassets', appicon_image_file: 'icons/release.png',
appicon_name: 'WatchIcon.appiconset', appicon_devices: %i[watch watch_marketing])
end
desc 'Update switftgen input/output files'
lane :update_swiftgen_config do
# rubocop:disable Layout/LineLength
sh('cd ../ && ./Pods/SwiftGen/bin/swiftgen config generate-xcfilelists --inputs swiftgen.yml.file-list.in --outputs swiftgen.yml.file-list.out')
# rubocop:enable Layout/LineLength
end
desc 'Download latest localization files from Lokalize'
lane :update_strings do
token = ENV.fetch('LOKALISE_API_TOKEN') do
prompt(
text: 'API token',
secure_text: true
)
end
project_id = ENV.fetch('LOKALISE_PROJECT_ID') do
prompt(
text: 'Project ID',
secure_text: true
)
end
frontend_project_id = ENV.fetch('LOKALISE_PROJECT_ID_FRONTEND') do
prompt(
text: 'Frontend Project ID',
secure_text: true
)
end
core_project_id = ENV.fetch('LOKALISE_PROJECT_ID_CORE') do
prompt(
text: 'Core Project ID',
secure_text: true
)
end
resources_dir_full = File.expand_path('../Sources/App/Resources')
sh(
'lokalise2',
'--token', token,
'--project-id', project_id,
'file', 'download',
'--format', 'ios_sdk',
'--export-empty-as', 'base',
'--export-sort', 'a_z',
'--replace-breaks=false',
'--include-comments=false',
'--include-description=false',
'--unzip-to', resources_dir_full,
log: false
)
lang_frontend_to_ios = {
'bg' => 'bg',
'ca' => 'ca-ES',
'cs' => 'cs',
'cy' => 'cy-GB',
'da' => 'da',
'de' => 'de',
'el' => 'el',
'en-GB' => 'en-GB',
'en' => 'en',
'es' => 'es-ES',
'es-419' => 'es',
# es-MX is missing from the frontend, so we copy es over below
'et' => 'et',
'fi' => 'fi',
'fr' => 'fr',
'he' => 'he',
'hu' => 'hu',
'id' => 'id',
'it' => 'it',
'ja' => 'ja',
'ko' => 'ko-KR',
'ml' => 'ml',
'nb' => 'nb',
'nl' => 'nl',
'pl' => 'pl-PL',
'pt-BR' => 'pt-BR',
'ru' => 'ru',
'sl' => 'sl',
'sv' => 'sv',
'tr' => 'tr',
'uk' => 'uk',
'vi' => 'vi',
'zh-Hans' => 'zh-Hans',
'zh-Hant' => 'zh-Hant'
}
# to => from, since to is unique but from may not be
manually_copied_languages = {
'es-MX' => 'es'
}
# make sure the previous map has everything. adding a new language should error.
ios_languages = Set.new(
Dir.children(resources_dir_full)
.select { |file| File.extname(file) == '.lproj' }
.map { |file| File.basename(file, File.extname(file)) }
.reject { |lang| lang == 'Base' }
)
mapped_ios_languages = Set.new(lang_frontend_to_ios.values + manually_copied_languages.keys)
unless ios_languages == mapped_ios_languages
missing = ios_languages - mapped_ios_languages
UI.user_error!("missing language in map. missing: #{missing.to_a.join(', ')}")
end
language_mapping = lang_frontend_to_ios
.map { |key, value| { 'original_language_iso' => key, custom_language_iso: value } }
sh(
'lokalise2',
'--token', token,
'--project-id', frontend_project_id,
'file', 'download',
'--format', 'strings',
'--filter-langs', lang_frontend_to_ios.keys.join(','),
'--language-mapping', language_mapping.to_json.to_s,
'--original-filenames=false',
'--bundle-structure', '%LANG_ISO%.lproj/Frontend.%FORMAT%',
'--export-empty-as', 'base',
'--export-sort', 'a_z',
'--replace-breaks=false',
'--include-comments=false',
'--include-description=false',
'--unzip-to', resources_dir_full,
log: false
)
manually_copied_languages.each do |to, from|
FileUtils.cp(
"#{resources_dir_full}/#{from}.lproj/Frontend.strings",
"#{resources_dir_full}/#{to}.lproj/Frontend.strings"
)
end
sh(
'lokalise2',
'--token', token,
'--project-id', core_project_id,
'file', 'download',
'--format', 'strings',
'--filter-langs', lang_frontend_to_ios.keys.join(','),
'--language-mapping', language_mapping.to_json.to_s,
'--original-filenames=false',
'--bundle-structure', '%LANG_ISO%.lproj/Core.%FORMAT%',
'--export-empty-as', 'base',
'--export-sort', 'a_z',
'--replace-breaks=false',
'--include-comments=false',
'--include-description=false',
'--unzip-to', resources_dir_full,
log: false
)
manually_copied_languages.each do |to, from|
FileUtils.cp(
"#{resources_dir_full}/#{from}.lproj/Core.strings",
"#{resources_dir_full}/#{to}.lproj/Core.strings"
)
end
sh('cd ../ && ./Pods/SwiftGen/bin/swiftgen')
end
desc 'Upload localized strings to Lokalise'
lane :push_strings do
source_directories = [
'../Sources/App/Resources/en.lproj'
]
token = ENV.fetch('LOKALISE_API_TOKEN') do
prompt(
text: 'API token',
secure_text: true
)
end
project_id = ENV.fetch('LOKALISE_PROJECT_ID') do
prompt(
text: 'Project ID',
secure_text: true
)
end
source_directories.each do |directory|
puts "Enumerating #{directory}..."
Dir.each_child(directory) do |file|
next if ['Frontend.strings', 'Core.strings'].include?(file)
puts "Uploading file #{file}"
sh(
'lokalise2',
'--token', token,
'--project-id', project_id,
'file', 'upload',
'--file', "#{directory}/#{file}",
'--lang-iso', 'en',
log: false
)
end
end
end
desc 'Find unused localized strings'
lane :unused_strings do
files = [
'../Sources/App/Resources/en.lproj/Localizable.strings'
]
files.each do |file|
puts "Looking at #{file}"
unused_strings = File.read(file)
# grab the keys only
.scan(/"([^"]+)" = [^;]+;/)
# replace _ in the keys with nothing, which is what swiftgen does
.map { |s| [s[0], s[0].gsub('_', '')] }
# ignore any keys at the root level (aka like ok_label)
.select { |full, _key| full.include?('.') }
# find any strings that don't have matches in code
.select { |_full, key| system("git grep --ignore-case --quiet #{key} -- ../*.swift") == false }
unused_strings.each_key { |full| puts full }
puts "- Found #{unused_strings.count} unused strings"
end
end
desc 'Upload App Store Connect metadata to Lokalise'
lane :update_lokalise_metadata do
lokalise_metadata(action: 'update_lokalise', override_translation: true)
end
desc 'Download App Store Connect metadata from Lokalise and upload to App Store Connect Connect'
lane :update_asc_metadata do
lokalise_metadata(action: 'update_itunes')
end
private_lane :set_version_info do |options|
File.write(
'../Configuration/Version.xcconfig',
"MARKETING_VERSION=#{options[:version]}\nCURRENT_PROJECT_VERSION=#{options[:build]}\n"
)
end
private_lane :get_xcconfig_marketing_version do
sh('cd .. ; . Configuration/Version.xcconfig ; echo $MARKETING_VERSION').strip!
end
private_lane :get_xcconfig_build_number do
sh('cd .. ; . Configuration/Version.xcconfig; echo $CURRENT_PROJECT_VERSION').strip!
end
desc 'Set version number'
lane :set_version do |options|
version = options[:version]
unless version
if is_ci
UI.error 'no version provided'
else
version = prompt(text: 'Version number: ')
end
end
set_version_info(
version: version,
build: get_xcconfig_build_number
)
end
desc 'Setup Continous Integration'
lane :setup_ha_ci do
raise 'No github run number specified' unless ENV['GITHUB_RUN_NUMBER']
set_version_info(
version: get_xcconfig_marketing_version,
build: get_xcconfig_build_number + '.' + ENV.fetch('GITHUB_RUN_NUMBER', nil) # rubocop:disable Style/StringConcatenation
)
# we only expose these keys in ci when appropriate
ENV['FASTLANE_DONT_STORE_PASSWORD'] = '1'
ENV['FASTLANE_PASSWORD'] = ENV.fetch('HOMEASSISTANT_APP_STORE_CONNECT_PASSWORD', nil)
ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD'] = ENV.fetch('HOMEASSISTANT_APP_STORE_CONNECT_PASSWORD', nil)
keychain_name = 'HomeAssistant-Keychain'
keychain_password = SecureRandom.hex
begin
delete_keychain(name: keychain_name)
rescue StandardError
# we don't care if it didn't exist yet
end
create_keychain(
name: keychain_name,
password: keychain_password,
timeout: 3600,
unlock: true,
add_to_search_list: true
)
KeyAndValue = Struct.new(:key, :value) # rubocop:disable Lint/ConstantDefinitionInBlock
[
KeyAndValue.new(ENV.fetch('P12_KEY_IOS_APP_STORE', nil), ENV.fetch('P12_VALUE_IOS_APP_STORE', nil)),
KeyAndValue.new(ENV.fetch('P12_KEY_MAC_APP_STORE', nil), ENV.fetch('P12_VALUE_MAC_APP_STORE', nil)),
KeyAndValue.new(ENV.fetch('P12_KEY_MAC_DEVELOPER_ID', nil), ENV.fetch('P12_VALUE_MAC_DEVELOPER_ID', nil))
].each do |info|
tmp_file = '/tmp/import.p12'
File.write(tmp_file, Base64.decode64(info.value))
import_certificate(
certificate_path: tmp_file,
certificate_password: info.key,
keychain_name: keychain_name,
keychain_password: keychain_password
)
FileUtils.rm(tmp_file)
end
import_provisioning_profiles
end
desc 'Run tests'
lane :test do
run_tests(
workspace: 'HomeAssistant.xcworkspace',
scheme: 'Tests-Unit',
result_bundle: true,
skip_package_dependencies_resolution: true,
destination: 'platform=iOS Simulator,name=iPhone 16,OS=18.1'
)
end
private_lane :provisioning_profile_specifiers do |options|
# gym doesn't parse the provisioning profile specifier, so we need to do it ourselves
all_targets_result = sh([
'xcodebuild',
'2>/dev/null', # this command started outputting warnings in Xcode 12.5
'-json',
'-showBuildSettings',
'-sdk', options[:sdk],
'-project', '../HomeAssistant.xcodeproj',
'-scheme', 'App-Release',
'-list'
] + [], log: false)
all_targets = JSON.parse(all_targets_result)['project']['targets'].map { |t| "-target #{t}" }
settings_result = sh([
'xcodebuild',
'2>/dev/null', # this command started outputting warnings in Xcode 12.5
'-json',
'-showBuildSettings',
'-sdk', options[:sdk],
'-project', '../HomeAssistant.xcodeproj'
] + all_targets, log: false)
settings = JSON.parse(settings_result)
specifiers = {}
settings.each do |target_info|
specifier = target_info['buildSettings']['PROVISIONING_PROFILE_SPECIFIER']
bundle_identifier = target_info['buildSettings']['PRODUCT_BUNDLE_IDENTIFIER']
next unless specifier && bundle_identifier
specifiers[bundle_identifier] = specifier
end
specifiers
end
private_lane :upload_binary_to_apple do |options|
attempts = 0
begin
sh(
'xcrun', 'altool', '--upload-app', '--type', options[:type],
'--file', options[:path],
'--username', ENV.fetch('DELIVER_USERNAME', nil),
'--password', '@env:FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD'
)
rescue StandardError => e
puts "Failed with #{e}; retrying upload..."
# retry a few times, then give up
attempts += 1
retry if attempts <= 3
raise e
end
end
platform :ios do
private_lane :create_archive do
setup_ha_ci if is_ci
specifiers = provisioning_profile_specifiers(sdk: 'iphoneos')
ipa_path = build_ios_app(
export_method: 'app-store',
skip_package_dependencies_resolution: true,
skip_profile_detection: true,
disable_xcpretty: true,
output_directory: './build/ios',
export_options: {
signingStyle: 'manual',
provisioningProfiles: specifiers
}
)
if ENV['EMERGE_API_TOKEN']
emerge(
repo_name: ENV.fetch('EMERGE_REPO_NAME', nil),
pr_number: ENV.fetch('EMERGE_PR_NUMBER', nil),
sha: ENV.fetch('EMERGE_SHA', nil),
base_sha: ENV.fetch('EMERGE_BASE_SHA', nil)
)
end
ipa_path
end
lane :build do
ipa_path = create_archive
upload_binary_to_apple(
type: 'ios',
path: ipa_path
)
end
lane :size do
create_archive
end
end
platform :mac do
lane :build do
setup_ha_ci if is_ci
specifiers = provisioning_profile_specifiers(sdk: 'macosx')
developer_id_app_path = build_mac_app(
export_method: 'developer-id',
skip_package_dependencies_resolution: true,
skip_profile_detection: true,
skip_package_pkg: true,
disable_xcpretty: true,
output_directory: './build/macos',
export_options: {
signingStyle: 'manual',
provisioningProfiles: specifiers
}
)
app_store_pkg_path = build_mac_app(
archive_path: lane_context[SharedValues::XCODEBUILD_ARCHIVE],
export_method: 'app-store',
skip_package_dependencies_resolution: true,
skip_profile_detection: true,
skip_build_archive: true,
skip_package_pkg: false,
output_directory: './build/macos',
export_options: {
signingStyle: 'manual',
provisioningProfiles: specifiers.transform_values { |v| v.sub('Mac Dev ID', 'Mac App Store') }
}
)
notarize_attempts = 0
begin
notarize(
package: developer_id_app_path,
# not the _app_ bundle id, just an id that notarize uses for referencing
bundle_id: 'io.home-assistant.fastlane.developer-id',
verbose: true
)
rescue StandardError => e
puts "Failed with #{e}; retrying notarize in a few seconds..."
sleep 5
# retry a few times, then give up
notarize_attempts += 1
retry if notarize_attempts <= 3
raise e
end
sh(
'ditto',
'-c',
'-k',
'--sequesterRsrc',
'--keepParent',
File.expand_path(developer_id_app_path),
'../build/macos/home-assistant-mac.zip'
)
upload_binary_to_apple(
type: 'osx',
path: app_store_pkg_path
)
end
end