우당탕탕 Github Action CI/CD 도입기

Jenny·2024년 4월 27일

목차

  • Github Action 구성 요소
  • fastlane 이란 무엇인가?
  • fastlane을 사용하게 된 이유?
  • Bibbi Github Action WorkFlow 설명
  • Fastlane
  • 결론
  • 느낀점

Github Action 구성 요소

Github Action은 크게 세 가지 구성요소로 이루어 집니다. 대표적으로는 Workflow, Job, Step 이 있는데 Step은 Job에 포함되고 Job은 Workflow에 포함 되어 있습니다. 즉 Workflow > Job > Step 순이 겠죠?

Workflow

Workflow는 Github Repository에 들어가는 작업단위 입니다. 보통 Repository의 ./github/workflows/ 경로에 존재하며 swift로 생성을 할 경우 swift.yml로 생성 됩니다.

Workflow는 어떤 이벤트(event)에 따라 Workflow 실행시킬지 on: 키워드를 사용해서 설정 할 수 있습니다. 저희는 feat,fix 로 생성을 하기 때문에 하단 처럼 구현 하였으며, 초기 git flow 형식과 다르게 release 브런치를 추가 한 이유는 Merge 이벤트 발생시 DEV, RELASE 배포를 명확하게 구분 지으려고 추가하게 되었습니다.

Jobs

Job은 병렬적으로 수행되거나 순차적으로 수행되어야 하는 작업 단위입니다. jobs를 실행하기 위해서는 작업 환경을 정의해야하는데 이를 Runner라고 지칭한다고 합니다. 작업 환경을 정의 하는 키워드는 runs-on:을 통해 설정이 가능 합니다. 다만 저희가 실행하는 환경과 맞게 정의를 해야합니다. (runner-images 참고)

Steps

마지막으로 Steps 입니다. Steps는 Job이 포함되며 Shell Script나 Action이 실행 됩니다. Shell Script는 terminal에서 수행되는 명령어의 집합(ex: tuist generate, tuist clean)등, Action은 Github Action에서 미리 정의한 Script 입니다.

fastlane 이란 무엇인가?

공식 문서에서 fastlane을 소개하는 문구입니다. fastlane은 개발 외에 code signing, 릴리즈/배타 배포 등의 작업을 자동화 해주는 프레임 워크 입니다.
추가로 슬랙 혹은 디스코드에 노티를 설정하거나 배포시 스크린샷을 설정하는등 다양한 기능도 제공 합니다.

fastlane을 사용하게 된 이유


최근 Xcode Cloud를 사용하여 통합 및 배포 서비스를 구축 할 수 있지만 Tuist CLI를 고려하면 많은 고민이 들었다(code signing, Workspace Bibbi.xcworkspace does not exist at 14-team5-iOS/Bibbi.xcworkspace 등)

반면에 fastlane은 code signing 뿐만 아니라 특정 xcworkspace 지정하여 빌드 할 수 있었기에 fastlane을 사용하게 되었습니다.

Bibbi Github Action WorkFlow 설명

