diff --git a/Application/App/Project.swift b/Application/App/Project.swift index d7fd2a98..08a77304 100644 --- a/Application/App/Project.swift +++ b/Application/App/Project.swift @@ -65,7 +65,7 @@ let project = Project( release: [ "APP_ENVIRONMENT": "prod", "APS_ENVIRONMENT": "production", - "FIRESTORE_DATABASE_ID": "prod", + "FIRESTORE_DATABASE_ID": "(default)", ] ) ), diff --git a/README.md b/README.md index df38de89..b9cfbe54 100644 --- a/README.md +++ b/README.md @@ -190,16 +190,16 @@ Application/App/Sources/Resource/ └── GoogleService-Info.plist ``` -Firestore database는 build configuration 기준으로 분리함 +Firebase 프로젝트와 Firestore database는 build configuration 기준으로 분리함 ```text -Debug, Staging -> staging -Release -> prod +Debug, Staging -> staging Firebase project / Firestore (default) +Release -> prod Firebase project / Firestore (default) ``` TestFlight archive는 `Staging`, App Store 실제 서비스 archive는 `Release` configuration을 사용함 GitHub Actions 배포 workflow는 PR label 기반 자동 실행 없이 수동 실행함 -TestFlight build는 App Store 심사 제출 대상으로 승격하지 않고, 실제 배포는 같은 `MARKETING_VERSION`의 별도 `Release/prod` build로 생성함 +TestFlight build는 App Store 심사 제출 대상으로 승격하지 않고, 실제 배포는 같은 `MARKETING_VERSION`의 별도 `Release` configuration build로 생성함 build number는 TestFlight와 App Store upload가 공유하는 App Store Connect build number 공간에서 자동 증가함 - TestFlight build: `bundle exec fastlane testflight_build_only` diff --git a/fastlane/Fastfile b/fastlane/Fastfile index af402582..9d942244 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -12,11 +12,12 @@ APPSTORE_CONFIGURATION = "Release" TESTFLIGHT_APP_ENVIRONMENT = "staging" APPSTORE_APP_ENVIRONMENT = "prod" TESTFLIGHT_DATABASE_ID = "(default)" -APPSTORE_DATABASE_ID = "prod" -TESTFLIGHT_FUNCTION_API_BASE_URL = "https://asia-northeast3-devlog-staging.cloudfunctions.net/api/api" -APPSTORE_FUNCTION_API_BASE_URL = "https://asia-northeast3-devlog-c87b6.cloudfunctions.net/prodApi/api" -TESTFLIGHT_FIREBASE_PROJECT_ID = "devlog-staging" -TESTFLIGHT_GOOGLE_APP_ID = "1:686659799179:ios:eba4e86096153f0539ec8e" +APPSTORE_DATABASE_ID = "(default)" +SHARED_FUNCTION_API_PATH = "/api/api" +APP_CONFIG_XCCONFIG_PATH = File.expand_path( + "../Application/App/Sources/Resource/Config.xcconfig", + __dir__ +) VERSION_XCCONFIG_PATH = File.expand_path( "../Application/Shared/Version.xcconfig", __dir__ @@ -54,6 +55,79 @@ rescue SystemCallError => error UI.user_error!("Could not read version configuration file at #{expanded_path}: #{error.message}") end +def function_api_base_url_from_xcconfig(configuration) + expanded_path = File.expand_path(APP_CONFIG_XCCONFIG_PATH) + UI.user_error!("Missing app configuration file: #{expanded_path}") if !File.file?(expanded_path) + + escaped_configuration = Regexp.escape(configuration) + assignments = File.foreach(expanded_path).filter_map do |line| + assignment = line.match( + /^\s*FUNCTION_API_BASE_URL\s*\[\s*config\s*=\s*#{escaped_configuration}\s*\]\s*=\s*(.*)$/ + ) + next if assignment.nil? + + assignment[1].strip + end + + if assignments.empty? + UI.user_error!("Missing FUNCTION_API_BASE_URL for #{configuration} in #{expanded_path}") + end + + function_api_base_url = assignments.last.gsub("/$()/", "//") + if function_api_base_url.empty? + UI.user_error!("Empty FUNCTION_API_BASE_URL for #{configuration} in #{expanded_path}") + end + + function_api_base_url +rescue SystemCallError => error + UI.user_error!("Could not read app configuration file at #{expanded_path}: #{error.message}") +end + +def expected_firebase_configuration_from_xcconfig(configuration) + expanded_path = File.expand_path(APP_CONFIG_XCCONFIG_PATH) + UI.user_error!("Missing app configuration file: #{expanded_path}") if !File.file?(expanded_path) + + escaped_configuration = Regexp.escape(configuration) + expected_settings = { + firebase_project_id: "EXPECTED_FIREBASE_PROJECT_ID", + google_app_id: "EXPECTED_GOOGLE_APP_ID" + } + + expected_settings.to_h do |key, setting| + assignments = File.foreach(expanded_path).filter_map do |line| + assignment = line.match( + /^\s*#{setting}\s*\[\s*config\s*=\s*#{escaped_configuration}\s*\]\s*=\s*(.*)$/ + ) + next if assignment.nil? + + assignment[1].strip + end + + if assignments.empty? || assignments.last.empty? + UI.user_error!("Missing #{setting} for #{configuration} in #{expanded_path}") + end + + [key, assignments.last] + end +rescue SystemCallError => error + UI.user_error!("Could not read app configuration file at #{expanded_path}: #{error.message}") +end + +def verify_function_api_base_url_policy(function_api_base_url) + require "uri" + + uri = URI.parse(function_api_base_url) + is_valid = uri.is_a?(URI::HTTPS) && + !uri.host.to_s.empty? && + uri.path == SHARED_FUNCTION_API_PATH && + uri.query.nil? && + uri.fragment.nil? + + UI.user_error!("Invalid FUNCTION_API_BASE_URL for store build") if !is_valid +rescue URI::InvalidURIError + UI.user_error!("Invalid FUNCTION_API_BASE_URL for store build") +end + default_platform(:ios) platform :ios do @@ -105,12 +179,12 @@ platform :ios do when TESTFLIGHT_CONFIGURATION { database_id: TESTFLIGHT_DATABASE_ID, - function_api_base_url: TESTFLIGHT_FUNCTION_API_BASE_URL + function_api_base_url: function_api_base_url_from_xcconfig(TESTFLIGHT_CONFIGURATION) } when APPSTORE_CONFIGURATION { database_id: APPSTORE_DATABASE_ID, - function_api_base_url: APPSTORE_FUNCTION_API_BASE_URL + function_api_base_url: function_api_base_url_from_xcconfig(APPSTORE_CONFIGURATION) } else UI.user_error!("Unsupported store configuration: #{configuration}") @@ -119,6 +193,8 @@ platform :ios do UI.user_error!("Missing Firestore database ID for #{configuration}") if database_id.empty? UI.user_error!("Missing Function API base URL for #{configuration}") if function_api_base_url.empty? + verify_function_api_base_url_policy(function_api_base_url) + if database_id != expected_configuration[:database_id] UI.user_error!( "Invalid store configuration: #{configuration} must use FIRESTORE_DATABASE_ID=#{expected_configuration[:database_id]}, got #{database_id}" @@ -127,11 +203,11 @@ platform :ios do if function_api_base_url != expected_configuration[:function_api_base_url] UI.user_error!( - "Invalid store configuration: #{configuration} must use FUNCTION_API_BASE_URL=#{expected_configuration[:function_api_base_url]}, got #{function_api_base_url}" + "Invalid store configuration: #{configuration} uses an unexpected FUNCTION_API_BASE_URL" ) end - UI.message("Verified store configuration: #{configuration}/#{database_id}/#{function_api_base_url}") + UI.message("Verified store configuration: #{configuration}/#{database_id}") end private_lane :verify_store_info_plist do |options| @@ -205,7 +281,8 @@ platform :ios do path: plist_path, source: "app Info.plist", key: "FUNCTION_API_BASE_URL", - expected: expected_configuration[:function_api_base_url] + expected: expected_configuration[:function_api_base_url], + redact_values: true ) verify_plist_value( @@ -235,15 +312,11 @@ platform :ios do ) if !expected_firebase_project_id.empty? && actual_firebase_project_id != expected_firebase_project_id - UI.user_error!( - "Unexpected PROJECT_ID in GoogleService-Info.plist: expected #{expected_firebase_project_id}, got #{actual_firebase_project_id}" - ) + UI.user_error!("Unexpected PROJECT_ID in GoogleService-Info.plist") end if !expected_google_app_id.empty? && actual_google_app_id != expected_google_app_id - UI.user_error!( - "Unexpected GOOGLE_APP_ID in GoogleService-Info.plist: expected #{expected_google_app_id}, got #{actual_google_app_id}" - ) + UI.user_error!("Unexpected GOOGLE_APP_ID in GoogleService-Info.plist") end UI.message("Verified PROJECT_ID in GoogleService-Info.plist") @@ -283,9 +356,11 @@ platform :ios do UI.user_error!("Missing expected #{options[:key]}") if expected_value.empty? if actual_value != expected_value - UI.user_error!( - "Unexpected #{options[:key]} in #{options[:source]}: expected #{expected_value}, got #{actual_value}" - ) + message = "Unexpected #{options[:key]} in #{options[:source]}" + if options[:redact_values] != true + message += ": expected #{expected_value}, got #{actual_value}" + end + UI.user_error!(message) end UI.message("Verified #{options[:key]} in #{options[:source]}") @@ -297,7 +372,7 @@ platform :ios do expected_database_id = options[:database_id] || (configuration == APPSTORE_CONFIGURATION ? APPSTORE_DATABASE_ID : TESTFLIGHT_DATABASE_ID) expected_function_api_base_url = options[:function_api_base_url] || - (configuration == APPSTORE_CONFIGURATION ? APPSTORE_FUNCTION_API_BASE_URL : TESTFLIGHT_FUNCTION_API_BASE_URL) + function_api_base_url_from_xcconfig(configuration) expected_app_environment = configuration == APPSTORE_CONFIGURATION ? APPSTORE_APP_ENVIRONMENT : TESTFLIGHT_APP_ENVIRONMENT @@ -307,6 +382,8 @@ platform :ios do function_api_base_url: expected_function_api_base_url ) + firebase_configuration = expected_firebase_configuration_from_xcconfig(configuration) + if ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"].to_s.strip.empty? ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "30" end @@ -398,8 +475,8 @@ platform :ios do bundle_id: APP_IDENTIFIER, database_id: expected_database_id, function_api_base_url: expected_function_api_base_url, - firebase_project_id: options[:firebase_project_id], - google_app_id: options[:google_app_id] + firebase_project_id: firebase_configuration[:firebase_project_id], + google_app_id: firebase_configuration[:google_app_id] } ) @@ -415,9 +492,7 @@ platform :ios do build_for_store( configuration: TESTFLIGHT_CONFIGURATION, database_id: TESTFLIGHT_DATABASE_ID, - function_api_base_url: TESTFLIGHT_FUNCTION_API_BASE_URL, - firebase_project_id: TESTFLIGHT_FIREBASE_PROJECT_ID, - google_app_id: TESTFLIGHT_GOOGLE_APP_ID + function_api_base_url: function_api_base_url_from_xcconfig(TESTFLIGHT_CONFIGURATION) ) upload_testflight_build @@ -427,9 +502,7 @@ platform :ios do build_for_store( configuration: TESTFLIGHT_CONFIGURATION, database_id: TESTFLIGHT_DATABASE_ID, - function_api_base_url: TESTFLIGHT_FUNCTION_API_BASE_URL, - firebase_project_id: TESTFLIGHT_FIREBASE_PROJECT_ID, - google_app_id: TESTFLIGHT_GOOGLE_APP_ID + function_api_base_url: function_api_base_url_from_xcconfig(TESTFLIGHT_CONFIGURATION) ) end @@ -438,7 +511,7 @@ platform :ios do configuration: APPSTORE_CONFIGURATION, output_directory: APPSTORE_BUILD_OUTPUT_DIRECTORY, database_id: APPSTORE_DATABASE_ID, - function_api_base_url: APPSTORE_FUNCTION_API_BASE_URL + function_api_base_url: function_api_base_url_from_xcconfig(APPSTORE_CONFIGURATION) ) end @@ -447,7 +520,7 @@ platform :ios do configuration: APPSTORE_CONFIGURATION, output_directory: APPSTORE_BUILD_OUTPUT_DIRECTORY, database_id: APPSTORE_DATABASE_ID, - function_api_base_url: APPSTORE_FUNCTION_API_BASE_URL + function_api_base_url: function_api_base_url_from_xcconfig(APPSTORE_CONFIGURATION) ) upload_appstore_build @@ -468,6 +541,7 @@ platform :ios do lane :upload_testflight_build do api_key = asc_api_key + firebase_configuration = expected_firebase_configuration_from_xcconfig(TESTFLIGHT_CONFIGURATION) # lane_context는 같은 fastlane 실행 내에서만 유지되므로, 별도 CI step에서는 고정 ipa 경로를 사용한다. ipa_output_path = lane_context[SharedValues::IPA_OUTPUT_PATH].to_s ipa_output_path = TESTFLIGHT_IPA_OUTPUT_PATH if ipa_output_path.empty? @@ -480,9 +554,9 @@ platform :ios do app_environment: TESTFLIGHT_APP_ENVIRONMENT, bundle_id: APP_IDENTIFIER, database_id: TESTFLIGHT_DATABASE_ID, - function_api_base_url: TESTFLIGHT_FUNCTION_API_BASE_URL, - firebase_project_id: TESTFLIGHT_FIREBASE_PROJECT_ID, - google_app_id: TESTFLIGHT_GOOGLE_APP_ID + function_api_base_url: function_api_base_url_from_xcconfig(TESTFLIGHT_CONFIGURATION), + firebase_project_id: firebase_configuration[:firebase_project_id], + google_app_id: firebase_configuration[:google_app_id] } ) @@ -495,6 +569,7 @@ platform :ios do lane :upload_appstore_build do api_key = asc_api_key + firebase_configuration = expected_firebase_configuration_from_xcconfig(APPSTORE_CONFIGURATION) # lane_context는 같은 fastlane 실행 내에서만 유지되므로, 별도 CI step에서는 고정 ipa 경로를 사용한다. ipa_output_path = lane_context[SharedValues::IPA_OUTPUT_PATH].to_s ipa_output_path = APPSTORE_IPA_OUTPUT_PATH if ipa_output_path.empty? @@ -507,7 +582,9 @@ platform :ios do app_environment: APPSTORE_APP_ENVIRONMENT, bundle_id: APP_IDENTIFIER, database_id: APPSTORE_DATABASE_ID, - function_api_base_url: APPSTORE_FUNCTION_API_BASE_URL + function_api_base_url: function_api_base_url_from_xcconfig(APPSTORE_CONFIGURATION), + firebase_project_id: firebase_configuration[:firebase_project_id], + google_app_id: firebase_configuration[:google_app_id] } )