# 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