# This file contains the fastlane.tools configuration # You can find the documentation at https://docs.fastlane.tools # # For a list of all available actions, check out # https://docs.fastlane.tools/actions # opt_out_usage require "bundler/setup" require "base64" require "xcodeproj" require_relative "helpers" # load secrets before project configuration Fastlane::Helpers.dev_load_dotenv_secrets is_ci = Fastlane::Helpers.is_ci_environment? local_development = !is_ci # checks after calling Dotenv.load android_has_permissions = false # Project configuration PROJECT_NAME = ENV["IOS_PROJECT_NAME"] APP_NAME = ENV["IOS_PROJECT_NAME"] || begin app_json_path = File.expand_path("../app.json", __dir__) if File.exist?(app_json_path) app_config = JSON.parse(File.read(app_json_path)) app_config["displayName"] if app_config.is_a?(Hash) end rescue JSON::ParserError, Errno::ENOENT UI.important("Could not read app.json or invalid JSON format, using default app name") nil end || "MobileApp" PROJECT_SCHEME = ENV["IOS_PROJECT_SCHEME"] SIGNING_CERTIFICATE = ENV["IOS_SIGNING_CERTIFICATE"] # Environment setup package_version = JSON.parse(File.read("../package.json"))["version"] # most of these values are for local development android_aab_path = "android/app/build/outputs/bundle/release/app-release.aab" android_gradle_file_path = "../android/app/build.gradle" android_keystore_path = "../android/app/upload-keystore.jks" android_play_store_json_key_path = "../android/app/play-store-key.json" ios_connect_api_key_path = "../ios/certs/connect_api_key.p8" ios_provisioning_profile_directory = "~/Library/MobileDevice/Provisioning\ Profiles" ios_xcode_profile_path = "../ios/#{PROJECT_NAME}.xcodeproj" default_platform(:ios) platform :ios do desc "Sync ios version (DEPRECATED)" lane :sync_version do UI.error("โ›” This lane is deprecated!") UI.error("Version management is now centralized in CI.") UI.error("Use: node scripts/version-manager.cjs apply ") UI.user_error!("sync_version lane is deprecated - use version-manager.cjs instead") end desc "Push a new build to TestFlight Internal Testing" lane :internal_test do |options| test_mode = options[:test_mode] == true || options[:test_mode] == "true" result = prepare_ios_build(prod_release: false) if test_mode UI.important("๐Ÿงช TEST MODE: Skipping TestFlight upload") UI.success("โœ… Build completed successfully!") UI.message("๐Ÿ“ฆ IPA path: #{result[:ipa_path]}") else upload_to_testflight( api_key: result[:api_key], distribute_external: true, # Only external TestFlight groups are valid here groups: ENV["IOS_TESTFLIGHT_GROUPS"].split(","), changelog: "", skip_waiting_for_build_processing: false, ) if result[:should_upload] end # Update deployment timestamp in version.json if result[:should_upload] Fastlane::Helpers.update_deployment_timestamp("ios") end end desc "Deploy iOS app with automatic version management" lane :deploy_auto do |options| deployment_track = options[:deployment_track] || "internal" version_bump = options[:version_bump] || "build" test_mode = options[:test_mode] == true || options[:test_mode] == "true" UI.message("๐Ÿš€ Starting iOS deployment") UI.message(" Track: #{deployment_track}") UI.message(" Version bump: #{version_bump}") UI.message(" Test mode: #{test_mode}") # In CI, version should already be set by version-manager.cjs # Verify it matches expected values if is_ci && version_bump == "skip" Fastlane::Helpers.verify_ci_version_match end # Prepare and build (no version bumping inside) result = prepare_ios_build(prod_release: deployment_track == "production", version_bump: version_bump) # Handle deployment based on track if test_mode UI.important("๐Ÿงช TEST MODE: Skipping App Store upload") UI.success("โœ… Build completed successfully!") elsif deployment_track == "internal" upload_to_testflight( api_key: result[:api_key], distribute_external: false, skip_waiting_for_build_processing: false, ) if result[:should_upload] elsif deployment_track == "production" # For production, upload to TestFlight first, then promote upload_to_testflight( api_key: result[:api_key], distribute_external: false, skip_waiting_for_build_processing: true, ) if result[:should_upload] # TODO: Add app store submission when ready UI.important("โš ๏ธ Production deployment uploaded to TestFlight. Manual App Store submission required.") end # Update deployment info if result[:should_upload] && !test_mode Fastlane::Helpers.update_deployment_timestamp("ios") end end private_lane :prepare_ios_build do |options| version_bump = options[:version_bump] || "build" if local_development # app breaks with Xcode 16.3 xcode_select "/Applications/Xcode.app" # Set up API key, profile, and potentially certificate for local dev Fastlane::Helpers.ios_dev_setup_connect_api_key(ios_connect_api_key_path) Fastlane::Helpers.ios_dev_setup_provisioning_profile(ios_provisioning_profile_directory) Fastlane::Helpers.ios_dev_setup_certificate else # we need this for building ios apps in CI # else build will hang on "[CP] Embed Pods Frameworks" setup_ci( keychain_name: "build.keychain", ) end required_env_vars = [ "IOS_APP_IDENTIFIER", "IOS_CONNECT_API_KEY_BASE64", "IOS_CONNECT_API_KEY_PATH", "IOS_CONNECT_ISSUER_ID", "IOS_CONNECT_KEY_ID", "IOS_PROJECT_NAME", "IOS_PROJECT_SCHEME", "IOS_PROV_PROFILE_NAME", "IOS_PROV_PROFILE_PATH", "IOS_TEAM_ID", "IOS_TEAM_NAME", ] target_platform = options[:prod_release] ? "App Store" : "TestFlight" should_upload = Fastlane::Helpers.should_upload_app(target_platform) workspace_path = File.expand_path("../ios/#{PROJECT_NAME}.xcworkspace", Dir.pwd) ios_signing_certificate_name = "iPhone Distribution: #{ENV["IOS_TEAM_NAME"]} (#{ENV["IOS_TEAM_ID"]})" Fastlane::Helpers.verify_env_vars(required_env_vars) if local_development && version_bump != "skip" Fastlane::Helpers.bump_local_build_number("ios") end # Read build number from version.json (already set by CI or local version-manager.cjs) build_number = Fastlane::Helpers.get_ios_build_number UI.message("๐Ÿ“ฆ Using iOS build number: #{build_number}") # Update Xcode project with build number increment_build_number( build_number: build_number, xcodeproj: "ios/#{ENV["IOS_PROJECT_NAME"]}.xcodeproj", ) # Verify build number is higher than TestFlight Fastlane::Helpers.ios_verify_app_store_build_number(ios_xcode_profile_path) Fastlane::Helpers.ios_verify_provisioning_profile api_key = app_store_connect_api_key( key_id: ENV["IOS_CONNECT_KEY_ID"], issuer_id: ENV["IOS_CONNECT_ISSUER_ID"], key_filepath: ENV["IOS_CONNECT_API_KEY_PATH"], in_house: false, ) # Update project to use manual code signing update_code_signing_settings( use_automatic_signing: false, path: "ios/#{PROJECT_NAME}.xcodeproj", team_id: ENV["IOS_TEAM_ID"], targets: [PROJECT_NAME], code_sign_identity: ios_signing_certificate_name, profile_name: ENV["IOS_PROV_PROFILE_NAME"], bundle_identifier: ENV["IOS_APP_IDENTIFIER"], build_configurations: ["Release"], ) clear_derived_data # Print final build settings before archiving sh "xcodebuild -showBuildSettings -workspace #{workspace_path} " \ "-scheme #{PROJECT_SCHEME} -configuration Release " \ "| grep 'CODE_SIGN_STYLE\|PROVISIONING_PROFILE_SPECIFIER\|CODE_SIGN_IDENTITY\|DEVELOPMENT_TEAM' || true" cocoapods( podfile: "ios/Podfile", clean_install: true, # setting to false for now because of NFCPassportReader locally we use ssh to install but the # workflow uses https so the Podfile.lock is different deployment: false, ) ipa_path = build_app({ workspace: "#{workspace_path}", scheme: PROJECT_SCHEME, export_method: "app-store", output_directory: "ios/build", clean: true, export_options: { method: "app-store", signingStyle: "manual", provisioningProfiles: { ENV["IOS_APP_IDENTIFIER"] => ENV["IOS_PROV_PROFILE_NAME"], }, signingCertificate: ios_signing_certificate_name, teamID: ENV["IOS_TEAM_ID"], }, }) { api_key: api_key, build_number: build_number, ipa_path: ipa_path, should_upload: should_upload, } end end platform :android do desc "Sync android version (DEPRECATED)" lane :sync_version do UI.error("โ›” This lane is deprecated!") UI.error("Version management is now centralized in CI.") UI.error("Use: node scripts/version-manager.cjs apply ") UI.user_error!("sync_version lane is deprecated - use version-manager.cjs instead") end desc "Push a new build to Google Play Internal Testing" lane :internal_test do |options| upload_android_build(track: "internal", test_mode: options[:test_mode]) end desc "Push a new build to Google Play Store" lane :deploy do upload_android_build(track: "production") end desc "Build Android app without uploading" lane :build_only do |options| deployment_track = options[:deployment_track] || "internal" version_bump = options[:version_bump] || "build" UI.message("๐Ÿ”จ Building Android app (build only)") UI.message(" Track: #{deployment_track}") UI.message(" Version bump: #{version_bump}") upload_android_build(options.merge(skip_upload: true)) end desc "Deploy Android app with automatic version management" lane :deploy_auto do |options| deployment_track = options[:deployment_track] || "internal" version_bump = options[:version_bump] || "build" test_mode = options[:test_mode] == true || options[:test_mode] == "true" UI.message("๐Ÿš€ Starting Android deployment") UI.message(" Track: #{deployment_track}") UI.message(" Version bump: #{version_bump}") UI.message(" Test mode: #{test_mode}") # In CI, version should already be set by version-manager.cjs # Verify it matches expected values if is_ci && version_bump == "skip" Fastlane::Helpers.verify_ci_version_match end # Map deployment track to Play Store track play_store_track = deployment_track == "production" ? "production" : "internal" # Build and deploy upload_android_build(track: play_store_track, test_mode: test_mode, deployment_track: deployment_track, version_bump: version_bump) end private_lane :upload_android_build do |options| test_mode = options[:test_mode] == true || options[:test_mode] == "true" skip_upload = options[:skip_upload] == true || options[:skip_upload] == "true" version_bump = options[:version_bump] || "build" # Automatically skip uploads in local development (no local permissions for Play Store) # Uploads must be done by CI/CD machines with proper authentication if local_development && !skip_upload skip_upload = true UI.important("๐Ÿ  LOCAL DEVELOPMENT: Play Store uploads are disabled") UI.important(" Upload the AAB manually in the Play Console after the build finishes") end if local_development if ENV["ANDROID_KEYSTORE_PATH"].nil? ENV["ANDROID_KEYSTORE_PATH"] = Fastlane::Helpers.android_create_keystore(android_keystore_path) end end required_env_vars = [ "ANDROID_KEYSTORE", "ANDROID_KEYSTORE_PASSWORD", "ANDROID_KEYSTORE_PATH", "ANDROID_KEY_ALIAS", "ANDROID_KEY_PASSWORD", "ANDROID_PACKAGE_NAME", ] Fastlane::Helpers.verify_env_vars(required_env_vars) if local_development && version_bump != "skip" Fastlane::Helpers.bump_local_build_number("android") end # Read version code from version.json (already set by CI or local version-manager.cjs) version_code = Fastlane::Helpers.get_android_build_number UI.message("๐Ÿ“ฆ Using Android build number: #{version_code}") # Update build.gradle with version code increment_version_code( version_code: version_code, gradle_file_path: android_gradle_file_path.gsub("../", ""), ) # TODO: uncomment when we have the permissions to run this action # Fastlane::Helpers.android_verify_version_code(android_gradle_file_path) target_platform = options[:track] == "production" ? "Google Play" : "Internal Testing" should_upload = Fastlane::Helpers.should_upload_app(target_platform) Fastlane::Helpers.with_retry(max_retries: 3, delay: 10) do gradle( task: "clean bundleRelease --stacktrace --info", project_dir: "android/", properties: { "MYAPP_UPLOAD_STORE_FILE" => ENV["ANDROID_KEYSTORE_PATH"], "MYAPP_UPLOAD_STORE_PASSWORD" => ENV["ANDROID_KEYSTORE_PASSWORD"], "MYAPP_UPLOAD_KEY_ALIAS" => ENV["ANDROID_KEY_ALIAS"], "MYAPP_UPLOAD_KEY_PASSWORD" => ENV["ANDROID_KEY_PASSWORD"] == "EMPTY" ? "" : ENV["ANDROID_KEY_PASSWORD"], }, ) end if test_mode || skip_upload if skip_upload UI.important("๐Ÿ”จ BUILD ONLY: Skipping Play Store upload") if local_development UI.important("๐Ÿ“ฆ Manual upload required: #{android_aab_path}") end else UI.important("๐Ÿงช TEST MODE: Skipping Play Store upload") end UI.success("โœ… Build completed successfully!") UI.message("๐Ÿ“ฆ AAB path: #{android_aab_path}") else if should_upload begin upload_options = { track: options[:track], package_name: ENV["ANDROID_PACKAGE_NAME"], skip_upload_changelogs: true, skip_upload_images: true, skip_upload_screenshots: true, track_promote_release_status: "completed", aab: android_aab_path, } # In local development, use the JSON key file; in CI rely on ADC if local_development upload_options[:json_key] = ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"] else # In CI, try to use ADC credentials file directly adc_creds_path = ENV["GOOGLE_APPLICATION_CREDENTIALS"] if adc_creds_path && File.exist?(adc_creds_path) UI.message("๐Ÿ”‘ Using ADC credentials file: #{adc_creds_path}") begin # Try passing the credentials file content as json_key_data creds_content = File.read(adc_creds_path) upload_options[:json_key_data] = creds_content rescue => e UI.error("Failed to read ADC credentials: #{e.message}") # Fallback: let supply try to use ADC automatically UI.message("๐Ÿ”„ Falling back to automatic ADC detection") end else UI.error("โŒ ADC credentials not found at: #{adc_creds_path}") end end upload_to_play_store(upload_options) rescue => e if e.message.include?("forbidden") || e.message.include?("403") || e.message.include?("insufficientPermissions") UI.error("โŒ Play Store upload failed: Insufficient permissions") UI.error("Please fix permissions in Google Play Console") UI.important("Build saved at: #{android_aab_path}") else # Re-raise if it's a different error raise e end end else UI.message("Skipping Play Store upload (should_upload: false)") end end # Update deployment timestamp in version.json if should_upload Fastlane::Helpers.update_deployment_timestamp("android") end end end