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 는 이러했다.
- 각 feature 개발
- 개발자 단위 테스트
- merge feature/a into develop/x.y.z
- dev server 기준 QA 요청
- merge feature/a into release/x.y.z
- stg server 기준 QA 요청
- merge release/x.y.z into main
- 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 |