본문 바로가기
개발/DevOps

Flutter 에서 CICD 적용기 (with github actions)

by WooHey 2025. 9. 29.

cicd 파이프라인 설계

우선 ci/cd 파이프라인 간략하게 설계했다.

 

체크 포인트로는 우리의 branch 전략에 따라 jira workflow 에 따라

어느 환경(dev, stg, prod)이 적용된 아티팩트를

어느 단계(qc-dev, qc-stg → backend 와 함께 배포 진행되어야 했음)에서 전달되어야 하는지 정리하고

어느 브랜치에서 트리거되어야 하는지 정리했다.

 

 

단계 Trigger Source 브랜치 Target 브랜치 Flavor 배포 대상 동작 비고
1 작업 완료 feature/... develop/x.y.z - - unit-test 작업자 수동 PR
2 단위 테스트 통과 후 PR merge develop/x.y.z   dev 개발 환경 (예: Firebase) dev-build 자동
3 QA 통과 후 feature/... release/x.y.z     code review 수동
4 release PR merge 시 release/x.y.z   stg 스테이징 환경 (예: Firebase) staging-build 자동
5 QA 통과 후 release/x.y.z main     code review & merge 수동
6 main PR merge 시 main   prod 운영 환경 (예: PlayStore, TestFlight) prodiction-deploy 자동

 

 

 

 

jenkins 적용

가장 중요했던건 일단 무료…. 우리팀이 github enterprise 계정이 무료플랜 계정이었다.

그래서 일단은 jenkins 를 택했다.

 

 

예전에 만든 fastlane 과 함께 적용하려 했는데, fastlane 빌드는 실패했는데 jenkins 에서는 성공했다고 뜨는 이슈가 있었고, 이건 내가 볼 때 fastlane 명령어 실행만 해주고 실제 실행 실패 체크는 안해주는 듯 했다. (내가 못하는 거였을지도)

또 jenkins + fastlane 이렇게 두개가 뭔가 이원화되는 것 같아서 일단 jenkins 에서 sh 명령어를 통해 실행하도록 했다.

⇒ 이건 github actions 를 택한 현재도 동일하다. 이슈라고 하긴 어려울 듯

 

 

근데 생각보다 까다로운게 많았다.

fastlane 으로 반자동화 할 때에는 로컬 파일에 dotenv 파일을 을 통해 앱키든 뭐든 적어서 적용했기 때문에 상대적으로 편한게 있었다.

하지만 jenkins 에서는 여러가지 필수 플러그인 설치할 것들도 많고 비교적 까다로웠다.

 

 

또 우리 제품 특성상 어떤 리포지토리는 single-app 이고 또 어떤 앱은 multi-app 구조이기 때문에 각각 관리하기가 여간 까다로운게 아니었다.

 

조금 까다롭긴 해도 일단 android 는 jenkins 를 이용해서 firebase distribution 까지는 성공했는데 ios 를 작업하려하니 무슨 key파일 생성하고 뭐시기 필요하대서 일단 좀 보류.. (개인적으로 좀 귀찮은거도 있었음.. 그리고 android 도 빡센데 ios 는 얼마나 빡셀지)

 

 

 

github actions with self hosting

어느날 서칭하다가 jenkins 를 local 에 설치해서 돌리는 것처럼 github actions 도 내 로컬에서 돌리기가 가능하단걸 알게 되었다.

얼마 전에 회사에서 맥북 최신 기기(m4)를 사줘서 마침 맥도 있고, 또 repository 도 github 을 사용 중이며 둘의 연동성은 아주 좋기도 하며 작년에 한 번 연동한 경험이 있기도 하고 겸사겸사..

 

...

 

그래서 Github Actions 로 바로 갈아탔다.

 

 

+ 과거에 했던 workflow

더보기
더보기
더보기
name: Flutter Release

on:
  push:
    branches:
      - master
      - release
  pull_request:
    branches:
      - master
      - release

