⚙️

Tuist+Fastlane ) CI/CD 구축하기

joho2022 2025. 5. 29. 01:47

 

 

지난 글에서 match를 이용한 코드 서명 자동화 설정을 했었다.

당시에는 app_identifier, team_id, apple_id 등등 

fastlane 설정 파일에 직접 하드코딩하는 방식으로 구현했었다.

 

이번 글에서는 보안성을 생각해서 .env.default를 도입했다.

 

그래서 fastlane으로 TestFlight 및 App Store 자동 배포 설정하고,

Discord 알림까지 연동과

 

Github Action 활용한 자동배포까지 정리하고자 한다.

 


 

루트 디렉토리에서 fastlane init 

Tuist로 구성된 프로젝트 루트에서 Fastlane 초기 설정 시작한다.

fastlane init

 

 

fastlane/ 디렉토리가 생성되고 기본 Fastfile, Appfile이 구성된다.

 

 


.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하면 자동제출이 되면서

빌드 업로드도 자동으로 올라간다. 

 

WOW

 



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

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

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.

[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 인증 정보를 자동으로 설정해주는 옵션이다.

 

근데 기본적으로 읽기 권한만 있기 때문에,

403 에러 경험

 

해당 에러를 경험했는데,

 

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를 구성해보았는데,

확실히 앱스토어 배포할 때 시간을 효율적으로 쓸 수 있어서 정말 도움이 되는 것 같다. 

 

 

후... 하얗게 불태웠다.