Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions .github/actions/install-private-config/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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"

Expand All @@ -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" \
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/appstore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Application/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
2 changes: 2 additions & 0 deletions Application/App/Sources/Resource/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
#else
<false/>
#endif
<key>APP_ENVIRONMENT</key>
<string>$(APP_ENVIRONMENT)</string>
<key>FIRESTORE_DATABASE_ID</key>
<string>$(FIRESTORE_DATABASE_ID)</string>
<key>FUNCTION_API_BASE_URL</key>
Expand Down
80 changes: 51 additions & 29 deletions Application/Infra/Sources/Common/FirebaseConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,57 +8,79 @@
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
}
Comment thread
opficdev marked this conversation as resolved.

static var firestore: Firestore {
Firestore.firestore(database: databaseID)
}

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
Expand Down
86 changes: 86 additions & 0 deletions Application/Infra/Tests/Common/FirebaseConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading