- 특정 브랜치에 PR & Push 작업이 진행되었을 때 Build & Test 가 성공하는지 확인
- 특정 브랜치에 Push 되었을 때 자동으로 TestFlight 배포
- 버전을 Tag 했을 때 자동으로 AppStore 심사 제출
이번 포스터에서는 특정 브랜치에 Push 되었을 때 자동으로 TestFlight 배포 작업을 다룹니다.
또 Github Actions 환경에서 어떻게 Match를 통해 인증서를 등록하는지에 대해서도 다룹니다.
Github Actions 환경은 아무것도 세팅되어 있지 않은 VM 환경입니다.
해당 환경에서 어떻게 Private Repo로 저장되어 있는 인증서를 가져오고, 또 어떻게 저장할 수 있을까요?
아래 URL은 실제로 저희가 사용하고 있는 Match Private Repo URL입니다.
https://github.com/ESTiOSAI/iOS-Certificate.git
로컬에서 가져올 때는 해당 Private Repo에 접근할 수 있는 PAT 토큰을 이미 키체인에 저장해뒀기 때문에, 별다른 문제 없이 Match를 불러올 수 있습니다.

Github Actions에서는 실행할 때마다 새로운 VM 환경이므로, PAT 토큰이 저장되어 있지 않기 때문에 다음과 같이 접근해야 합니다.
https://(PAT 토큰)@github.com/ESTiOSAI/iOS-Certificate.git # 토큰을 통해 Private Repo 접근
위와 같이 치환될 수 있도록 워크플로우 파일에 다음과 같이 치환해주는 명령어를 작성합니다.
- name: Private Repo에 접근하기 위한 치환 작업 구성
run: git config --global url."https://${GIT_TOKEN}@github.com/".insteadOf "https://github.com/"
env:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
PAT 토큰은 Github Secrets에 따로 암호화해서 사용합니다.
Github Actions 환경에서 Fastlane을 설치하고 실행합니다.
또 Github Secrets를 사용하여, Fastfile에서 필요한 환경 변수를 정의합니다.
- name: Fastlane 설치
run: brew install fastlane
- name: Fastlane 실행
working-directory: AIProject
run: fastlane testflight
env: # Fastfile에서 사용하는 환경변수 전달
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} # match 비밀번호
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} # match private repo url
GIT_TOKEN: ${{ secrets.GIT_TOKEN }} # match PAT 토큰
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
KEYCHAIN_NAME: ${{ secrets.KEYCHAIN_NAME }} # 키체인 이름
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} # 키체인 비밀번호
🚨 간혹 실행할 때 Fastlane을 찾지 못한다는 에러가 발생할 수도 있습니다.
직접 working-directory 를 지정하는 방식으로 해결했습니다.

