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
2 changes: 1 addition & 1 deletion Application/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ let project = Project(
release: [
"APP_ENVIRONMENT": "prod",
"APS_ENVIRONMENT": "production",
"FIRESTORE_DATABASE_ID": "prod",
"FIRESTORE_DATABASE_ID": "(default)",
Comment thread
opficdev marked this conversation as resolved.
]
)
),
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
145 changes: 111 additions & 34 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand All @@ -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}"
Expand All @@ -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|
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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]}")
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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]
}
)

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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?
Expand All @@ -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]
}
)

Expand All @@ -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?
Expand All @@ -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]
}
)

Expand Down
Loading