diff --git a/.github/actions/install-private-config/action.yml b/.github/actions/install-private-config/action.yml
index 314dbe47..61f46b48 100644
--- a/.github/actions/install-private-config/action.yml
+++ b/.github/actions/install-private-config/action.yml
@@ -1,5 +1,5 @@
name: Install Private Config
-description: Fetch Config.xcconfig and GoogleService-Info.plist from a private repository.
+description: Fetch Config.xcconfig and an environment-specific GoogleService-Info.plist from a private repository.
inputs:
git_url:
@@ -8,6 +8,9 @@ inputs:
git_basic_authorization:
description: Base64-encoded basic authorization header value
required: true
+ environment:
+ description: Firebase environment to install (staging or prod)
+ required: true
ref:
description: Git ref to fetch from the private repository
required: false
@@ -21,14 +24,29 @@ runs:
env:
PRIVATE_CONFIG_GIT_URL: ${{ inputs.git_url }}
PRIVATE_CONFIG_GIT_BASIC_AUTHORIZATION: ${{ inputs.git_basic_authorization }}
+ PRIVATE_CONFIG_ENVIRONMENT: ${{ inputs.environment }}
PRIVATE_CONFIG_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
privateConfigCheckoutPath="$RUNNER_TEMP/private-config"
trap 'rm -rf "$privateConfigCheckoutPath"' EXIT
+
+ case "$PRIVATE_CONFIG_ENVIRONMENT" in
+ staging)
+ googleServiceInfoFileName="GoogleService-Info-Staging.plist"
+ ;;
+ prod)
+ googleServiceInfoFileName="GoogleService-Info-Prod.plist"
+ ;;
+ *)
+ echo "Invalid Firebase environment: $PRIVATE_CONFIG_ENVIRONMENT (expected staging or prod)" >&2
+ exit 1
+ ;;
+ esac
+
configSourcePath="$privateConfigCheckoutPath/resources/DevLog/Config.xcconfig"
- googleServiceInfoSourcePath="$privateConfigCheckoutPath/resources/DevLog/GoogleService-Info.plist"
+ googleServiceInfoSourcePath="$privateConfigCheckoutPath/resources/DevLog/$googleServiceInfoFileName"
configDestinationPath="$GITHUB_WORKSPACE/Application/App/Sources/Resource/Config.xcconfig"
googleServiceInfoDestinationPath="$GITHUB_WORKSPACE/Application/App/Sources/Resource/GoogleService-Info.plist"
@@ -40,7 +58,7 @@ runs:
git -C "$privateConfigCheckoutPath" config core.sparseCheckout true
printf '%s\n' \
'resources/DevLog/Config.xcconfig' \
- 'resources/DevLog/GoogleService-Info.plist' \
+ "resources/DevLog/$googleServiceInfoFileName" \
> "$privateConfigCheckoutPath/.git/info/sparse-checkout"
git -C "$privateConfigCheckoutPath" \
diff --git a/.github/workflows/appstore.yml b/.github/workflows/appstore.yml
index 9a156c53..9cecd566 100644
--- a/.github/workflows/appstore.yml
+++ b/.github/workflows/appstore.yml
@@ -41,6 +41,7 @@ jobs:
with:
git_url: ${{ env.MATCH_GIT_URL }}
git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }}
+ environment: prod
- name: Set up Ruby
uses: ruby/setup-ruby@v1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ce81e7b5..c71152d5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,7 @@ jobs:
with:
git_url: ${{ env.MATCH_GIT_URL }}
git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }}
+ environment: staging
- name: Select Xcode
shell: bash
@@ -240,6 +241,7 @@ jobs:
with:
git_url: ${{ env.MATCH_GIT_URL }}
git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }}
+ environment: staging
- name: Select Xcode
shell: bash
diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml
index af624833..011414f7 100644
--- a/.github/workflows/testflight.yml
+++ b/.github/workflows/testflight.yml
@@ -42,6 +42,7 @@ jobs:
with:
git_url: ${{ env.MATCH_GIT_URL }}
git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }}
+ environment: staging
- name: Set up Ruby
uses: ruby/setup-ruby@v1
diff --git a/Application/App/Project.swift b/Application/App/Project.swift
index 9c305624..d4aca877 100644
--- a/Application/App/Project.swift
+++ b/Application/App/Project.swift
@@ -53,14 +53,17 @@ let project = Project(
"PRODUCT_MODULE_NAME": "App",
],
debug: [
+ "APP_ENVIRONMENT": "staging",
"APS_ENVIRONMENT": "development",
"FIRESTORE_DATABASE_ID": "staging",
],
staging: [
+ "APP_ENVIRONMENT": "staging",
"APS_ENVIRONMENT": "production",
"FIRESTORE_DATABASE_ID": "staging",
],
release: [
+ "APP_ENVIRONMENT": "prod",
"APS_ENVIRONMENT": "production",
"FIRESTORE_DATABASE_ID": "prod",
]
diff --git a/Application/App/Sources/Resource/Info.plist b/Application/App/Sources/Resource/Info.plist
index 4ae74740..3b8f5b05 100644
--- a/Application/App/Sources/Resource/Info.plist
+++ b/Application/App/Sources/Resource/Info.plist
@@ -43,6 +43,8 @@
#else
#endif
+ APP_ENVIRONMENT
+ $(APP_ENVIRONMENT)
FIRESTORE_DATABASE_ID
$(FIRESTORE_DATABASE_ID)
FUNCTION_API_BASE_URL
diff --git a/Application/Infra/Sources/Common/FirebaseConfiguration.swift b/Application/Infra/Sources/Common/FirebaseConfiguration.swift
index bb72978c..6667e83b 100644
--- a/Application/Infra/Sources/Common/FirebaseConfiguration.swift
+++ b/Application/Infra/Sources/Common/FirebaseConfiguration.swift
@@ -8,31 +8,25 @@
import FirebaseFirestore
import Foundation
+enum FirebaseConfigurationError: Error, Equatable {
+ case unresolvedValue(String)
+}
+
public enum FirebaseConfiguration {
private enum InfoKey {
static let databaseID = "FIRESTORE_DATABASE_ID"
static let functionAPIBaseURL = "FUNCTION_API_BASE_URL"
}
- static let defaultDatabaseID = "staging"
-
public 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
+ do {
+ return try resolveDatabaseID(
+ environmentValue: ProcessInfo.processInfo.environment[InfoKey.databaseID],
+ bundleValue: Bundle.main.object(forInfoDictionaryKey: InfoKey.databaseID) as? String
+ )
+ } catch {
+ preconditionFailure("\(InfoKey.databaseID) is missing or unresolved.")
}
-
- return databaseID
}
static var firestore: Firestore {
@@ -40,25 +34,53 @@ public enum FirebaseConfiguration {
}
static func functionAPIBaseURL() throws -> URL {
- if let value = resolvedValue(for: InfoKey.functionAPIBaseURL),
- let url = URL(string: value) {
- return url
+ try resolveFunctionAPIBaseURL(
+ environmentValue: ProcessInfo.processInfo.environment[InfoKey.functionAPIBaseURL],
+ bundleValue: Bundle.main.object(forInfoDictionaryKey: InfoKey.functionAPIBaseURL) as? String
+ )
+ }
+
+ static func resolveDatabaseID(
+ environmentValue: String?,
+ bundleValue: String?
+ ) throws -> String {
+ guard let value = resolvedValue(
+ environmentValue: environmentValue,
+ bundleValue: bundleValue
+ ) else {
+ throw FirebaseConfigurationError.unresolvedValue(InfoKey.databaseID)
}
- throw URLError(.badURL)
+ return value
}
- private static func resolvedValue(for key: String) -> String? {
- let environmentValue = ProcessInfo.processInfo.environment[key]?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if let environmentValue, !environmentValue.isEmpty {
- return environmentValue
+ static func resolveFunctionAPIBaseURL(
+ environmentValue: String?,
+ bundleValue: String?
+ ) throws -> URL {
+ guard let value = resolvedValue(
+ environmentValue: environmentValue,
+ bundleValue: bundleValue
+ ),
+ let url = URL(string: value),
+ let scheme = url.scheme?.lowercased(),
+ ["http", "https"].contains(scheme),
+ url.host != nil else {
+ throw URLError(.badURL)
}
- guard let rawValue = Bundle.main.object(forInfoDictionaryKey: key) as? String else {
- return nil
- }
+ return url
+ }
+
+ private static func resolvedValue(
+ environmentValue: String?,
+ bundleValue: String?
+ ) -> String? {
+ normalizedValue(environmentValue) ?? normalizedValue(bundleValue)
+ }
+ private static func normalizedValue(_ rawValue: String?) -> String? {
+ guard let rawValue else { return nil }
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty, !value.hasPrefix("$(") else {
return nil
diff --git a/Application/Infra/Tests/Common/FirebaseConfigurationTests.swift b/Application/Infra/Tests/Common/FirebaseConfigurationTests.swift
new file mode 100644
index 00000000..ec799b6c
--- /dev/null
+++ b/Application/Infra/Tests/Common/FirebaseConfigurationTests.swift
@@ -0,0 +1,86 @@
+//
+// FirebaseConfigurationTests.swift
+// InfraTests
+//
+// Created by opfic on 7/20/26.
+//
+
+import Foundation
+import Testing
+@testable import Infra
+
+struct FirebaseConfigurationTests {
+ @Test("환경 변수의 database ID를 우선 사용한다")
+ func 환경_변수의_database_ID를_우선_사용한다() throws {
+ let databaseID = try FirebaseConfiguration.resolveDatabaseID(
+ environmentValue: "prod",
+ bundleValue: "staging"
+ )
+
+ #expect(databaseID == "prod")
+ }
+
+ @Test("환경 변수의 database ID가 비어 있으면 bundle 값을 사용한다")
+ func 환경_변수의_database_ID가_비어_있으면_bundle_값을_사용한다() throws {
+ let databaseID = try FirebaseConfiguration.resolveDatabaseID(
+ environmentValue: " ",
+ bundleValue: "prod"
+ )
+
+ #expect(databaseID == "prod")
+ }
+
+ @Test("database ID가 누락되면 오류를 반환한다")
+ func database_ID가_누락되면_오류를_반환한다() {
+ #expect(
+ throws: FirebaseConfigurationError.unresolvedValue("FIRESTORE_DATABASE_ID")
+ ) {
+ try FirebaseConfiguration.resolveDatabaseID(
+ environmentValue: nil,
+ bundleValue: nil
+ )
+ }
+ }
+
+ @Test("database ID가 미치환 값이면 오류를 반환한다")
+ func database_ID가_미치환_값이면_오류를_반환한다() {
+ #expect(
+ throws: FirebaseConfigurationError.unresolvedValue("FIRESTORE_DATABASE_ID")
+ ) {
+ try FirebaseConfiguration.resolveDatabaseID(
+ environmentValue: nil,
+ bundleValue: "$(FIRESTORE_DATABASE_ID)"
+ )
+ }
+ }
+
+ @Test("절대 HTTPS Functions URL을 반환한다")
+ func 절대_HTTPS_Functions_URL을_반환한다() throws {
+ let url = try FirebaseConfiguration.resolveFunctionAPIBaseURL(
+ environmentValue: nil,
+ bundleValue: "https://example.com/stagingApi/api"
+ )
+
+ #expect(url == URL(string: "https://example.com/stagingApi/api"))
+ }
+
+ @Test("Functions URL이 누락되면 bad URL 오류를 반환한다")
+ func Functions_URL이_누락되면_bad_URL_오류를_반환한다() {
+ #expect(throws: URLError.self) {
+ try FirebaseConfiguration.resolveFunctionAPIBaseURL(
+ environmentValue: nil,
+ bundleValue: nil
+ )
+ }
+ }
+
+ @Test("Functions URL이 상대 경로이면 bad URL 오류를 반환한다")
+ func Functions_URL이_상대_경로이면_bad_URL_오류를_반환한다() {
+ #expect(throws: URLError.self) {
+ try FirebaseConfiguration.resolveFunctionAPIBaseURL(
+ environmentValue: nil,
+ bundleValue: "stagingApi/api"
+ )
+ }
+ }
+}
diff --git a/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift b/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift
index f2201985..03d3a026 100644
--- a/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift
+++ b/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift
@@ -35,7 +35,7 @@ struct SettingsFeature {
var alertType: Action.AlertType?
var appVersion = Self.appVersion()
var betaTestURL = Self.betaTestURL(
- databaseID: Bundle.main.object(forInfoDictionaryKey: "FIRESTORE_DATABASE_ID") as? String,
+ appEnvironment: Bundle.main.object(forInfoDictionaryKey: "APP_ENVIRONMENT") as? String,
testFlightURL: Bundle.main.object(forInfoDictionaryKey: "TESTFLIGHT_URL") as? String
)
var policyURL = Bundle.main.object(forInfoDictionaryKey: "PRIVACY_POLICY_URL") as? String
@@ -44,8 +44,8 @@ struct SettingsFeature {
loading.isLoading
}
- static func betaTestURL(databaseID: String?, testFlightURL: String?) -> URL? {
- guard databaseID?.trimmingCharacters(in: .whitespacesAndNewlines) == "prod",
+ static func betaTestURL(appEnvironment: String?, testFlightURL: String?) -> URL? {
+ guard appEnvironment?.trimmingCharacters(in: .whitespacesAndNewlines) == "prod",
let rawValue = testFlightURL else {
return nil
}
diff --git a/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift
index 2c855e62..96236428 100644
--- a/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift
+++ b/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift
@@ -5,14 +5,45 @@
// Created by opfic on 6/12/26.
//
-import Testing
import Core
import Domain
+import Foundation
import PresentationShared
+import Testing
@testable import ProfileTab
@MainActor
struct SettingsFeatureTests {
+ @Test("prod 환경이면 베타 테스트 URL을 제공한다")
+ func prod_환경이면_베타_테스트_URL을_제공한다() {
+ let url = SettingsFeature.State.betaTestURL(
+ appEnvironment: "prod",
+ testFlightURL: "https://testflight.apple.com/join/b8mpr4UN"
+ )
+
+ #expect(url == URL(string: "https://testflight.apple.com/join/b8mpr4UN"))
+ }
+
+ @Test("staging 환경이면 베타 테스트 URL을 제공하지 않는다")
+ func staging_환경이면_베타_테스트_URL을_제공하지_않는다() {
+ let url = SettingsFeature.State.betaTestURL(
+ appEnvironment: "staging",
+ testFlightURL: "https://testflight.apple.com/join/b8mpr4UN"
+ )
+
+ #expect(url == nil)
+ }
+
+ @Test("미치환 환경값이면 베타 테스트 URL을 제공하지 않는다")
+ func 미치환_환경값이면_베타_테스트_URL을_제공하지_않는다() {
+ let url = SettingsFeature.State.betaTestURL(
+ appEnvironment: "$(APP_ENVIRONMENT)",
+ testFlightURL: "https://testflight.apple.com/join/b8mpr4UN"
+ )
+
+ #expect(url == nil)
+ }
+
@Test("네트워크 상태 관찰 결과를 상태에 반영한다")
func 네트워크_상태_관찰_결과를_상태에_반영한다() async {
let networkSpy = ObserveNetworkConnectivityUseCaseSpy()