diff --git a/.github/workflows/appstore.yml b/.github/workflows/appstore.yml new file mode 100644 index 00000000..a1e84e83 --- /dev/null +++ b/.github/workflows/appstore.yml @@ -0,0 +1,143 @@ +name: iOS App Store + +on: + workflow_dispatch: + inputs: + upload_to_app_store_connect: + description: Upload to App Store Connect + required: true + default: "false" + type: choice + options: + - "false" + - "true" + +env: + RUBY_VERSION: "3.2" + XCODE_VERSION: latest + APP_STORE_TEAM_ID: ${{ secrets.APP_STORE_TEAM_ID }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_PATH: fastlane/AuthKey.p8 + SPACESHIP_CONNECT_API_IN_HOUSE: "false" + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + +permissions: + contents: read + +jobs: + appstore: + runs-on: macos-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install private config files + uses: ./.github/actions/install-private-config + with: + git_url: ${{ env.MATCH_GIT_URL }} + git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: ${{ env.RUBY_VERSION }} + + - name: Select Xcode + shell: bash + run: | + set -euo pipefail + + if [ "$XCODE_VERSION" = "latest" ]; then + XCODE_APP="$(find /Applications -maxdepth 1 -name 'Xcode*.app' -type d | sort -V | tail -n 1)" + else + XCODE_APP="/Applications/Xcode_${XCODE_VERSION}.app" + if [ ! -d "$XCODE_APP" ]; then + XCODE_APP="/Applications/Xcode-${XCODE_VERSION}.app" + fi + fi + + if [ ! -d "${XCODE_APP:-}" ]; then + echo "Requested Xcode not found for version: $XCODE_VERSION" >&2 + exit 1 + fi + + sudo xcode-select -s "$XCODE_APP/Contents/Developer" + xcodebuild -version + + - name: Set up Tuist + uses: jdx/mise-action@v4 + with: + install: true + cache: true + + - name: Generate Xcode workspace with Tuist + shell: bash + run: | + set -euo pipefail + + tuist generate --no-open + + - name: Write App Store Connect API key + env: + ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} + run: | + printf '%s' "$ASC_KEY_CONTENT" | base64 -D > "$ASC_KEY_PATH" + + - name: Build for App Store + run: bundle exec fastlane appstore_build_only + + - name: Upload dSYM to Firebase Crashlytics + shell: bash + run: | + set -euo pipefail + + google_service_info_path="Application/DevLogApp/Sources/Resource/GoogleService-Info.plist" + dsym_zip_path="$(find fastlane/appstore_build -maxdepth 1 -name '*.dSYM.zip' -print -quit)" + upload_symbols_path="$(find "$HOME/Library/Developer/Xcode/DerivedData" "$HOME/Library/Developer/Xcode/SourcePackages" "$GITHUB_WORKSPACE" -path '*/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols' -type f -print -quit 2>/dev/null || true)" + + if [ ! -f "$google_service_info_path" ]; then + echo "Missing GoogleService-Info.plist at $google_service_info_path" >&2 + exit 1 + fi + + if [ -z "$dsym_zip_path" ]; then + echo "Missing dSYM zip in fastlane/appstore_build" >&2 + exit 1 + fi + + if [ -z "$upload_symbols_path" ]; then + echo "Missing Firebase Crashlytics upload-symbols script" >&2 + exit 1 + fi + + "$upload_symbols_path" -gsp "$google_service_info_path" -p ios "$dsym_zip_path" + + - name: Upload App Store build log + if: always() + uses: actions/upload-artifact@v6 + with: + name: appstore-build-log + path: ~/Library/Logs/gym/*.log + if-no-files-found: ignore + + - name: Upload App Store dSYM + if: always() + uses: actions/upload-artifact@v6 + with: + name: appstore-dsym + path: fastlane/appstore_build/*.dSYM.zip + if-no-files-found: warn + + - name: Skip App Store Upload + if: inputs.upload_to_app_store_connect != 'true' + run: echo "Skipping App Store upload" + + - name: Upload to App Store Connect + if: inputs.upload_to_app_store_connect == 'true' + run: bundle exec fastlane upload_appstore_build diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index c41599ca..57dad9b5 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -3,19 +3,14 @@ name: iOS TestFlight on: workflow_dispatch: inputs: - upload_to_app_store_connect: - description: Upload to App Store Connect + upload_to_testflight: + description: Upload to TestFlight required: true default: "false" type: choice options: - "false" - "true" - pull_request: - types: - - closed - branches: - - develop env: SCHEME: DevLogApp @@ -35,19 +30,11 @@ permissions: jobs: testflight: - if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' && (contains(github.event.pull_request.labels.*.name, 'qa') || contains(github.event.pull_request.labels.*.name, 'qa-local'))) runs-on: macos-latest timeout-minutes: 45 steps: - - name: Checkout merge commit - if: github.event_name == 'pull_request' - uses: actions/checkout@v5 - with: - ref: ${{ github.event.pull_request.merge_commit_sha }} - - - name: Checkout current ref - if: github.event_name == 'workflow_dispatch' + - name: Checkout uses: actions/checkout@v5 - name: Install private config files @@ -159,9 +146,9 @@ jobs: if-no-files-found: warn - name: Skip TestFlight Upload - if: (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'qa-local')) || (github.event_name == 'workflow_dispatch' && inputs.upload_to_app_store_connect != 'true') + if: inputs.upload_to_testflight != 'true' run: echo "Skipping TestFlight upload" - name: Upload to TestFlight - if: (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'qa') && !contains(github.event.pull_request.labels.*.name, 'qa-local')) || (github.event_name == 'workflow_dispatch' && inputs.upload_to_app_store_connect == 'true') + if: inputs.upload_to_testflight == 'true' run: bundle exec fastlane upload_testflight_build diff --git a/Application/DevLogApp/Project.swift b/Application/DevLogApp/Project.swift index 244f4c0a..0a2b3d0b 100644 --- a/Application/DevLogApp/Project.swift +++ b/Application/DevLogApp/Project.swift @@ -52,11 +52,19 @@ let project = Project( debug: [ "APS_ENVIRONMENT": "development", "DEBUG_INFORMATION_FORMAT": "dwarf", + "FIRESTORE_DATABASE_ID": "staging", "INFOPLIST_KEY_FirebaseCrashlyticsCollectionEnabled": "NO", ], + staging: [ + "APS_ENVIRONMENT": "production", + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", + "FIRESTORE_DATABASE_ID": "staging", + "INFOPLIST_KEY_FirebaseCrashlyticsCollectionEnabled": "YES", + ], release: [ "APS_ENVIRONMENT": "production", "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", + "FIRESTORE_DATABASE_ID": "prod", "INFOPLIST_KEY_FirebaseCrashlyticsCollectionEnabled": "YES", ] ) diff --git a/Application/DevLogApp/Sources/Resource/Info.plist b/Application/DevLogApp/Sources/Resource/Info.plist index 5ae3b321..252abcda 100644 --- a/Application/DevLogApp/Sources/Resource/Info.plist +++ b/Application/DevLogApp/Sources/Resource/Info.plist @@ -40,6 +40,8 @@ FirebaseAutomaticScreenReportingEnabled + FIRESTORE_DATABASE_ID + $(FIRESTORE_DATABASE_ID) GIDClientID $(CLIENT_ID) GITHUB_CLIENT_ID diff --git a/Application/DevLogApp/Tests/Support/LocalFirebaseRESTSupport.swift b/Application/DevLogApp/Tests/Support/LocalFirebaseRESTSupport.swift index 5c55edd7..0ae0d0af 100644 --- a/Application/DevLogApp/Tests/Support/LocalFirebaseRESTSupport.swift +++ b/Application/DevLogApp/Tests/Support/LocalFirebaseRESTSupport.swift @@ -149,7 +149,7 @@ final class LocalFirebaseRESTSupport { func fetchPushNotificationIDs(userId: String) async throws -> [String] { let googleServiceInfo = try loadGoogleServiceInfo() let url = firestoreBaseURL.appending( - path: "v1/projects/\(googleServiceInfo.projectId)/databases/(default)/documents/users/" + + path: "v1/projects/\(googleServiceInfo.projectId)/databases/\(databaseID())/documents/users/" + "\(userId)/notifications", directoryHint: .notDirectory ) @@ -181,7 +181,8 @@ final class LocalFirebaseRESTSupport { func fetchWebPageURLs(userId: String) async throws -> [String] { let googleServiceInfo = try loadGoogleServiceInfo() let url = firestoreBaseURL.appending( - path: "v1/projects/\(googleServiceInfo.projectId)/databases/(default)/documents/users/\(userId)/webPages", + path: "v1/projects/\(googleServiceInfo.projectId)/databases/\(databaseID())" + + "/documents/users/\(userId)/webPages", directoryHint: .notDirectory ) let (data, response) = try await URLSession.shared.data(from: url) @@ -301,7 +302,8 @@ private extension LocalFirebaseRESTSupport { let encodedPath = encode(documentPath) var request = URLRequest( url: firestoreBaseURL.appending( - path: "v1/projects/\(googleServiceInfo.projectId)/databases/(default)/documents/\(encodedPath)", + path: "v1/projects/\(googleServiceInfo.projectId)/databases/\(databaseID())" + + "/documents/\(encodedPath)", directoryHint: .notDirectory ) ) @@ -336,6 +338,22 @@ private extension LocalFirebaseRESTSupport { }.joined(separator: "/") } + func databaseID() -> String { + let environmentValue = ProcessInfo.processInfo.environment["FIRESTORE_DATABASE_ID"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let environmentValue, !environmentValue.isEmpty { + return environmentValue + } + + let bundleValue = Bundle.main.object(forInfoDictionaryKey: "FIRESTORE_DATABASE_ID") as? String + let databaseID = bundleValue?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let databaseID, !databaseID.isEmpty, !databaseID.hasPrefix("$(") else { + return "staging" + } + + return databaseID + } + func stringValue(_ value: String) -> [String: Any] { ["stringValue": value] } diff --git a/Application/DevLogInfra/Sources/Common/FirebaseConfiguration.swift b/Application/DevLogInfra/Sources/Common/FirebaseConfiguration.swift new file mode 100644 index 00000000..72345e7c --- /dev/null +++ b/Application/DevLogInfra/Sources/Common/FirebaseConfiguration.swift @@ -0,0 +1,45 @@ +// +// FirebaseConfiguration.swift +// DevLogInfra +// +// Created by opfic on 6/26/26. +// + +import FirebaseFirestore +import FirebaseFunctions +import Foundation + +enum FirebaseConfiguration { + private enum InfoKey { + static let databaseID = "FIRESTORE_DATABASE_ID" + } + + static let defaultDatabaseID = "staging" + + static var databaseID: String { + let environmentValue = ProcessInfo.processInfo.environment[InfoKey.databaseID]? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let environmentValue, !environmentValue.isEmpty { + return environmentValue + } + + guard let rawValue = Bundle.main.object(forInfoDictionaryKey: InfoKey.databaseID) as? String else { + return defaultDatabaseID + } + + let databaseID = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if databaseID.isEmpty || databaseID.hasPrefix("$(") { + return defaultDatabaseID + } + + return databaseID + } + + static var firestore: Firestore { + Firestore.firestore(database: databaseID) + } + + static var functions: Functions { + Functions.functions(region: "asia-northeast3") + } +} diff --git a/Application/DevLogInfra/Sources/Extension/FirebaseFunctions+.swift b/Application/DevLogInfra/Sources/Extension/FirebaseFunctions+.swift index 1e69cace..d9ce43bc 100644 --- a/Application/DevLogInfra/Sources/Extension/FirebaseFunctions+.swift +++ b/Application/DevLogInfra/Sources/Extension/FirebaseFunctions+.swift @@ -6,7 +6,6 @@ // import FirebaseFunctions -import DevLogData extension Functions { func httpsCallable(_ name: some RawRepresentable) -> HTTPSCallable { diff --git a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift index 6d73ff4f..c7eb47b4 100644 --- a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift @@ -24,7 +24,7 @@ final class AuthServiceImpl: AuthService { } } - private let store = Firestore.firestore() + private let store = FirebaseConfiguration.firestore private let messaging = Messaging.messaging() private let logger = Logger(category: "AuthServiceImpl") private let subject = CurrentValueSubject(Auth.auth().currentUser != nil) diff --git a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift index 7b37318b..c856c688 100644 --- a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift @@ -34,8 +34,8 @@ final class PushNotificationServiceImpl: PushNotificationService { case undoPushNotificationDeletion } - private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") + private let store = FirebaseConfiguration.firestore + private let functions = FirebaseConfiguration.functions private let logger = Logger(category: "PushNotificationServiceImpl") /// 푸시 알림 On/Off 설정 diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 2ed89436..3722707e 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -37,8 +37,8 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { private var appleSignInDelegate: AppleSignInDelegate? private var appleSignInContinuation: CheckedContinuation? - private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") + private let store = FirebaseConfiguration.firestore + private let functions = FirebaseConfiguration.functions private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let providerID = AuthProviderID.apple diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index 97f2f2e9..f02da95e 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -38,8 +38,8 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { static let acceptHeader = "application/vnd.github.v3+json" } - private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") + private let store = FirebaseConfiguration.firestore + private let functions = FirebaseConfiguration.functions private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let providerID = AuthProviderID.gitHub diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 42db361b..b2d080e0 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -26,7 +26,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } } - private let store = Firestore.firestore() + private let store = FirebaseConfiguration.firestore private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let provider = TopViewControllerProvider() diff --git a/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift index 3ee310c0..44089d27 100644 --- a/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift @@ -35,7 +35,7 @@ final class TodoCategoryServiceImpl: TodoCategoryService { case user } - private let store = Firestore.firestore() + private let store = FirebaseConfiguration.firestore private let logger = Logger(category: "TodoCategoryServiceImpl") func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] { diff --git a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift index 9f32e727..fd84aa95 100644 --- a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift @@ -30,8 +30,8 @@ final class TodoServiceImpl: TodoService { case undoTodoDeletion } - private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") + private let store = FirebaseConfiguration.firestore + private let functions = FirebaseConfiguration.functions private let encoder = Firestore.Encoder() private let logger = Logger(category: "TodoServiceImpl") diff --git a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift index 0cf2df5c..a39f48a7 100644 --- a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift @@ -23,7 +23,7 @@ final class UserServiceImpl: UserService { } } - private let store = Firestore.firestore() + private let store = FirebaseConfiguration.firestore private let logger = Logger(category: "UserServiceImpl") // 유저를 Firestore에 저장 및 업데이트 diff --git a/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift b/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift index 955b3291..6053cbf9 100644 --- a/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift @@ -28,8 +28,8 @@ final class WebPageServiceImpl: WebPageService { case undoWebPageDeletion } - private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") + private let store = FirebaseConfiguration.firestore + private let functions = FirebaseConfiguration.functions private let encoder = Firestore.Encoder() private let logger = Logger(category: "WebPageServiceImpl") diff --git a/README.md b/README.md index a2dd9763..1a6fd500 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,23 @@ Application/DevLogApp/Sources/Resource/ └── GoogleService-Info.plist ``` +Firestore database는 build configuration 기준으로 분리함 + +```text +Debug, Staging -> staging +Release -> prod +``` + +TestFlight archive는 `Staging`, App Store 실제 서비스 archive는 `Release` configuration을 사용함 +GitHub Actions 배포 workflow는 PR label 기반 자동 실행 없이 수동 실행함 +TestFlight build는 App Store 심사 제출 대상으로 승격하지 않고, 실제 배포는 같은 `MARKETING_VERSION`의 별도 `Release/prod` build로 생성함 +build number는 TestFlight와 App Store upload가 공유하는 App Store Connect build number 공간에서 자동 증가함 + +- TestFlight build: `bundle exec fastlane testflight_build_only` +- TestFlight upload: `bundle exec fastlane deploy_testflight` +- App Store build: `bundle exec fastlane appstore_build_only` +- App Store upload: `bundle exec fastlane deploy_appstore` + ### 3. Xcode 워크스페이스 생성 ```bash diff --git a/Tuist/ProjectDescriptionHelpers/Project+Settings.swift b/Tuist/ProjectDescriptionHelpers/Project+Settings.swift index 48bae5f2..cd1f054b 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Settings.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Settings.swift @@ -9,6 +9,7 @@ public extension Settings { versionXcconfigPath: Path? = nil, base: SettingsDictionary = [:], debug: SettingsDictionary = [:], + staging: SettingsDictionary = [:], release: SettingsDictionary = [:], defaultSettings: DefaultSettings = .recommended ) -> Settings { @@ -26,6 +27,7 @@ public extension Settings { base: commonBase, configurations: [ .debug(name: "Debug", settings: debug, xcconfig: versionXcconfigPath), + .release(name: "Staging", settings: staging, xcconfig: versionXcconfigPath), .release(name: "Release", settings: release, xcconfig: versionXcconfigPath), ], defaultSettings: defaultSettings @@ -36,6 +38,7 @@ public extension Settings { base: commonBase, configurations: [ .debug(name: "Debug", settings: debug), + .release(name: "Staging", settings: staging), .release(name: "Release", settings: release), ], defaultSettings: defaultSettings diff --git a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift index e97e9ef0..d5470197 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift @@ -34,6 +34,9 @@ public extension Project { debug: [ "DEBUG_INFORMATION_FORMAT": "dwarf", ], + staging: [ + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", + ], release: [ "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", ] diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ec1c77be..970ca221 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -7,13 +7,19 @@ APP_IDENTIFIERS = [APP_IDENTIFIER, WIDGET_IDENTIFIER] TARGET_NAME = "DevLogApp" WIDGET_TARGET_NAME = "DevLogWidgetExtension" APP_PRODUCT_NAME = "DevLog" +TESTFLIGHT_CONFIGURATION = "Staging" +APPSTORE_CONFIGURATION = "Release" +TESTFLIGHT_DATABASE_ID = "staging" +APPSTORE_DATABASE_ID = "prod" TESTFLIGHT_BUILD_OUTPUT_DIRECTORY = File.expand_path("testflight_build", __dir__) TESTFLIGHT_IPA_OUTPUT_PATH = File.join(TESTFLIGHT_BUILD_OUTPUT_DIRECTORY, "#{APP_PRODUCT_NAME}.ipa") +APPSTORE_BUILD_OUTPUT_DIRECTORY = File.expand_path("appstore_build", __dir__) +APPSTORE_IPA_OUTPUT_PATH = File.join(APPSTORE_BUILD_OUTPUT_DIRECTORY, "#{APP_PRODUCT_NAME}.ipa") default_platform(:ios) platform :ios do - private_lane :fetch_latest_testflight_build_number do |options| + private_lane :fetch_latest_app_store_connect_build_number do |options| require "spaceship" api_key = options[:api_key] @@ -51,7 +57,74 @@ platform :ios do ) end - private_lane :build_for_store do + private_lane :verify_store_configuration do |options| + configuration = options[:configuration].to_s.strip + database_id = options[:database_id].to_s.strip + + expected_database_id = + case configuration + when TESTFLIGHT_CONFIGURATION + TESTFLIGHT_DATABASE_ID + when APPSTORE_CONFIGURATION + APPSTORE_DATABASE_ID + else + UI.user_error!("Unsupported store configuration: #{configuration}") + end + + UI.user_error!("Missing Firestore database ID for #{configuration}") if database_id.empty? + + if database_id != expected_database_id + UI.user_error!( + "Invalid store configuration: #{configuration} must use FIRESTORE_DATABASE_ID=#{expected_database_id}, got #{database_id}" + ) + end + + UI.message("Verified store configuration: #{configuration}/#{database_id}") + end + + private_lane :verify_firestore_database_id do |options| + require "shellwords" + require "tmpdir" + + ipa_path = options[:ipa] + expected_database_id = options[:database_id] + + UI.user_error!("Missing IPA path") if ipa_path.to_s.strip.empty? + UI.user_error!("Missing expected Firestore database ID") if expected_database_id.to_s.strip.empty? + UI.user_error!("Missing built ipa at #{ipa_path}") if !File.exist?(ipa_path) + + Dir.mktmpdir("devlog-ipa") do |tmpdir| + sh("unzip -q #{Shellwords.escape(ipa_path)} -d #{Shellwords.escape(tmpdir)}") + + plist_path = File.join(tmpdir, "Payload", "#{APP_PRODUCT_NAME}.app", "Info.plist") + UI.user_error!("Missing app Info.plist in ipa") if !File.exist?(plist_path) + + actual_database_id = sh( + "/usr/libexec/PlistBuddy -c 'Print :FIRESTORE_DATABASE_ID' #{Shellwords.escape(plist_path)}", + log: false + ).strip + + if actual_database_id != expected_database_id + UI.user_error!( + "Unexpected FIRESTORE_DATABASE_ID: expected #{expected_database_id}, got #{actual_database_id}" + ) + end + + UI.message("Verified FIRESTORE_DATABASE_ID=#{actual_database_id}") + end + end + + private_lane :build_for_store do |options| + configuration = options[:configuration] || TESTFLIGHT_CONFIGURATION + output_directory = options[:output_directory] || TESTFLIGHT_BUILD_OUTPUT_DIRECTORY + expected_database_id = options[:database_id] || + (configuration == APPSTORE_CONFIGURATION ? APPSTORE_DATABASE_ID : TESTFLIGHT_DATABASE_ID) + + verify_store_configuration( + configuration: configuration, + database_id: expected_database_id + ) + if ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"].to_s.strip.empty? ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "30" end @@ -67,23 +140,23 @@ platform :ios do target: TARGET_NAME ) - latest_testflight_build_number = fetch_latest_testflight_build_number( + latest_build_number = fetch_latest_app_store_connect_build_number( api_key: api_key, version: version_number ) setup_ci if ENV["CI"] - testflight_build_number = latest_testflight_build_number + 1 + next_build_number = latest_build_number + 1 increment_build_number( xcodeproj: XCODE_PROJ, - build_number: testflight_build_number + build_number: next_build_number ) increment_build_number( xcodeproj: WIDGET_XCODE_PROJ, - build_number: testflight_build_number + build_number: next_build_number ) match( @@ -118,7 +191,7 @@ platform :ios do sdk: "iphoneos*", team_id: ENV["APP_STORE_TEAM_ID"], targets: [target_name], - build_configurations: ["Release"], + build_configurations: [configuration], code_sign_identity: "Apple Distribution", profile_name: provisioning_profile_specifier ) @@ -128,13 +201,22 @@ platform :ios do build_app( workspace: XCODE_WORKSPACE, scheme: TARGET_NAME, + configuration: configuration, export_method: "app-store", - output_directory: TESTFLIGHT_BUILD_OUTPUT_DIRECTORY, + output_directory: output_directory, output_name: "#{APP_PRODUCT_NAME}.ipa", include_symbols: true, xcargs: "-skipPackagePluginValidation -skipMacroValidation" ) + ipa_output_path = lane_context[SharedValues::IPA_OUTPUT_PATH].to_s + ipa_output_path = File.join(output_directory, "#{APP_PRODUCT_NAME}.ipa") if ipa_output_path.empty? + + verify_firestore_database_id( + ipa: ipa_output_path, + database_id: expected_database_id + ) + dsym_output_path = lane_context[SharedValues::DSYM_OUTPUT_PATH] archive_path = lane_context[SharedValues::XCODEBUILD_ARCHIVE] UI.message("dSYM output path: #{dsym_output_path}") unless dsym_output_path.to_s.empty? @@ -144,13 +226,37 @@ platform :ios do end lane :deploy_testflight do - build_for_store + build_for_store( + configuration: TESTFLIGHT_CONFIGURATION, + database_id: TESTFLIGHT_DATABASE_ID + ) upload_testflight_build end lane :testflight_build_only do - build_for_store + build_for_store( + configuration: TESTFLIGHT_CONFIGURATION, + database_id: TESTFLIGHT_DATABASE_ID + ) + end + + lane :appstore_build_only do + build_for_store( + configuration: APPSTORE_CONFIGURATION, + output_directory: APPSTORE_BUILD_OUTPUT_DIRECTORY, + database_id: APPSTORE_DATABASE_ID + ) + end + + lane :deploy_appstore do + build_for_store( + configuration: APPSTORE_CONFIGURATION, + output_directory: APPSTORE_BUILD_OUTPUT_DIRECTORY, + database_id: APPSTORE_DATABASE_ID + ) + + upload_appstore_build end lane :sync_appstore_profiles do @@ -174,6 +280,11 @@ platform :ios do UI.user_error!("Missing built ipa at #{ipa_output_path}") if !File.exist?(ipa_output_path) + verify_firestore_database_id( + ipa: ipa_output_path, + database_id: TESTFLIGHT_DATABASE_ID + ) + upload_to_testflight( api_key: api_key, ipa: ipa_output_path, @@ -181,4 +292,28 @@ platform :ios do ) end + lane :upload_appstore_build do + api_key = asc_api_key + # 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? + + UI.user_error!("Missing built ipa at #{ipa_output_path}") if !File.exist?(ipa_output_path) + + verify_firestore_database_id( + ipa: ipa_output_path, + database_id: APPSTORE_DATABASE_ID + ) + + upload_to_app_store( + api_key: api_key, + ipa: ipa_output_path, + skip_metadata: true, + skip_screenshots: true, + submit_for_review: false, + automatic_release: false, + force: true + ) + end + end