jobs:
  release:
    runs-on: macos-latest

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Set Up Java
        uses: actions/setup-java@v3
        with:
          distribution: 'oracle'
          java-version: '17'

      - name: Set Up Dart
        uses: dart-lang/setup-dart@v1

      - name: Set Up Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.0'
          channel: 'stable'

      - name: Install Dependencies
        run: flutter pub get

      - name: Create .env file for production
        run: |
          touch .env
          echo "SERVER_URL_DEV=${{ secrets.SERVER_URL_DEV }}" >> .env
          echo "SERVER_URL_PROD=${{ secrets.SERVER_URL_PROD }}" >> .env
          echo "NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" >> .env
          echo "NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" >> .env
          echo "TMAP_API_KEY=${{ secrets.TMAP_API_KEY }}" >> .env
          cat .env
      - name: Analyze project source
        run: dart analyze

      - name: Verify formatting
        run: dart format --output=none --set-exit-if-changed .

      - name: Run Tests
        run: flutter test

      - name: Decode Google Services JSON
        env:
          FIREBASE_SECRET_JSON: ${{ secrets.FIREBASE_SECRET_JSON }}
        run: echo "$FIREBASE_SECRET_JSON" > android/app/google-services.json

      - name: Build APK
        run: flutter build apk --release

      - name: Build AppBundle
        run: flutter build appbundle

      - name: Build IPA
        run: flutter build ipa --no-codesign

      - name: Compress Archives and IPAs
        run: |
          cd build
          tar -czf ios_build.tar.gz ios

      - name: Upload artifact to Firebase App Distribution
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ secrets.FIREBASE_APP_ID }}
          token: ${{ secrets.FIREBASE_TOKEN }}
          groups: internal-testers
          file: build/app/outputs/flutter-apk/app-release.apk

      - name: Extract version from pubspec.yaml
        id: extract_version
        run: |
          version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\\r')
          echo "VERSION=$version" >> $GITHUB_ENV
      - name: Check if Tag Exists
        id: check_tag
        run: |
          if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then
            echo "TAG_EXISTS=true" >> $GITHUB_ENV
          else
            echo "TAG_EXISTS=false" >> $GITHUB_ENV
          fi
      - name: Modify Tag
        if: env.TAG_EXISTS == 'true'
        id: modify_tag
        run: |
          new_version="${{ env.VERSION }}-build-${{ github.run_number }}"
          echo "VERSION=$new_version" >> $GITHUB_ENV
      - name: Create Release
        uses: ncipollo/release-action@v1
        with:
          artifacts: "build/app/outputs/flutter-apk/app-release.apk,build/app/outputs/bundle/release/app-release.aab,build/ios_build.tar.gz"
          artifacts: "build/app/outputs/flutter-apk/app-release.apk,build/app/outputs/bundle/release/app-release.aab"
          tag: v${{ github.run_number}}
          token: ${{ secrets.TOKEN }}

      - name: Remove .env file
        run: rm .env
        if: success()

 

 

 

 

일단은.. 트리거부터

우리 개발 workflow 는 이러했다.

  1. 각 feature 개발
  2. 개발자 단위 테스트
  3. merge feature/a into develop/x.y.z
  4. dev server 기준 QA 요청
  5. merge feature/a into release/x.y.z
  6. stg server 기준 QA 요청
  7. merge release/x.y.z into main
  8. prod server 기준 QA 요청

 

 

위에서 보이다시피 QA 요청 시점에 아티팩트(apk 파일 등) 생성해야 했다.

때문에 trigger 는 main, release/, develop/ 에서 PR 또는 push 가 되는 시점이 된다.

on:
  pull_request:
    branches:
      - 'develop/**'
      - 'release/**'
      - 'main'
  push:
    branches:
      - 'develop/**'
      - 'release/**'
      - 'main'

 

 

 

 

작업 목록(jobs)

작업은 크게 다음과 같이 나눴다

  • setup
  • dev deploy
  • stg deploy
  • prod deploy

 

 

 

Setup

여기선 패키지 설치, 테스트, 린트 검사, 코드 분석 등을 수행하도록 구성했다.