Github Actions의 최종 코드는 다음과 같습니다.
name: TestFlight
on:
push:
branches: [ "main" ]
jobs:
testflight:
runs-on: macos-latest
steps:
# 레파지토리 체크인
- name: Checkout
uses: actions/checkout@v4
# XCConfig / GoogleService 등의 파일을 클론 (이전 포스트에서 설명)
- name: Clone Secret file
uses: actions/checkout@v4
with:
repository: ESTiOSAI/Secrets
path: temp/
token: ${{ secrets.SECRET_TOKEN }}
# 가져온 파일 이동 (이전 포스트에서 설명)
- name: Move config
run: |
mv temp/XCConfig/Secrets.xcconfig AIProject/iCo/App/Resource/
mv temp/GoogleServices/GoogleService-Info.plist AIProject/iCo/App/Resource/
- name: Private Repo에 접근하기 위한 치환 작업 구성
run: git config --global url."https://${GIT_TOKEN}@github.com/".insteadOf "https://github.com/"
env:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
- name: Fastlane 설치
run: brew install fastlane
- name: Fastlane 실행
working-directory: AIProject
run: fastlane testflight
env: # Fastfile에서 사용하는 환경변수 전달
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
KEYCHAIN_NAME: ${{ secrets.KEYCHAIN_NAME }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
Fastlane에서의 명령어는 로컬에서 TestFlight를 올려보자 포스트와 거의 동일하지만 몇 가지 차이점이 있습니다.
해당 차이점은 Github Actions가 완전히 독립적인 VM 환경에서 실행하기 때문에 생기는 차이점으로, 대표적으로 2가지가 있습니다.
코드를 빌드하고 배포할 때는 인증서가 필요합니다. Match를 통해서 인증서를 받아오는데, 또 등록은 어떻게 해야 할까요?
인증서를 등록하는 단계에서는 MacOS 키체인에 인증서를 등록하는 과정이 필요합니다.

키체인에 인증서가 등록되어 있어야 Xcode가 해당 인증서를 찾아서 Code Signing이 가능합니다.
앞서 말했듯이, Github Actions는 깨끗한 VM 환경이기 때문에 키체인을 생성하고 인증서를 등록해줘야 하는 작업이 필요합니다.
1️⃣ 먼저 키체인을 생성합니다.
create_keychain(
name: ENV["KEYCHAIN_NAME"], # 키체인 이름
password: ENV["KEYCHAIN_PASSWORD"], # 키체인 비밀번호
timeout: 1800, # 자동 잠금 시간
default_keychain: true, # macOS의 기본 키체인으로 등록
unlock: true, # 잠금 해제
lock_when_sleeps: false
)
2️⃣ Match를 통해서 인증서를 받아오고 키체인에 저장합니다.
MatchFile을 참조하여 Match로부터 AppStore 인증서를 가져오고, 지정한 키체인에 등록합니다.
match(
type: "appstore",
keychain_name: ENV["KEYCHAIN_NAME"],
keychain_password: ENV["KEYCHAIN_PASSWORD"],
readonly: true
)
해당 포스트에 App Store Connect Key를 발급받는 파트가 있습니다.
api_key =
app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_KEY_ID"],
is_key_content_base64: true, # base64로 콘텐츠를 인코딩했다는 뜻
issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
key_content: ENV["APP_STORE_CONNECT_KEY_CONTENT"], # key_filepath 대신에 콘텐츠 전달
in_house: false
)
로컬과 다른점이 있다면 key_filepath 대신에 key_content를 사용합니다.
AuthKey.p8 파일도 보안 문제로 인해서 원격으로 올리면 안되기 때문에, Github Actions 환경에서 사용할 때는 파일 안에 있는 콘텐츠를 사용해야 합니다. 콘텐츠는 보통 아래와 같이 구성되어 있습니다.
-----BEGIN PRIVATE KEY-----
sdklgjdfklghjfdnbkfdmsgnlkdfjgkldfsgosdghertojshgj
sdfgjsdfklgjlsdfkjgldfskjglfksdgmldfsgnlsdfkgjslwe ...
-----END PRIVATE KEY-----
문제는 해당 콘텐츠의 구성이 멀티라인으로 구성되어있다는 점입니다. 이 콘텐츠를 그대로 옮기면 줄바꿈이 깨지는 등의 문제로 제대로 읽지 못하는 경우가 생길 수 있습니다.
이 문제를 해결하기 위해 base64 형식으로 인코딩하여 저장하는 방식을 많이 사용합니다.
cat AuthKey_523454235.p8 | base64
위의 명령어를 통해서 안전하게 base64 형식으로 인코딩하여 출력하고 복붙해서 사용합니다.
위의 작업 외에는 사실상 로컬에서 테스트플라이트 배포하기 포스트 코드와 대부분 동일합니다.
default_platform(:ios)
platform :ios do
desc "빌드하고 TestFlight에 업로드"
lane :testflight do
api_key =
app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_KEY_ID"],
is_key_content_base64: true,
issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
key_content: ENV["APP_STORE_CONNECT_KEY_CONTENT"],
in_house: false
)
create_keychain(
name: ENV["KEYCHAIN_NAME"],
password: ENV["KEYCHAIN_PASSWORD"],
timeout: 1800,
default_keychain: true,
unlock: true,
lock_when_sleeps: false
)
increment_build_number
match(
type: "appstore",
keychain_name: ENV["KEYCHAIN_NAME"],
keychain_password: ENV["KEYCHAIN_PASSWORD"],
readonly: true
)
build_app(
scheme: "iCo",
clean: true,
export_method: "app-store",
export_options: {
signingStyle: "manual", # Xcode의 Code Signing 방식을 수동으로 설정.
provisioningProfiles: { # match가 설치한 배포용 프로비저닝 프로파일을 지정.
"com.est.ico" => "match AppStore com.est.ico",
"com.est.ico.widget" => "match AppStore com.est.ico.widget"
}
}
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: false,
submit_beta_review: false,
distribute_external: false,
changelog: "새로운 기능 및 버그 수정"
)
end
end
CI/CD 환경은 로컬과 달리 완전히 깨끗한 환경의 VM에서 실행되기 때문에, 인증서·프로비저닝 프로파일·API Key와 같은 중요한 정보가 외부로 노출되지 않도록 암호화 하는 것이 매우 중요한 거 같습니다.
또한, 배포 트리거를 어떤 기준으로 설정할지 팀원들과 이야기하는 과정도 중요할 거 같습니다. 단순히 push될 때마다 배포한다가 아니라, 특정 브랜치 규칙, 태그 기반 배포, PR 병합 시 등 다양한 경우가 존재할 거 같습니다.
https://hackjsp.tistory.com/72