default_platform :ios # No Xcode version pin: novaloop-mac-studio-001 carries a single, globally-selected Xcode, so # whatever xcode-select points at is by definition the one we build with. Pinning bought nothing # and risked everything — a version the runner lacks sends xcodes into a multi-GB authenticated # download that no CI secret can satisfy, hanging the release. Change the runner's Xcode instead. platform :ios do #### Public #### desc "Builds the App Store ipa and uploads it to TestFlight (internal testers only)" # Named release_testflight to avoid colliding with fastlane's built-in :testflight action. lane :release_testflight do |options| # Accept inputs via options (local) or env vars (CI — lets the workflow pass multi-line # release notes without shell-escaping them). marketing_version = options[:marketing_version] || ENV["HEALTHCHAT_MARKETING_VERSION"] release_notes = options[:release_notes] || ENV["HEALTHCHAT_RELEASE_NOTES"] UI.user_error!("'marketing_version' is required (pass the option or set HEALTHCHAT_MARKETING_VERSION)") if marketing_version.to_s.empty? # Apple caps CFBundleShortVersionString at three period-separated integers. The workflow's # regex is laxer and accepts markers like 26.07.3-rc1, so catch it here — otherwise the run # dies at the upload step after ~30 minutes of signing, looking exactly like a signing failure. unless marketing_version =~ /\A\d+(\.\d+){0,2}\z/ UI.user_error!("'marketing_version' must be 1-3 dot-separated integers (got '#{marketing_version}'). " \ "Apple rejects anything else at upload. Put markers in the tag or release notes instead.") end build_number = options[:build_number] || generate_build_number build_release(marketing_version: marketing_version, build_number: build_number) upload_to_testflight( ipa: lane_context[SharedValues::IPA_OUTPUT_PATH], skip_submission: true, skip_waiting_for_build_processing: true, changelog: release_notes, distribute_external: false, ) end #### Private #### desc "Builds the signed App Store ipa" private_lane :build_release do |options| marketing_version = options[:marketing_version] build_number = options[:build_number] UI.user_error!("'build_number' parameter is missing") if build_number.to_s.empty? if is_ci %w[APPSTORECONNECT_KEY_ID APPSTORECONNECT_KEY_ISSUER_ID APPSTORECONNECT_KEY_CONTENT].each do |key| UI.user_error!("'#{key}' environment variable should be set to use this lane") if ENV[key].to_s.empty? end app_store_connect_api_key( key_id: ENV["APPSTORECONNECT_KEY_ID"], issuer_id: ENV["APPSTORECONNECT_KEY_ISSUER_ID"], key_content: ENV["APPSTORECONNECT_KEY_CONTENT"] ) else UI.user_error!("'APPLE_ID' environment variable should be set to use this lane") if ENV["APPLE_ID"].to_s.empty? end team_id = CredentialsManager::AppfileConfig.try_fetch_value(:team_id) UI.user_error!("Failed to fetch the team id from the Appfile") if team_id.to_s.empty? # Inject the version at build time rather than rewriting project.yml, so no tracked file ever # carries a release-only value. All three targets resolve CFBundleShortVersionString and # CFBundleVersion from these settings, which keeps the app and its extensions consistent — # App Store rejects an upload where they disagree. xcargs_hash = { "MARKETING_VERSION" => marketing_version, "CURRENT_PROJECT_VERSION" => build_number, "CODE_SIGN_STYLE" => "Manual", } xcargs = xcargs_hash.map { |k, v| "#{k}=#{v.to_s.shellescape}" }.join(" ") xcodegen(spec: "project.yml") clear_derived_data(derived_data_path: ENV["DERIVED_DATA_PATH"]) download_provisioning_profiles disable_automatic_code_signing(path: ENV["PROJECT_PATH"]) update_project_provisioning_profiles build_app( clean: true, scheme: ENV["SCHEME"], xcargs: xcargs, export_method: "app-store", derived_data_path: ENV["DERIVED_DATA_PATH"], archive_path: ENV["ARCHIVE_PATH"], output_directory: ENV["BUILD_OUTPUT_DIRECTORY"], output_name: "#{ENV['IPA_NAME']}.ipa", buildlog_path: ENV["BUILD_LOG_DIRECTORY"], codesigning_identity: ENV["APPSTORE_CODESIGNING_IDENTITY"], skip_profile_detection: true, export_options: { method: "app-store", signingStyle: "manual", teamID: team_id, signingCertificate: ENV["APPSTORE_SIGNING_CERTIFICATE"], provisioningProfiles: { ENV["MAIN_BUNDLE_ID"] => ENV["APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER"], ENV["NSE_BUNDLE_ID"] => ENV["APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER"], ENV["SHARE_EXTENSION_BUNDLE_ID"] => ENV["APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER"], }, }, ) end desc "Downloads the three App Store provisioning profiles" private_lane :download_provisioning_profiles do { ENV["MAIN_BUNDLE_ID"] => [ENV["APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER"], ENV["MAIN_PROVISIONING_PROFILE_FILENAME"]], ENV["NSE_BUNDLE_ID"] => [ENV["APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER"], ENV["NSE_PROVISIONING_PROFILE_FILENAME"]], ENV["SHARE_EXTENSION_BUNDLE_ID"] => [ENV["APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER"], ENV["SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME"]], }.each do |bundle_id, (profile_name, filename)| get_provisioning_profile( app_identifier: bundle_id, provisioning_name: profile_name, ignore_profiles_with_different_name: true, adhoc: false, skip_certificate_verification: false, output_path: ENV["PROVISIONING_PROFILES_PATH"], filename: filename, readonly: true, ) end end desc "Points each target at its downloaded provisioning profile" private_lane :update_project_provisioning_profiles do { ENV["MAIN_TARGET"] => ENV["MAIN_PROVISIONING_PROFILE_FILENAME"], ENV["NSE_TARGET"] => ENV["NSE_PROVISIONING_PROFILE_FILENAME"], ENV["SHARE_EXTENSION_TARGET"] => ENV["SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME"], }.each do |target, filename| update_project_provisioning( xcodeproj: ENV["PROJECT_PATH"], profile: "#{ENV['PROVISIONING_PROFILES_PATH']}#{filename}", # Anchored: target_filter is a regex, and an unanchored "NSE" or "ElementX" would also # match any future target containing that substring. target_filter: "^#{target}$", build_configuration: "Release", ) end end end # Generates a build number from the current timestamp. Monotonic across runs, so a retry never # collides with a build number App Store Connect has already seen. def generate_build_number require 'date' DateTime.now.strftime("%Y%m%d%H%M%S") end