# 코드 저장소 확인
- name: Checkout
  uses: actions/checkout@v4

# Flutter 설치
- name: Setup Flutter
  uses: subosito/flutter-action@v2
  with:
    channel: stable
    flutter-version: '3.29.1'

# 종속성 설치
- name: Install dependencies
  run: flutter pub get

# 코드 포맷팅
- name: dart format
  run: dart format --set-exit-if-changed .

# 정적 분석
- name: flutter analyze
  run: flutter analyze

# 단위/위젯 테스트
- name: flutter test
  run: flutter test --coverage

이 job(setup)은 모든 job 이 실행될 때 공통으로 실행되도록 했다.

 

 

 

 

(dev, stg, prod)_deploy

이 jobs 를 묶은 이유는 전달해주는 파라미터만 다르고 내부에서 돌아가는 step 들은 거의 동일하기 때문이다.

우선 dev_deploy 부터 살펴보자

dev_deploy:
  name: Dev Distribute
  needs: setup
  runs-on: [self-hosted, macOS]
  if: startsWith(github.ref, 'refs/heads/develop/')
  env:
    FLAVOR: dev
    PREFIX: '[DEV SERVER]'
  steps:
    - name: Android Distribute
      uses: ./.github/actions/android-distribute
      with:
        flavor: ${{ env.FLAVOR }}
        build_number: ${{ github.run_number }}
        prefix: ${{ env.PREFIX }}
        firebase_app_id_android: ${{ secrets.FIREBASE_APP_ID_ANDROID }}
        firebase_tester_groups: ${{ secrets.FIREBASE_TESTER_GROUPS }}

    - name: iOS Distribute
      uses: ./.github/actions/ios-distribute
      with:
        flavor: ${{ env.FLAVOR }}
        build_number: ${{ github.run_number }}
        prefix: ${{ env.PREFIX }}
        asc_key_id: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
        asc_issuer_id: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
        asc_key_b64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
        match_password: ${{ secrets.MATCH_PASSWORD }}

 

 

여기서 궁금해 할 만한 것들을 살펴보자면,

  • prefix: 별건 아니고 firebase distribution 의 release_note 에 달리는 접두사이다.
  • firebase_app_id_android: firebase 프로젝트 설정의 app_id 이다
  • asc_key_id, asc_issure_id, asc_key_b64: apple store connect 사이트에서 발급한 값들로, 서명을 위한 값들이다.

나머지 stg, prod 는 flavor 값만 다르고 거의 동일하다.

그러면 android, ios 차례로 상세 단계를 살펴보자

 

 

 

 

Android action

 

이거도 일단 코드부터.

name: Android Distribute

description: Build APK and distribute to Firebase App Distribution

inputs:
  flavor:
    description: Flutter flavor (dev/stg/prod)
    required: true

  build_number:
    description: Build number to use (e.g., github.run_number)
    required: true
  
  prefix:
    description: Release notes prefix (e.g., [DEV SERVER])
    required: false
    default: ""
  
  firebase_app_id_android:
    description: Firebase Android App ID (pass from workflow via secrets)
    required: true
  
  firebase_tester_groups:
    description: Firebase tester groups (comma-separated)
    required: true

 

 

아까 deploy job 에서 넣어줬던 파라미터들이다.

 

 

 

다음은 step 들이다.

- name: Build Android APK
  shell: bash
  run: |
    set -euxo pipefail
    flutter pub get
    flutter build apk \\
      --flavor "${{ inputs.flavor }}" \\
      --release \\
      --no-tree-shake-icons \\
      --build-number "${{ inputs.build_number }}"

- name: Install Node (for Firebase CLI)
  uses: actions/setup-node@v4
  with:
    node-version: "18"

- name: Install firebase-tools
  shell: bash
  run: npm i -g firebase-tools