on:  
  push:  
    branches:  
      - feat/*  
      - fix/*  
  pull_request:  
    branches:  
      - release/**  
      - develop  
    
jobs:    
  build:  
    runs-on: macos-13  
    strategy:  
      matrix:  
        xcodebuild-scheme: ['App']  
          
    steps:  
      - uses: actions/checkout@v3  
      - uses: jdx/mise-action@v2  
      - uses: ruby/setup-ruby@v1  
        with:   
          ruby-version: '3.2.0'  
​  
​  
      - name: Setup Xcode version  
        uses: maxim-lobanov/setup-xcode@v1  
        with:  
            xcode-version: '15.0'  
              
      - name: Checkout branch  
        uses: actions/checkout@v3  
        with:  
          token: ${{ secrets.GITHUB_TOKEN }}- name: Bring Bibbi ignored file with Config  
        uses: actions/checkout@v3  
        with:   
          repository: depromeet/14th-team5-iOS-ignored  
          path: depromeet/14th-team5-iOS/14th-team5-iOS/XCConfig  
          token: ${{secrets.ACTION_TOKEN}}  
          
      - name: Install Tuist CLI  
        run: bash <(curl -Ls https://install.tuist.io)  
          
      - name: Install FastLane   
        uses: ruby/setup-ruby@v1  
        with:  
          ruby-version: '3.2.0'  
      - run: brew install fastlane  
​  
      - name: Tuist Clean Command  
        run: tuist clean  
        
      - name: Tuist Fetch Command  
        run: tuist fetch  
​  
      - name: Tuist Generate Commnad  
        run: tuist generate  
​  
      - name: fastlane upload_stg_testflight  
        if: github.event.pull_request.base.ref == 'release' && github.head_ref == 'develop'  
        env:  
          APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}  
          APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}  
          APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}  
          PROJECT_PATH: ${{ secrets.PROJECT_PATH }}  
          MATCH_PASSWORD: ${{secrets.MATCH_PASSWORD}}  
          MATCH_PERSONAL_TOKEN: ${{ secrets.MATCH_PERSONAL_TOKEN}}  
          DEV_SCHEME: ${{secrets.DEV_SCHEME}}  
          BUNDLE_ID: ${{secrets.BUNDLE_ID}}  
          SLACK_HOOK_URL: ${{secrets.SLACK_HOOK_URL}}  
          WIDGET_BUNDLE_ID: ${{secrets.WIDGET_BUNDLE_ID}}  
          PROFILE_PATH: ${{secrets.PROFILE_PATH}}  
          APP_NAME: ${{secrets.APP_NAME}}  
          APPLE_ID: ${{secrets.APPLE_ID}}  
          TEAM_ID: ${{secrets.TEAM_ID}}  
          WIDGET_NAME: ${{secrets.WIDGET_NAME}}  
        run: fastlane github_action_prd_upload_testflight  
​  
​  
      - name: fastlane upload_prd_testflight  
        if: github.event.pull_request.base.ref == 'develop' && github.head_ref == 'feature'  
        env:  
          APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}  
          APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}  
          APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}  
          PROJECT_PATH: ${{ secrets.PROJECT_PATH }}  
          MATCH_PASSWORD: ${{secrets.MATCH_PASSWORD}}  
          SLACK_HOOK_URL: ${{secrets.SLACK_HOOK_URL}}  
          MATCH_PERSONAL_TOKEN: ${{ secrets.MATCH_PERSONAL_TOKEN}}  
          PRD_SCHEME: ${{secrets.PRD_SCHEME}}  
          BUNDLE_ID: ${{secrets.BUNDLE_ID}}  
          WIDGET_BUNDLE_ID: ${{secrets.WIDGET_BUNDLE_ID}}  
          PROFILE_PATH: ${{secrets.PROFILE_PATH}}  
          APP_NAME: ${{secrets.APP_NAME}}  
          APPLE_ID: ${{secrets.APPLE_ID}}  
          TEAM_ID: ${{secrets.TEAM_ID}}  
          WIDGET_NAME: ${{secrets.WIDGET_NAME}}  
        run: fastlane github_action_stg_upload_testflight  
​  
      - name: Upload coverage to Codecov  
        uses: codecov/codecov-action@v1.2.1  
        env:  
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}  

name

  • 워크플로우의 이름(Bibbi)로 정의

on

  • 워크플로우가 어떤 이벤트에 의해 Trigger될지 정의
  • feat/**, fix 브런치에서 Push 될 때 및 release/**, develop 으로 PR이 될때 워크플로우 실행

jobs

  • 워크플로우에서 실행할 작업을 정의
  • build라는 하나의 작업을 정의

runs-on

  • 작업이 실행될 환경을 정의
  • macos-13 을 사용하여 macOS 13 환경에서 실행하도록 정의

if

  • 작업이 실행되어야 하는 조건 정의(조건문)
  • 요기서는 develop 브런치에서 feat 브런치로 PR 할 경우 작업이 실행 되도록 조건 설정 or develop 에서 release 브런치로 PR 할 경우도 작업 실행 되도록 설정

steps

  • 작업을 구성하는 단계 정의
  • 각 단계는 작업을 실행하는 데 필요한 명령어나 액션을 포함

각 Step 설명
1) Setup Xcode version
xcode-version Key를 통해 로컬 환경과 동일한 Xcode 15.0 버전 설정

1) Checkout branch
actions/checkout@v3 액션을 사용하여 Repository의 코드를 체크아웃(흔히 github에서 하는 체크아웃이라 생각하면 됨)

2) Bring Bibbi ignored file with Config
민감한 정보 데이터(XCConfig, Provisioning profile, Certificate)를 .gitignore 에 명시하여 레포지토리에 등록하지 않았기 때문에 외부 Private Repository에 관리하여 해당 민감한 파일들을 github Action 실행시 생성하도록 설정

3) Install Tuist CLI
Tuist 명령어를 실행하기 위해 Tuist 설치

4) Install FastLane
배포 자동화를 위한 Fastlane을 설치 or with 키를 사용하여 ruby version 설정

5) Tuist Clean Command
Tuist 생성된 모든 데이터 제거하기 위한 명령어 실행

6) Tuist Fetch Command
Tuist 외부 Third Party Library 의존성 가져오기 위해 명령어 실행

7) Tuist Generate Commnad
Tuist xcworkSpace File을 Repository에 생성하기 위해 실행(현재 Repository에는 .xcworkspace File이 없습니다.)
8,9) fastlane uploadstg_testflight orfastlane upload_prd_testflight
fastlane을 사용하여 TestFlight에 앱을 업로드 하기 위한 명령어 실행
- APP_STORE_CONNECT_API_KEY_KEY :
앱스토어 커넥트 API의 KEY CONTENT
- APP_STORE_CONNECT_API_KEY_ISSUER_ID :
앱스토어 커넥트 API의 ISSUER ID
- APP_STORE_CONNECT_API_KEY_KEY_ID :
앱스토어 커넥트 API의 KEY ID_
- PROJECT_PATH : Bibbi Local 환경 xcodeProject File Path
- MATCH_PASSWORD : Match File Passowrd
- MATCH_PERSONAL_TOKEN : Match File Private Repository Personal Access Token
- DEV_SCHEME or PRD_SCHEME : DEV SCHEME or PRD SCHEME 이름
- SLACK_HOOK_URL : Slack Hook URL 주소
- APP_NAME : Bibbi xcworkspace 이름
- TEAM_ID : Apple Team id

위와 같은 환경변수의 경우, 민감한 정보가 많기 때문에, Github에서는 프로젝트의 Settings에 접근하여 Security > ActionsRepository secrets에 등록하여 해당 값을을 관리하도록 합니다.

fastlane

로컬 환경에서는 fastlane 디렉토리 내에 .env 파일을 생성하여, 환경변수를 정의 합니다. 당연히 .env 파일은 원격 및 Git Repository에 올라가면 안되는 민감한 정보이므로, .gitignore 에 명시를 해야합니다.

FastFile

# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
#     https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
#     https://docs.fastlane.tools/plugins/available-plugins
#

# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane

#✅ 상수
APP_NAME = ENV["APP_NAME"]
WIDGET_NAME = ENV["WIDGET_NAME"]

PRD_SCHEME = ENV["PRD_SCHEME"]
DEV_SCHEME = ENV["DEV_SCHEME"]
BUNDLE_ID = ENV["BUNDLE_ID"]
WIDGET_BUNDLE_ID = ENV["WIDGET_BUNDLE_ID"]

APP_STORE_CONNECT_API_KEY_KEY_ID = ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"]
APP_STORE_CONNECT_API_KEY_ISSUER_ID = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"]
APP_STORE_CONNECT_API_KEY_KEY = ENV["APP_STORE_CONNECT_API_KEY_KEY"]

KEYCHAIN_NAME = ENV["KEYCHAIN_NAME"]
KEYCHAIN_PASSWORD = ENV["KEYCHAIN_PASSWORD"]
P12_PASSWORD =ENV["P12_PASSWORD"]

PROJECT_PATH = ENV["PROJECT_PATH"]
TEAM_ID = ENV["TEAM_ID"]
APPLE_ID = ENV["APPLE_ID"]
PROFILE_PATH = ENV["PROFILE_PATH"]
SLACK_HOOK_URL = ENV["SLACK_HOOK_URL"]


default_platform(:ios)

platform :ios do


  lane :github_action_stg_upload_testflight do |options|
    app_store_connect_api_key(
      key_id: "#{APP_STORE_CONNECT_API_KEY_KEY_ID}",
      issuer_id: "#{APP_STORE_CONNECT_API_KEY_ISSUER_ID}",
      key_content: "#{APP_STORE_CONNECT_API_KEY_KEY}",
      is_key_content_base64: true,
      in_house: false
    )
    
    setup_ci

    match(
      storage_mode: "git",
      type: "appstore",
      app_identifier: ["com.5ing.bibbi","com.5ing.bibbi.widget"],
      readonly: is_ci,
      git_basic_authorization: Base64.strict_encode64("Do-hyun-Kim:#{ENV["MATCH_PERSONAL_TOKEN"]}"),
      generate_apple_certs: false
    )

    new_build_number = latest_testflight_build_number() + 1


    increment_build_number(
      xcodeproj: "14th-team5-iOS/App/App.xcodeproj",
      build_number: new_build_number
    )

    update_project_team(
      path: "14th-team5-iOS/App/App.xcodeproj",
      teamid: "#{TEAM_ID}"
    )



    build_app(
      workspace: "Bibbi.xcworkspace",
      scheme: "#{DEV_SCHEME}",
      export_method: "app-store",
      xcodebuild_formatter: "xcpretty",
      archive_path: "./build/Runner.xcarchive",
      output_directory: "./build/Runner",
      export_options: {
        method: "app-store",
        signingStyle: "manual",
        provisioningProfiles: {
          "com.5ing.bibbi" => "match AppStore com.5ing.bibbi",
          "com.5ing.bibbi.widget" => "match AppStore com.5ing.bibbi.widget"
        }
      }
    )

    upload_to_testflight(skip_waiting_for_build_processing: true)


    slack(
      message: "⭐️ 테스트 플라이트 업로드 성공",
      channel: "#알림-ios-배포",
      slack_url: "#{SLACK_HOOK_URL}",
      username: "대신 배포해주는 고양이",
      icon_url: "https://avatars.githubusercontent.com/u/21079970?v=4",
      payload: {
        "Build Date" => Time.new.to_s,
      },
      attachment_properties: {
        fields: [
          {
            title: "Scheme",
            value: "#{DEV_SCHEME}",
          },
          {
            title: "Build Number",
            value: "#{new_build_number.to_s}",
          }
        ]
      },
      success: true
    )

  end


  lane :github_action_prd_upload_testflight do |options|
    app_store_connect_api_key(
      key_id: "#{APP_STORE_CONNECT_API_KEY_KEY_ID}",
      issuer_id: "#{APP_STORE_CONNECT_API_KEY_ISSUER_ID}",
      key_content: "#{APP_STORE_CONNECT_API_KEY_KEY}",
      is_key_content_base64: true,
      in_house: false
    )
    
    setup_ci

    match(
      storage_mode: "git",
      type: "appstore",
      app_identifier: ["com.5ing.bibbi","com.5ing.bibbi.widget"],
      readonly: is_ci,
      git_basic_authorization: Base64.strict_encode64("Do-hyun-Kim:#{ENV["MATCH_PERSONAL_TOKEN"]}"),
      generate_apple_certs: false
    )

    new_build_number = latest_testflight_build_number() + 1


    increment_build_number(
      xcodeproj: "14th-team5-iOS/App/App.xcodeproj",
      build_number: new_build_number
    )

    update_project_team(
      path: "14th-team5-iOS/App/App.xcodeproj",
      teamid: "#{TEAM_ID}"
    )



    build_app(
      workspace: "Bibbi.xcworkspace",
      scheme: "#{PRD_SCHEME}",
      export_method: "app-store",
      xcodebuild_formatter: "xcpretty",
      archive_path: "./build/Runner.xcarchive",
      output_directory: "./build/Runner",
      export_options: {
        method: "app-store",
        signingStyle: "manual",
        provisioningProfiles: {
          "com.5ing.bibbi" => "match AppStore com.5ing.bibbi",
          "com.5ing.bibbi.widget" => "match AppStore com.5ing.bibbi.widget"
        }
      }
    )

    upload_to_testflight(skip_waiting_for_build_processing: true)


    slack(
      message: "⭐️ 테스트 플라이트 업로드 성공",
      channel: "#알림-ios-배포",
      slack_url: "#{SLACK_HOOK_URL}",
      username: "대신 배포해주는 고양이",
      icon_url: "https://avatars.githubusercontent.com/u/21079970?v=4",
      payload: {
        "Build Date" => Time.new.to_s,
      },
      attachment_properties: {
        fields: [
          {
            title: "Scheme",
            value: "#{PRD_SCHEME}",
          },
          {
            title: "Build Number",
            value: "#{new_build_number.to_s}",
          }
        ]
      },
      success: true
    )

  end





  #✅ 테스트 플라이트에 PRD 업로드
  lane :upload_prd_to_testflight do |options|
    app_store_connect_api_key(
      key_id: "#{APP_STORE_CONNECT_API_KEY_KEY_ID}",
      issuer_id: "#{APP_STORE_CONNECT_API_KEY_ISSUER_ID}",
      key_content: "#{APP_STORE_CONNECT_API_KEY_KEY}",
      is_key_content_base64: true,
      in_house: false
    )

    new_build_number = latest_testflight_build_number() + 1

    increment_build_number(
      xcodeproj: "#{PROJECT_PATH}",
      build_number: new_build_number
    )

    build_app(
      workspace: "#{APP_NAME}.xcworkspace",
      scheme: "#{PRD_SCHEME}",
      export_method: "app-store",
      export_options: {
        method: "app-store", 
	provisioningProfiles: {
      "com.5ing.bibbi" => "match AppStore com.5ing.bibbi",
      "com.5ing.bibbi.widget" => "match AppStore com.5ing.bibbi.widget",
        }
      }
    )

    upload_to_testflight(skip_waiting_for_build_processing: true)

    slack(
      message: "⭐️ 테스트 플라이트 업로드 성공",
      channel: "#알림-ios-배포",
      slack_url: "#{SLACK_HOOK_URL}",
      username: "대신 배포해주는 고양이",
      icon_url: "https://avatars.githubusercontent.com/u/21079970?v=4",
      payload: {
        "Build Date" => Time.new.to_s,
      },
      attachment_properties: {
        fields: [
          {
            title: "Scheme",
            value: "#{PRD_SCHEME}",
          },
          {
            title: "Build Number",
            value: "#{new_build_number.to_s}",
          }
        ]
      },
      success: true
    )    
  end

  #✅ 테스트 플라이트에 STG 업로드
  lane :upload_stg_to_testflight do |options|
    app_store_connect_api_key(
      key_id: "#{APP_STORE_CONNECT_API_KEY_KEY_ID}",
      issuer_id: "#{APP_STORE_CONNECT_API_KEY_ISSUER_ID}",
      key_content: "#{APP_STORE_CONNECT_API_KEY_KEY}",
      is_key_content_base64: true,
      in_house: false
    )

    new_build_number = latest_testflight_build_number() + 1

    increment_build_number(
      xcodeproj: "#{PROJECT_PATH}",
      build_number: new_build_number
    )

    build_app(
      workspace: "#{APP_NAME}.xcworkspace",
      scheme: "#{DEV_SCHEME}",
      export_method: "app-store",
      export_options: {
        method: "app-store", 
	provisioningProfiles: {
      "com.5ing.bibbi" => "match AppStore com.5ing.bibbi",
      "com.5ing.bibbi.widget" => "match AppStore com.5ing.bibbi.widget",
        }
      }
    )

    upload_to_testflight(skip_waiting_for_build_processing: true)

    slack(
      message: "⭐️ 테스트 플라이트 업로드 성공",
      channel: "#알림-ios-배포",
      slack_url: "#{SLACK_HOOK_URL}",
      username: "Bibbibot",
      icon_url: "https://avatars.githubusercontent.com/u/160627812?s=200&v=4",
      payload: {
        "Build Date" => Time.new.to_s,
      },
      attachment_properties: {
        fields: [
          {
            title: "Scheme",
            value: "#{DEV_SCHEME}",
          },
          {
            title: "Build Number",
            value: "#{new_build_number.to_s}",
          }
        ]
      },
      success: true
    )        
  end




  #🔴 예외 처리
  error do |lane, exception, options|
    slack(
      message: "❗️ 테스트 플라이트 업로드 실패",
      channel: "#알림-ios-배포",
      slack_url: "#{SLACK_HOOK_URL}",
      username: "대신 배포해주는 고양이",
      icon_url: "https://avatars.githubusercontent.com/u/21079970?v=4",
      success: false,
      attachment_properties: {
        fields: [
          {
            title: "Error message",
            value: "#{exception.to_s}",
            short: false
          }
        ]
      },
    )        
  end
end

느낀점

이번 기회를 통해 GitHub Actions를 사용하면서 깨달은 것은 로컬 환경이 생각 이상으로 독립적이지 않았다는 사실이었습니다. Xcode 버전과 Swift 버전을 동일하게 설정해주었음에도, 로컬 환경에서는 Fastlane이 정상 작동하였으나, 가상 머신 환경에서는 작동을 하지 않는 현상을 심심찮게 마주하였습니다.

특히, 가장 큰 문제상황으로 GitHub Actions가 빌드가 되지 않고 freeze 되는 현상을 발견하였습니다. 보통 에러가 발생할 경우, 에러 로그를 출력하고 워크플로우가 종료되는데, 이번 경우에는 에러 출력 없이 워크플로우가 끝이 나지 않았습니다. 이로 인하여, 어떠한 문제로 에러가 발생했는지 디버그 과정에서 크게 애를 먹었고, 심지어 GitHub Actions 무료분을 모두 소진하고 말았습니다.

로컬 환경과 최대한 환경을 동일시하고, 로그를 하나하나 분석해본 결과, GitHub Actions의 경우 비대화형이기 때문에 터미널 환경에서 출력되는 오류같은 경우는 잘 출력하지만, 키체인 입력과 같이 애플 환경 자체에서 요구하는 스크립트의 경우에는 아무 출력 없이 freeze 됨을 확인하였습니다. 이를 해결하기 위해 build 전, fastlane 함수인 setup_ci을 통해 임시 키체인을 생성하여 위와 같은 문제를 해결하였습니다.

결론

GitHub Actions의 강력한 자동화 기능과 Fastlane의 효율적인 iOS 앱 관리 기능을 결합함으로써, 빌드부터 테스트, 배포에 이르기까지 전 과정을 하나의 워크플로우 내에서 관리할 수 있었습니다.

profile
"Jenny 있게 iOS 개발을 하며 대체 불가능한 인재가 되자"

0개의 댓글