Tuist+Fastlane ) CI/CD 구축하기
지난 글에서 match를 이용한 코드 서명 자동화 설정을 했었다.
당시에는 app_identifier, team_id, apple_id 등등
fastlane 설정 파일에 직접 하드코딩하는 방식으로 구현했었다.
이번 글에서는 보안성을 생각해서 .env.default를 도입했다.
그래서 fastlane으로 TestFlight 및 App Store 자동 배포 설정하고,
Discord 알림까지 연동과
Github Action 활용한 자동배포까지 정리하고자 한다.
루트 디렉토리에서 fastlane init
Tuist로 구성된 프로젝트 루트에서 Fastlane 초기 설정 시작한다.
fastlane init
.env.default
보안 노출에 대한 방지도 그렇고, 환경이 바뀔 때마다 직접 수정하는 것보다
유지보수를 높이기 때문에 환경변수를 사용하고자 했다.
// .env.default
APPLE_ID=애플 개발자 계정
...
DISCORD_WEBHOOK_URL=웹훅 url
MATCH_GIT_URL=프라이빗 저장소 깃 url
사용예시)
apple_id(ENV["APPLE_ID"])
git_url(ENV["MATCH_GIT_URL"])
webhook_url = ENV["DISCORD_WEBHOOK_URL"]
Appfile
해당 파일에서 수정해야 하는 경우에는,
TEAM_ID는 아래 계정에서 팀 ID를 확인 할 수 있고,
로그인 - Apple
idmsa.apple.com
ITC_TEAM_ID 은 로그인 이후에 아래 링크를 들어가면,
https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/user/detail
{
"data": {
"associatedAccounts": [
{
"contentProvider": {
"contentProviderId"
contentProviderId를 넣어주면 된다.
App Store Connect API Key
[Fastlane] App Store Connect API Key로 인증하기
이중 인증 이슈 없이 App Store Connect 로그인하기
sweepty.medium.com
해당 내용에 대해 많은 도움이 된 레퍼런스가 있어서, 첨부
App Store Connect API Key를 사용하는 이유는,
(2FA)2단계 인증 없이도 안정적으로 인증할 수 있고,
갱신 같은 번거로운 작업이 없어 관리하기 편하다.
최초 한번 다운로드가 가능하니 주의해야 한다.
fastlane/ 하위에
asc_api_key.json을 생성하여 아래와 같이 만들었다.
이때 인증키의 \n 줄개행을 적절히 넣어줘야 한다.
{
"key_id": "",
"issuer_id": "",
"key": "-----BEGIN PRIVATE KEY-----\n ... \n-----END PRIVATE KEY-----",
"duration": 1200,
"in_house": false
}
Fastlane 구성
lane은 fastlane의 작업단위를 담당한다.
난 디스코드로 알림을 연동하고자 했고,
테스트플라이트와 앱스토어 배포를 분리해서 구현하고자 했다.
아래와 같이 구성했다.
Fastfile 파일
default_platform(:ios)
VERSION_FILE = "Projects/InvestMate/Configuration/Version.xcconfig"
platform :ios do
# ===============================
# 베타 배포: TestFlight 업로드만
# ===============================
desc "배포: TestFlight 전용 (Beta)"
lane :beta do
prepare_build
upload_to_testflight(
api_key_path: "fastlane/asc_api_key.json",
skip_waiting_for_build_processing: true
)
send_discord(message: "🚀 베타 배포 성공! TestFlight 업로드 완료")
rescue => error
send_discord(message: "❌ 베타 배포 실패: #{error.message}")
raise error
end
# ===============================
# 릴리즈 배포: TestFlight + App Store
# ===============================
desc "배포: TestFlight + App Store (Release)"
lane :release do
prepare_build
upload_to_app_store(
api_key_path: "fastlane/asc_api_key.json",
submit_for_review: false,
skip_metadata: false,
skip_screenshots: true,
precheck_include_in_app_purchases: false,
copyright: "© #{Time.now.year} KAI.",
automatic_release: true,
release_notes: {
"en-US" => "Bug fixes and stability improvements",
"ko" => "버그 수정 및 안정성 개선"
},
submission_information: {
export_compliance_encryption_updated: false,
export_compliance_uses_encryption: false,
add_id_info_uses_idfa: false
},
force: true
)
send_discord(message: "✅ 릴리즈 배포 성공! TestFlight + App Store 업로드 완료")
rescue => error
send_discord(message: "❌ 릴리즈 배포 실패: #{error.message}")
raise error
end
# ===============================
# 공통 작업: match, 빌드번호 증가, 빌드
# ===============================
private_lane :prepare_build do
setup_ci
match(
type: "appstore",
readonly: true,
api_key_path: "fastlane/asc_api_key.json"
)
build_number = bump_xcconfig
UI.message("↗️ New build number: #{build_number}")
build_app(
workspace: "InvestMateWorkspace.xcworkspace",
scheme: "InvestMate",
export_method: "app-store",
export_options: {
provisioningProfiles: {
"io.hogeunjo.InvestMate" => "match AppStore io.hogeunjo.InvestMate"
}
}
)
end
# ===============================
# XCConfig 전용 빌드번호 Bump Lane
# ===============================
desc "Bump CURRENT_PROJECT_VERSION in Version.xcconfig"
lane :bump_xcconfig do
current = get_xcconfig_value(
path: VERSION_FILE,
name: "CURRENT_PROJECT_VERSION"
).to_i
next_build = current + 1
update_xcconfig_value(
path: VERSION_FILE,
name: "CURRENT_PROJECT_VERSION",
value: next_build.to_s,
mask_value: false
)
next_build
end
# ===============================
# Discord Webhook 알림
# ===============================
private_lane :send_discord do |options|
require 'date'
message = options[:message]
webhook_url = ENV['DISCORD_WEBHOOK_URL']
# 빌드 정보 수집
version = get_version_number(xcodeproj:"Projects/InvestMate/InvestMate.xcodeproj")
build_number = get_xcconfig_value(path: VERSION_FILE, name: "CURRENT_PROJECT_VERSION")
scheme = "InvestMate"
target = "io.hogeunjo.InvestMate"
git_hash = sh("git rev-parse --short HEAD").strip
branch = sh("git rev-parse --abbrev-ref HEAD").strip
timestamp = DateTime.now.strftime("%Y-%m-%d %H:%M:%S")
# 디스코드 메시지 포맷
payload = {
content: message,
embeds: [
{
title: "📦 배포 정보",
fields: [
{ name: "Version", value: "#{version} (#{build_number})", inline: true },
{ name: "Target", value: target, inline: true },
{ name: "Scheme", value: scheme, inline: true },
{ name: "Branch", value: branch, inline: true },
{ name: "Git Commit", value: git_hash, inline: true },
{ name: "Time", value: timestamp, inline: false }
]
}
]
}
# JSON 문자열로 변환
require 'json'
json_payload = payload.to_json
escaped_payload = Shellwords.escape(json_payload)
escaped_hook = Shellwords.escape(webhook_url)
sh "curl -H \"Content-Type: application/json\" -X POST -d #{escaped_payload} #{escaped_hook}"
end
end
( 아래 트러블 슈팅작성하면서, 적용한 최종 Fastfile 코드이다. )
그리고
[!] Precheck cannot check In-app purchases with the App Store Connect API Key (yet). Exclude In-app purchases from precheck, di ble the precheck step in your build step, or use Apple ID login
설정하면서 이러한 에러를 경험했었는데,
현재 해당 프로젝트는 인앱결제가 없어서,
precheck_include_in_app_purchases: false 설정하여 해결했다.
upload_to_app_store - fastlane docs
<!-- This file is auto-generated and will be re-generated every time the docs are updated. To modify it, go to its source at https://github.com/fastlane/fastlane/blob/master/fastlane/lib/fastlane/actions/upload_to_app_store.rb --> upload_to_app_store Uploa
docs.fastlane.tools
메서드의 속성들을 확인할 수 있다.
Fastlane 결과
fastlane beta를 하면 테스트플라이트에만 올라가고,
fastlane release하면 앱스토어에 올라간다.
근데 현재는 이러한 과정을 확인하기 위해
submit_for_review: false, 설정해놨는데, true하면 자동제출이 되면서
빌드 업로드도 자동으로 올라간다.
GitHub Actions 연결하기
이제 Fastlane 설정하고 정상적으로 배포되는 것을 확인 했으니,
자동화를 위해 Github Action 연결을 해줘야 한다.
나는 main 브랜치에만 push했을 때
- Tuist 생성, 빌드
- Fastlane release 실행
- 배포 및 디스코드 알림
과정으로 자동화를 이루고자 한다.
GitHub Secrets
워크플로우에서 사용하는 민감 정보를 안전하게 관리한다.
GitHub Secrets에는 파일을 직접 업로드할 수 없고, 문자열만 저장할 수 있기 때문에
파일 내용을 base64로 인코딩한 문자열로 변환해 등록한다.
cat .env.default | base64
cat asc_api_key.json | base64
해당 파일이 있는 디렉토리로 이동하고, 명령어를 사용하여 문자열을 얻는다.
yml 파일 작성
GitHub Actions 워크플로우는
루트 디렉토리의 .github/workflows 경로에 직접 .yml 파일을 만들어 커밋하거나,
GitHub 웹에서 Actions 탭을 통해 워크플로우를 설정할 수 있다.
우선 파일작성에 앞서
겪었던 이슈 케이스를 정리하고 최종 코드를 나열하고자 한다.
트러블 슈팅
Could not locate Gemfile
기준 경로를 찾지 못해서 발생한 에러는
defaults:
run:
working-directory: ./InvestMate
디렉토리를 명시해서 해결했다.
Tuist 버전을 찾지 못하는 에러 발생
Tuist 명령어 실행 시 사용할 버전이 명시되지 않아 에러가 발생
mise를 설치해서 정확한 버전을 제시하도록 했고,
mise x -- Tuist Install 와 같이
mise가 해당 버전으로 실행할 수 있도록 설정하여 해결했다.
Error cloning certificates git repo, please make sure you have access to the repository - see instructions above
로컬에서는 인증 정보가 저장되어 있어서 문제없이 인증서와 프로비저닝 파일을 내려받았는데,
깃허브 액션의 가상머신에서는 인증 정보가 없어서 실패했었다.
.env파일에 아래와 같은 코드를 추가해줬다.
MATCH_PASSWORD="your_match_keychain"
MATCH_GIT_BASIC_AUTHORIZATION="your_GIT_PAT_TOKEN"
근데 Personal access token을 바로 넣었는데, 계속 똑같은 이슈를 경험했다.
찾아보니,
match - fastlane docs
type Define the profile type, can be appstore, adhoc, development, enterprise, developer_id, mac_installer_distribution, developer_id_installer development
docs.fastlane.tools
MATCH_GIT_BASIC_AUTHORIZATION에는 username:token 형식을 base64로 인코딩한 값을 넣어야 한다.
Signing작업에서 멈춤현상
Signing작업에서 멈춤현상이 일어났다.
로컬에서는 문제가 없었는데, CI에서는 문제가 발생했다.
match로 인증서랑 프로비저닝 프로필을 잘 가져오는데, 가상머신에서 키체인 관련해서 실패하는 것 같았다.
찾아보니,
setup_ci - fastlane docs
<!-- This file is auto-generated and will be re-generated every time the docs are updated. To modify it, go to its source at https://github.com/fastlane/fastlane/blob/master/fastlane/lib/fastlane/actions/setup_ci.rb --> setup_ci Setup the keychain and matc
docs.fastlane.tools
fastfile에서 setup_ci를 통해
match가 가져온 인증서를 사용할 수 있게 환경을 세팅해주니 해결되었다.
This app was built with the iOS 17.5 SDK. All iOS and iPadOS apps must be built with the iOS 18 SDK or later, included in Xcode 16 or later, in order to be uploaded to App Store Connect or submitted for distribution.
기존에는 macos-latest와 sudo xcode-select -switch /Applications/Xcode.app 를 했었는데,
이는 가상머신이 필요로하는 버전을 선택하지 않아서, 에러가 발생했다.
runner-images/images/macos/macos-15-Readme.md at main · actions/runner-images
GitHub Actions runner images. Contribute to actions/runner-images development by creating an account on GitHub.
github.com
문서를 보니,
macos-15와 xcode 16.3버전을 명시했는데 해결되었다.
[Application Loader Error Output]: The call to the altool completed with a non-zero exit status: 1. This indicates a failure.
( + update )
Xcode에는 agvtool이라는 도구가 내장되어 있어
빌드 번호(CURRENT_PROJECT_VERSION)와 마케팅 버전(CFBundleShortVersionString)을
간단히 조회하고 증가시킬 수 있다.
Tuist 기반으로 프로젝트를 구성한 나는,
로컬에서는 agvtool로 빌드 번호가 잘 증가했지만,
GitHub Actions에서 CI 환경으로 넘어가면 문제가 발생했다.
CI는 매번 깨끗하게 클론 → Tuist generate → VersioningSystem = apple-generic으로 재설정되기 때문에
예를 들어, 이미 빌드버전 1이 올라간 상태에서, 나는 빌드 버전2가 증가된 프로젝트가 배포되길 기대했으나,
초기값을 바탕으로 똑같이 빌드 버전 1이 또 올라가서,
중복된 빌드버전이기 때문에 에러가 발생했다.
---
그래서 로컬과 CI 환경에서 자동화로 빌드 증가가 되기 위해 방안을 찾았다.
XCConfig 기반 버전 관리
1. Version.xcconfig 파일을 추가했다.
//
// Version.xcconfig
//
CFBundleShortVersionString = 마케팅버전
CURRENT_PROJECT_VERSION = 빌드번호
2. Tuist 설정에 xcconfig를 연결한다.
.debug(
name: "Debug",
settings: [...],
xcconfig: "Configuration/Version.xcconfig"
),
.release(
name: "Release",
settings: [...],
xcconfig: "Configuration/Version.xcconfig"
)
3. info.plist에 변수 참조를 추가한다.
"CFBundleVersion": "$(CURRENT_PROJECT_VERSION)",
"CFBundleShortVersionString": "$(CFBundleShortVersionString)"
4. Fastlane 플러그인 연동
기존 fastlane의 내장된 빌드 증가하는 방법말고,
나는 xcconfig파일 바탕으로 조회, 증가가 되어야하기 때문에,
fastlane-plugin-xcconfig를 설치해서
xcconfig 값을 읽고 수정할 수 있도록 설정했다.
GitHub - sovcharenko/fastlane-plugin-xcconfig
Contribute to sovcharenko/fastlane-plugin-xcconfig development by creating an account on GitHub.
github.com
그리고 터미널에서는 아래 명령어처럼 실행되어야 한다.
이제부터 플러그인과 함께 사용해야 하기 때문이다.
bundle exec fastlane beta
---
CI에서 버전이 증가했으면 그대로 종료하지 않고,
버전 증가 사항을 커밋 + 푸시하고자 의도했다.
CI에서
persist-credentials: true
GitHub Actions에서 checkout 액션 사용 시,
기본 GITHUB_TOKEN을 이용한 Git 인증 정보를 자동으로 설정해주는 옵션이다.
근데 기본적으로 읽기 권한만 있기 때문에,
해당 에러를 경험했는데,
GitHub 리포지토리 > Settings > Actions > General 들어가서,
Read and write permissions 를 체크하면 해결된다.
최종 yml 파일
on:
push:
branches:
- main
workflow_dispatch:
jobs:
release:
runs-on: macos-15
defaults:
run:
working-directory: ./InvestMate
steps:
- name: Checkout source code
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Select Xcode version
run: sudo xcode-select -switch /Applications/Xcode.app
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
- name: Install Bundler & Gems
run: bundle install
- name: Install Tuist
run: curl -Ls https://install.tuist.io | bash
- name: Install Mise
run: |
curl https://mise.run | sh
mise install
- name: Set up Tuist project
run: |
mise x -- tuist install
mise x -- tuist generate
- name: Decode asc_api_key.json
run: |
mkdir -p fastlane
echo "${{ secrets.ASC_API_KEY_JSON_BASE64 }}" | base64 --decode > fastlane/asc_api_key.json
- name: Decode .env
run: echo "${{ secrets.ENV_FILE_BASE64 }}" | base64 --decode > fastlane/.env
- name: Select Xcode 16.3
run: sudo xcode-select -switch /Applications/Xcode_16.3.app
- name: Check selected Xcode version
run: xcodebuild -version
- name: Run Fastlane Release
run: bundle exec fastlane release
- name: Commit & Push bumped Version.xcconfig
if: ${{ success() }}
env:
GIT_USER_NAME: ${{ secrets.GIT_USER_NAME }}
GIT_USER_EMAIL: ${{ secrets.GIT_USER_EMAIL }}
run: |
git config user.name "$GIT_USER_NAME"
git config user.email "$GIT_USER_EMAIL"
git add Projects/InvestMate/Configuration/Version.xcconfig
git commit -m "bump build number [skip ci]"
git push origin HEAD:main
기본 세팅은 macOS 15에서 실행되며, 작업 디렉토리는 명시적으로 지정하였다.
workflow_dispatch는 수동 실행을 위해 설정했으나, 현재는 제거해도 무방하다.
mise와 Tuist를 기반으로 프로젝트가 자동으로 구성되며, asc_api_key.json과 .env 파일은 디코딩 과정을 거친다.
그 후 릴리즈 배포가 진행되며, 모든 작업이 성공하면 Version.xcconfig 파일만 커밋 및 푸시되어
CI와 로컬 환경의 빌드 번호를 통일되게 관리할 수 있다.
CI/CD 결과
main에 푸쉬를 하면 자동으로 앱스토어 배포하는 CI/CD를 구성해보았는데,
확실히 앱스토어 배포할 때 시간을 효율적으로 쓸 수 있어서 정말 도움이 되는 것 같다.