- name: Distribute to Firebase App Distribution
  shell: bash
  env:
    FIREBASE_APP_ID_ANDROID: ${{ inputs.firebase_app_id_android }}
    FIREBASE_TESTER_GROUPS: ${{ inputs.firebase_tester_groups }}
  run: |
    set -euxo pipefail
    APK="build/app/outputs/flutter-apk/app-${{ inputs.flavor }}-release.apk"
    test -f "$APK"
    firebase appdistribution:distribute "$APK" \\
      --app "$FIREBASE_APP_ID_ANDROID" \\
      --groups "$FIREBASE_TESTER_GROUPS" \\
      --release-notes "${{ inputs.prefix }} Build #${{ inputs.build_number }} (${{ inputs.flavor }})"

 

 

차례로 살펴보면,

apk 빌드 할 때 --flavor 옵션을 입력해줬고, --build-number 도 함께 입력해서 release 빌드파일을 생성했다.

 

다음으로 note 설치는 forebase distribution 을 사용하기 위한 firebase tools 설치를 위해 넣은 단계이고..

 

빌드 성공 후에는 바로 firebase distribution 에 업로드를 해서 QA 에 전달될 수 있도록 했다. 

 

 

 

IOS Action

inputs:
  flavor:
    description: Flutter flavor (dev/stg/prod)
    required: true
  build_number:
    description: Build number to use (e.g., github.run_number)
    required: true
  prefix:
    description: Release notes prefix (e.g., [DEV])
    required: false
    default: ""

  # Fastlane 인증 관련(ASC API Key + match)
  asc_key_id:
    description: App Store Connect API Key ID
    required: true
  asc_issuer_id:
    description: App Store Connect API Issuer ID
    required: true
  asc_key_b64:
    description: Base64-encoded contents of the .p8 key
    required: true
  match_password:
    description: fastlane match decryption password
    required: true

 

여기도 마찬가지로 flavor_deploy 에서 입력한 값들..

 

 

 

다음으로 step 들이다

steps:
  - name: Use local Homebrew Ruby (ARM)
    shell: bash
    run: |
      set -euxo pipefail
      echo '/opt/homebrew/opt/ruby@3.2/bin' >> "$GITHUB_PATH"
      which ruby || true
      ruby -v || true
      echo "PATH=$PATH"

  # pod install
  - name: Prepare iOS Pods (clean & install)
    shell: bash
    run: |
      set -euxo pipefail
      cd ios
      rm -rf Pods Podfile.lock
      pod repo update
      pod install

  # build
  - name: Build & Distribute via fastlane - ${{ inputs.flavor }}
    shell: bash
    env:
      MATCH_PASSWORD: ${{ inputs.match_password }}

      # App Store Connect API Key (Fastfile에서 ENV를 사용하도록 구성)
      APP_STORE_CONNECT_API_KEY_ID:     ${{ inputs.asc_key_id }}
      APP_STORE_CONNECT_API_ISSUER_ID:  ${{ inputs.asc_issuer_id }}
      APP_STORE_CONNECT_API_KEY_BASE64: ${{ inputs.asc_key_b64 }}

      # 빌드 넘버/플레이버/선택 옵션
      FLAVOR:       ${{ inputs.flavor }}
      BUILD_NUMBER: ${{ inputs.build_number }}
      PREFIX:       ${{ inputs.prefix }}
    run: |
      set -euxo pipefail
      cd ios
      
      if [ -f Gemfile ]; then
        bundle exec fastlane "${FLAVOR}" \
          flavor:"${FLAVOR}" \
          build_number:"${BUILD_NUMBER}" \
          prefix:"${PREFIX}" \
          key_id:"${APP_STORE_CONNECT_API_KEY_ID}" \
          issuer_id:"${APP_STORE_CONNECT_API_ISSUER_ID}" \
          key_base64:"${APP_STORE_CONNECT_API_KEY_BASE64}"
      else
        fastlane "${FLAVOR}" \
          flavor:"${FLAVOR}" \
          build_number:"${BUILD_NUMBER}" \
          prefix:"${PREFIX}" \
          key_id:"${APP_STORE_CONNECT_API_KEY_ID}" \
          issuer_id:"${APP_STORE_CONNECT_API_ISSUER_ID}" \
          key_base64:"${APP_STORE_CONNECT_API_KEY_BASE64}"
      fi

 

