Files
iNaturalistReactNative/fastlane/Fastfile
Johannes Klein 99a1ca0fa0 Update Fastfile
2025-07-10 10:54:35 +02:00

670 lines
22 KiB
Ruby

# frozen_string_literal: true
require "fileutils"
require "spaceship"
appfile_path = File.join( __dir__, "Appfile" )
unless File.exist?( appfile_path )
UI.abort_with_message! <<~NO_APPFILE_ERROR.gsub( /\s+/, " " ).strip
Could not find #{appfile_path}. Copy the example file in that directory to
that path and fill in the relevant values to use Fastlane.
NO_APPFILE_ERROR
end
forbidden_env_vars = [
"MOCK_MODE"
]
forbidden_env_vars.each do | env_var |
next unless ENV[env_var].to_s.size.positive?
UI.abort_with_message! <<~NO_ENV_ERROR.gsub( /\s+/, " " ).strip
ENV is set #{env_var}. Remove the value from your ENV before running fastlane.
NO_ENV_ERROR
end
required_env_vars = [
"IOS_PROVISIONING_PROFILE_NAME",
"IOS_SHARE_BUNDLE_ID",
"IOS_SHARE_PROVISIONING_PROFILE_NAME"
]
required_env_vars.each do | env_var |
next unless ENV[env_var].to_s.empty?
UI.abort_with_message! <<~NO_ENV_ERROR.gsub( /\s+/, " " ).strip
ENV is missing #{env_var}. Add the value to .env or populate your ENV in
another way.
NO_ENV_ERROR
end
VERSION = File.open( "../package.json" ) {| f | JSON.parse( f.read )["version"] }
editor_cmd = [
ENV.fetch( "EDITOR", nil ),
`git config core.editor`,
`which vi`
].map {| e | e.to_s.strip }.detect {| e | !e.empty? }
if editor_cmd.nil?
UI.abort_with_message! <<~NO_EDITOR_ERROR
Could not find an editor, not even vi. Set the EDITOR environmental
variable or the core.editor git config"
NO_EDITOR_ERROR
end
editor_cmd_needs_to_wait = ( editor_cmd =~ /^code / || editor_cmd =~ /^subl / ) && !editor_cmd.include?( "-w" )
EDITOR = editor_cmd_needs_to_wait ? "#{editor_cmd} -w" : editor_cmd
XCODEPROJ = "ios/iNaturalistReactNative.xcodeproj"
PACKAGE_ID = CredentialsManager::AppfileConfig.try_fetch_value( :package_name )
IOS_BUNDLE_ID = CredentialsManager::AppfileConfig.try_fetch_value( :app_identifier )
IOS_ITC_TEAM_ID = CredentialsManager::AppfileConfig.try_fetch_value( :itc_team_id )
# https://github.com/fastlane/fastlane/issues/20741#issuecomment-1280687500
# ENV["ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD"] = "true"
def set_android_version_code( new_version_code )
build_gradle_path = "../android/app/build.gradle"
new_gradle = File.read( build_gradle_path ).sub( /versionCode\s+\d+/, "versionCode #{new_version_code}" )
File.write( build_gradle_path, new_gradle )
end
def set_android_version_name( _new_version_name )
build_gradle_path = "../android/app/build.gradle"
new_gradle = File.read( build_gradle_path ).sub( /versionName\s+".+"/, "versionName \"#{VERSION}\"" )
File.write( build_gradle_path, new_gradle )
end
def get_changelog_path( options = {} )
build_number = options[:build_number] || get_build_number( xcodeproj: XCODEPROJ )
changelog_dir_path = File.join( "metadata", "android", "en-US", "changelogs" )
FileUtils.mkpath( changelog_dir_path )
File.join( changelog_dir_path, "#{build_number}.txt" )
end
def app_store_release_notes
build_number ||= get_build_number( xcodeproj: XCODEPROJ )
data = {}
Dir.glob( File.join( "metadata", "android", "*" ) ) do | locale_dir |
changelog_path = File.join( locale_dir, "changelogs", "#{build_number}.txt" )
locale = File.basename( locale_dir )
unless File.exist?( changelog_path )
UI.abort_with_message! <<~MSG
No change log file exists at #{changelog_path}. That should have been
created when you ran `fastlane tag`.
MSG
end
data[locale] = File.read( changelog_path )
end
data
end
def get_aab_path( build_number = nil )
build_number ||= get_build_number( xcodeproj: XCODEPROJ )
File.expand_path( File.join(
__dir__,
"..",
"android",
"app",
"build",
"outputs",
"bundle",
"release",
"#{PACKAGE_ID}-v#{VERSION}+#{build_number}-release.aab"
) )
end
def get_apk_path( build_number = nil )
build_number ||= get_build_number( xcodeproj: XCODEPROJ )
File.expand_path( File.join(
__dir__,
"..",
"android",
"app",
"build",
"outputs",
"apk",
"release",
"#{PACKAGE_ID}-v#{VERSION}+#{build_number}-release.apk"
) )
end
def get_ipa_path( build_number = nil )
build_number ||= get_build_number( xcodeproj: XCODEPROJ )
File.expand_path( File.join(
__dir__,
"..",
"ios",
"build",
"#{PACKAGE_ID}-v#{VERSION}+#{build_number}.ipa"
) )
end
def get_changelog( options = {} )
build_number = options[:build_number] || nil
changelog_path = get_changelog_path( build_number: build_number )
unless File.exist?( changelog_path )
return nil if options[:allow_blank]
UI.abort_with_message! <<~MSG
No change log file exists at #{changelog_path}. That should have been
created when you ran `fastlane tag`.
MSG
end
begin
File.read( changelog_path )
rescue StandardError
nil
end
end
def prompt_for_release_notes( options = {} )
instructions = options[:instructions] || ""
# Get release notes
# Bit silly but takes advantage of existing syntax highlighting
fname = "COMMIT_EDITMSG"
File.open( fname, "w" ) do | f |
f << instructions
end
system "#{EDITOR} #{fname}", exception: true
release_notes = ""
File.readlines( fname ).each do | line |
release_notes += line unless line[0] == "#"
end
release_notes.strip!
FileUtils.rm( fname )
if release_notes.strip.empty?
if options[:reset_git_repo_on_fail]
reset_git_repo skip_clean: true
end
UI.abort_with_message! "You gotta enter release notes!"
end
if release_notes.strip.length > 500
if options[:reset_git_repo_on_fail]
reset_git_repo skip_clean: true
end
UI.abort_with_message! "Release notes must be 500 characters or less"
end
# Return the release notes
release_notes
end
lane :tag do | options |
desc "Add a new tag with an incremented version"
ensure_git_status_clean( ignore_files: ["fastlane/Fastfile"] )
last_tag = last_git_tag
changes = changelog_from_git_commits( pretty: "# * %h %s (%an, %ai)" )
if last_tag && changes.empty? && !options[:force]
UI.abort_with_message! "Nothing has changed since the last tag (#{last_tag})"
end
# Increment the iOS build number
increment_build_number( xcodeproj: XCODEPROJ )
increment_version_number( xcodeproj: XCODEPROJ, version_number: VERSION )
build_number = get_build_number( xcodeproj: XCODEPROJ )
# set android/app/build.gradle versionCode to this build_number
set_android_version_code( build_number )
# set android/app/build.gradle versionName to VERSION
set_android_version_name( VERSION )
tag = "v#{VERSION}+#{build_number}"
# Prompt for release notes
instructions = <<~INSTRUCTIONS
# Enter notes about what's new in #{tag}. Lines beginning with # will be ignored.
# Keep notes within the 500 character limit required by Google Play.
#
# Here's what changed since the last tag:
#{changes}
INSTRUCTIONS
# At the moment we are not bothered about translating those into other languages
release_notes = prompt_for_release_notes( instructions: instructions, reset_git_repo_on_fail: true )
# Write release notes to a place where they can be translated and add that file to git
changelog_path = get_changelog_path( build_number: build_number )
File.open( changelog_path, "w" ) do | f |
f << "#{release_notes}\n"
end
changelog_git_path = File.join( "fastlane", changelog_path )
git_add( path: changelog_git_path )
# commit
commit_version_bump(
message: tag,
xcodeproj: XCODEPROJ,
include: [
"android/app/build.gradle",
changelog_git_path
]
)
push_to_git_remote
# Create a tag for this release
add_git_tag( tag: tag )
push_git_tags
end
platform :android do
desc "Upload description to Google Play"
lane :upload_metadata do
# Upload for all tracks
tracks = [
"internal",
"alpha",
"beta"
# TODO: 20241105-we currently have no production track opened, so uploading will error out
# "production"
]
tracks.each do | track |
upload_to_play_store(
track: track,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: false,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
lane :build do
desc "Build release files for Android"
Dir.children( "../android/app/src/main/assets/camera" ).each do | file |
next if ENV.value?( file )
UI.abort_with_message! <<~EXTRA_ANDROID_ASSETS_ERROR
Android assets folder has extraneous file: #{file}, which is not listed
as an environment variable. Remove any unreferenced files from
android/app/src/main/assets/camera before running a release build
EXTRA_ANDROID_ASSETS_ERROR
end
assets = File.open( "../android/link-assets-manifest.json" ) {| f | JSON.parse( f.read )["data"] }
linked_assets = assets.map do | asset |
asset["path"].split( "assets/fonts/" )[1]
end
Dir.children( "../assets/fonts" ).each do | file |
next unless !linked_assets.include?( file ) && ( file != ".DS_Store" )
UI.abort_with_message! <<~EXTRA_ASSETS_ERROR
Assets folder has extraneous file: #{file}, which is not listed
in link-assets-manifest.json. Remove any unreferenced assets before
running a release build
EXTRA_ASSETS_ERROR
end
keystore_properties_path = File.join(
__dir__,
"..",
"android",
"keystore.properties"
)
unless File.exist?( keystore_properties_path )
UI.abort_with_message! <<~NO_KEYSTORE_PROPERTIES_ERROR.gsub( /\s+/, " " ).strip
Could not find #{keystore_properties_path}. Copy the example file in that directory to
that path and fill in the relevant values to build for Android.
NO_KEYSTORE_PROPERTIES_ERROR
end
build_number = get_build_number( xcodeproj: XCODEPROJ )
# Build AAB. This should write
# android/app/build/outputs/bundle/release/PACKAGE_ID-vVERSION_NAME+VERSION_CODE-release.aab
aab_path = get_aab_path( build_number )
if File.exist?( aab_path )
UI.important "AAB already exists at #{aab_path}"
else
gradle( task: "bundle", project_dir: "android" )
end
unless File.exist?( aab_path )
UI.abort_with_message! "Failed to create AAB at #{aab_path}"
end
# Build APK. This should write
# android/app/build/outputs/apk/release/PACKAGE_ID-vVERSION_NAME+VERSION_CODE-release.apk
apk_path = get_apk_path( build_number )
if File.exist?( apk_path )
UI.important "APK already exists at #{apk_path}"
else
gradle( task: "build", project_dir: "android", flags: "-x lint" )
end
unless File.exist?( apk_path )
UI.abort_with_message! "Failed to create APK at #{apk_path}"
end
end
lane :clean do
sh "rm -rf ../android/.gradle", step_name: "Deleting Android .gradle folder"
sh "rm -rf ../android/build", step_name: "Deleting Android build folder"
sh "(cd ../android && ./gradlew --stop && ./gradlew clean)", step_name: "Cleaning Android build environment"
Dir.glob( File.join( File.dirname( get_aab_path ), "*.aab" ) ).each do | aab_path |
UI.message "Deleting #{aab_path}"
File.delete aab_path
end
Dir.glob( File.join( File.dirname( get_apk_path ), "*.apk" ) ).each do | apk_path |
UI.message "Deleting #{apk_path}"
File.delete apk_path
end
end
end
platform :ios do
desc "Upload app metadata to App Store"
# This only works while the new app version is in the "Prepare for Submission" state
lane :upload_metadata do
upload_to_app_store(
metadata_path: "./fastlane/metadata/ios",
force: true,
skip_binary_upload: true,
skip_metadata: false,
skip_screenshots: true
)
end
lane :build do
desc "Build release files for iOS"
# Build iOS app
get_certificates
get_provisioning_profile
ipa_path = get_ipa_path
if File.exist?( ipa_path )
UI.important "IPA already exists at #{ipa_path}"
else
build_app(
workspace: File.join( "ios", "iNaturalistReactNative.xcworkspace" ),
scheme: "iNaturalistReactNative",
output_directory: File.dirname( ipa_path ),
output_name: File.basename( ipa_path ),
# I'd rather not do it this way, but so far I haven't figured out a
# way for fastlane to automatically detect both of the provisioning
# profiles we need
export_options: {
signingStyle: "manual",
provisioningProfiles: {
IOS_BUNDLE_ID => ENV.fetch( "IOS_PROVISIONING_PROFILE_NAME", nil ),
ENV.fetch( "IOS_SHARE_BUNDLE_ID", nil ) => ENV.fetch( "IOS_SHARE_PROVISIONING_PROFILE_NAME", nil )
}
}
)
end
end
lane :clean do
sh "rm -rf ios/build", step_name: "Deleting iOS build folder"
Dir.glob( File.join( File.dirname( get_ipa_path ), "*.ipa" ) ).each do | ipa_path |
UI.message "Deleting #{ipa_path}"
File.delete ipa_path
end
end
end
lane :build do
desc "Build release files for all platforms"
Fastlane::LaneManager.cruise_lane "ios", "build"
Fastlane::LaneManager.cruise_lane "android", "build"
end
lane :clean do
desc "Delete build artifacts"
sh "rm -rf $TMPDIR/react-*", step_name: "Deleting React Native cache"
sh "rm -rf $TMPDIR/metro-*", step_name: "Deleting Metro cache"
sh "watchman watch-del-all", step_name: "Deleting watchman cache"
Fastlane::LaneManager.cruise_lane "ios", "clean"
Fastlane::LaneManager.cruise_lane "android", "clean"
end
lane :release do
desc "Make github release for the latest tag and make builds"
last_tag = last_git_tag
if last_tag.nil? || last_tag.empty?
UI.abort_with_message! "No tags have been added yet. Try starting with `fastlane tag`"
end
original_branch = git_branch
system "git checkout #{last_tag}", exception: true
build_number = get_build_number( xcodeproj: XCODEPROJ )
if build_number.to_s != last_tag.split( "+" ).last
UI.abort_with_message! <<~MSG
The last tag doesn't match the current build number. Either make a new
tag or check out the tag before releasing.
MSG
end
changelog = get_changelog
build
apk_path = File.expand_path( get_apk_path( build_number ) )
if File.exist?( apk_path )
UI.success "Found APK at #{apk_path}"
else
UI.abort_with_message! "Failed to find APK at #{apk_path}"
end
github_release = get_github_release(
url: "inaturalist/iNaturalistReactNative",
version: last_tag,
api_token: ENV.fetch( "GITHUB_TOKEN", nil )
)
if github_release
UI.important "Release already exists at #{github_release['url']}. You need to manually upload any missing assets."
else
set_github_release(
repository_name: "inaturalist/iNaturalistReactNative",
api_token: ENV.fetch( "GITHUB_TOKEN", nil ),
name: last_tag,
tag_name: last_tag,
description: changelog,
# This is really just a fallback in case last_tag isn't really a tag
commitish: "main",
upload_assets: [apk_path],
is_prerelease: true
)
end
system "git checkout #{original_branch}", exception: true
end
lane :internal do
desc "Push builds for the latest tag for internal testing"
# Ensure build files exist for the latest tag
aab_path = get_aab_path
unless File.exist?( aab_path )
UI.abort_with_message! <<~MSG
AAB does not exist at #{aab_path}. You may need to run the release lane before doing this.
MSG
end
last_tag = last_git_tag
if last_tag.nil? || last_tag.empty?
UI.abort_with_message! "No tags have been added yet. Try starting with `fastlane tag`"
end
changelog = get_changelog
upload_to_play_store(
aab: aab_path,
track: "internal",
version_name: last_tag
)
upload_to_testflight(
ipa: get_ipa_path,
changelog: changelog,
# https://github.com/fastlane/fastlane/issues/20756
itc_provider: CredentialsManager::AppfileConfig.try_fetch_value( :itc_provider ),
# The following distributes the build to our invite-only external test group
distribute_external: true,
groups: [
"iNat Friends"
]
)
slack_message = <<~MESSAGE
🧪 NEW INTERNAL RELEASE
Should be available to staff in TestFlight and Play Store soon. Please test and report any bugs, especially ones that should block release!
#{changelog}
MESSAGE
slack(
message: slack_message,
default_payloads: [],
payload: {
version: last_tag,
apk: "https://github.com/inaturalist/iNaturalistReactNative/releases/download/#{last_tag}/org.inaturalist.iNaturalistMobile-#{last_tag}-release.apk"
}
)
end
lane :beta do
desc "Make latest builds available for public testing"
build_number = get_build_number( xcodeproj: XCODEPROJ )
# UI.message "Play Store: promoting build #{build_number} to beta track"
upload_to_play_store(
version_code: build_number,
track: "internal",
track_promote_to: "beta"
)
UI.message "TestFlight: adding build #{build_number} to Open Beta group"
add_build_to_testflight_beta_group(
version_code: build_number,
beta_group: "Open Beta"
)
# We decided to send builds to internal and beta at the same time, so this is appearing twice in Slack
# last_tag = last_git_tag
# changelog = get_changelog
# slack_message = <<~MESSAGE
# 🚧 NEW BETA RELEASE
# Should be available to beta testers in TestFlight and Play Store soon.
# #{changelog}
# MESSAGE
# slack(
# message: slack_message,
# default_payloads: [],
# payload: {
# version: last_tag,
# apk: "https://github.com/inaturalist/iNaturalistReactNative/releases/download/#{last_tag}/org.inaturalist.iNaturalistMobile-#{last_tag}-release.apk"
# }
# )
end
lane :download_app_store_reviews do
desc "Download App Store reviews and save them to a file"
UI.message "Logging into App Store Connect..."
# Use credentials from the Appfile
apple_id = CredentialsManager::AppfileConfig.try_fetch_value( :apple_id )
itc_team_id = CredentialsManager::AppfileConfig.try_fetch_value( :itc_team_id )
# Explicitly login with credentials
Spaceship::Tunes.login( apple_id )
Spaceship::Tunes.select_team( team_id: itc_team_id, team_name: nil )
# Select the app using the bundle ID from the Appfile
UI.message "Finding app with bundle ID: #{IOS_BUNDLE_ID}"
app = Spaceship::Tunes::Application.find( IOS_BUNDLE_ID )
unless app
UI.abort_with_message! "Could not find app with bundle ID: #{IOS_BUNDLE_ID}"
end
# Fetch reviews
UI.message "Fetching reviews for #{app.name}..."
reviews = app.ratings.reviews
# Create an array to hold processed reviews
processed_reviews = []
reviews.each do | review |
# Convert review object to a hash with the fields you want
processed_review = {
rating: review.rating.to_i,
title: review.title,
review_content: review.review,
nickname: review.nickname,
country: review.store_front,
last_modified: review.last_modified,
edited: review.edited,
responded: review.responded?,
developer_response: review.developer_response.response
}
processed_reviews << processed_review
end
# Save reviews to a JSON file
output_file = "app_store_reviews.json"
File.write( output_file, JSON.pretty_generate( processed_reviews ) )
UI.success "Downloaded #{reviews.count} reviews to #{output_file}"
end
lane :prod do
desc "Set up App Store version and upload new metadata"
version = get_version_number( xcodeproj: XCODEPROJ, target: "iNaturalistReactNative" )
# Get all build numbers between the last released version and the current build number
build_number = get_build_number( xcodeproj: XCODEPROJ )
published_build_number = app_store_build_number
new_build_numbers = ( ( published_build_number.to_i + 1 )..build_number.to_i ).to_a
# Get all previous changelogs to show them as an instruction for new release notes
previous_changelogs = ""
new_build_numbers.each do | new_build_number |
changelog = get_changelog( build_number: new_build_number, allow_blank: true )
next unless changelog
previous_changelogs += "#\n#\n"
previous_changelogs += "# Version #{VERSION}+#{new_build_number}\n"
# Add a # to the start of each line
changelog.each_line do | line |
previous_changelogs += "# #{line}"
end
end
instructions = <<~INSTRUCTIONS
# Write custom release notes that summarize changes since the last production
# release (not since the last build).
#
# Lines beginning with # will be ignored.
# Keep notes within the 500 character limit required by Google Play.
#
# Here are all the changelogs since the last release:
\n
#{previous_changelogs}
INSTRUCTIONS
release_notes = {}
# At the moment we are not bothered about translating those into other languages
release_notes["default"] = prompt_for_release_notes( instructions: instructions )
upload_to_app_store(
build_number: build_number,
app_version: version,
automatic_release: false,
submit_for_review: false,
force: false,
# We don't want to upload the current binary but promote it from TestFlight to AppStore
# TODO: this does not work in conjunction with submit_for_review: false, so for now the new build
# needs to be manually attached
skip_binary_upload: true,
skip_metadata: false,
skip_screenshots: true,
metadata_path: "./fastlane/metadata/ios",
copyright: "#{Date.today.year} iNaturalist",
release_notes: release_notes,
precheck_include_in_app_purchases: false
)
slack_message = <<~MESSAGE
🚀 NEW PROD RELEASE
Staged on app stores with localized metadata, ready to submit for review.
#{release_notes['default']}
MESSAGE
slack(
message: slack_message,
default_payloads: [],
payload: {
version: last_git_tag
}
)
UI.important "This just sets up the version in the App Store. You need to go there " \
"to attach a build and submit for review."
end