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