ios 에서 애를 좀 먹긴 했다.

이유는 로컬과 같은 버전의 ruby 를 설치했는데 괴상한(?) 오류들을 내뿜으면서

 

actions 가 '루비를 못찾겠다!! 로컬 루비를 달라!!' 라고 하길래.. 

일단 임시적인 임시방편으로 local 루비를 사용하였다.

 

이게 가능한 이유는 초반에 내가 설정한 self-hosted 덕분인지도 모르겠다.

 

그리고 난 다음으로는 항상 하는 pod install.. 

그리고  ios 하위에 설정했던 fastlane 을 실행하도록 했다.

 

 

(IOS) FastFIle

이거도 flavor 를 제외하고는 거의 비슷하기 떄문에 주요 코드만 발췌했다.

lane :dev do |options|
    flavor = options[:flavor] || "dev"
    build_number = options[:build_number]
    prefix = options[:prefix]
    key_id = options[:key_id]
    issuer_id = options[:issuer_id]
    key_base64 = options[:key_base64]
    
    begin
        deployToTestFlight(flavor: flavor, build_number: build_number, prefix: prefix, key_id: key_id, issuer_id: issuer_id, key_base64: key_base64)
        send_slack_message("✅ ios 배포 성공! (Flavor: #{flavor}) 🚀")
    rescue => error
        send_slack_message("❌ ios 배포 실패 (Flavor: #{flavor}): #{error}")
        UI.error("배포 실패: #{error}")
    end
end

 

이건 lane 내부고..

 

def deployToTestFlight(flavor:, build_number: nil, prefix: nil, key_id: nil, issuer_id: nil, key_base64: nil)
    branch_name = sh("git rev-parse --abbrev-ref HEAD").strip
    sh "echo '🔹 Server: #{flavor}' > ../../release_notes.txt"
    sh "echo '🔹 Branch: #{branch_name}' >> ../../release_notes.txt"

    # App Store 배포용 프로비저닝 프로파일 설치
    match(
        type: "appstore",
        readonly: true,
        app_identifier: ["your.bundle.id"]
    )

    # build
    build_app(
        clean: true,
        workspace: "Runner.xcworkspace",
        scheme: flavor,
        configuration: "Release-#{flavor}",
        export_method: "app-store",
    )

    # App Store Connect API Key 구성
    key_id_env     = key_id     || ENV['APP_STORE_CONNECT_KEY_ID']
    issuer_id_env  = issuer_id  || ENV['APP_STORE_CONNECT_ISSUER_ID']
    key_b64_env    = key_base64 || ENV['APP_STORE_CONNECT_KEY_BASE64']

    api_key = app_store_connect_api_key(
        key_id:      key_id_env,
        issuer_id:   issuer_id_env,
        key_content: Base64.decode64(key_b64_env)
    )

    # upload to testflight
    upload_to_testflight(
        api_key: api_key,
        changelog: File.read('../../release_notes.txt'),
        skip_waiting_for_build_processing: true,
        skip_submission: true,                  # App Store 심사 제출 건너뛰기
        distribute_external: false,             # 외부 테스터 배포 비활성화 (내부 테스트만)
    )
end

 

match 를 통해 인증서 프로필을 설치하고,

build_app(...) 을 실행한다.

 

근데.. 이제와서 보니 action.yml 에도 ios build 가 있고 fastfile 에도 app build 가 있는데 중복되네..

이건 조만간 수정해야 할 듯.. (그래서 빌드 시간이 오래 걸렸구만)

 

 

 

무튼 크게보면 이게 끝이다.

마지막으로 배포 완료되면 slack 으로 메시지 전송 되도록 세팅했다.

 

'개발 > DevOps' 카테고리의 다른 글

[Flutter] fastlane 도입기  (0) 2025.01.23
CI/CD 도입 일지  (2) 2024.12.16
CI/CD 정의  (0) 2022.12.21