Skip to content

build-ios-app

build-ios-app #9

# ===================================================================
# Flutter iOS 테스트용 TestFlight 배포 워크플로우
# ===================================================================
#
# 이 워크플로우는 기능 브랜치에서 수동으로 실행하여
# 테스트용 iOS 빌드를 TestFlight에 배포합니다.
#
# ★ 마법사 우선 아키텍처 ★
# - 빌드에 필요한 설정 파일들은 웹 마법사가 생성합니다
# - 워크플로우는 마법사가 생성한 파일들을 그대로 사용합니다
# - 마법사 경로: .github/util/flutter/ios-testflight-setup-wizard/index.html
#
# 주요 특징:
# - workflow_dispatch로 수동 실행 또는 repository_dispatch로 트리거
# - 버전은 0.0.0으로 고정 (운영 버전과 분리)
# - 빌드 번호는 PR번호 + 카운트 또는 GITHUB_RUN_NUMBER 사용
# - 브랜치명에서 이슈 번호 자동 추출 (YYYYMMDD_#이슈번호_내용 형식)
# - GitHub API로 이슈 정보 가져와서 릴리즈 노트에 포함
#
# 빌드 파이프라인:
# 1. flutter build ios --no-codesign (Flutter 빌드)
# 2. xcodebuild archive (Xcode 아카이브 생성)
# 3. xcodebuild -exportArchive (IPA 생성, ExportOptions.plist 사용)
# 4. fastlane upload_testflight (마법사 생성 Fastfile 사용)
#
# ===================================================================
# 📋 필요한 GitHub Secrets
# ===================================================================
#
# 🔐 Apple 인증서 & 프로비저닝 프로파일 (필수):
# - APPLE_CERTIFICATE_BASE64 : .p12 인증서 (base64 인코딩)
# - APPLE_CERTIFICATE_PASSWORD : .p12 인증서 비밀번호
# - APPLE_PROVISIONING_PROFILE_BASE64 : .mobileprovision 파일 (base64 인코딩)
# - IOS_PROVISIONING_PROFILE_NAME : 프로비저닝 프로파일 이름 (예: "MyApp Distribution")
#
# 🔑 App Store Connect API (필수):
# - APP_STORE_CONNECT_API_KEY_ID : API Key ID (10자리, 예: ABC123DEF4)
# - APP_STORE_CONNECT_ISSUER_ID : Issuer ID (UUID 형식)
# - APP_STORE_CONNECT_API_KEY_BASE64: AuthKey_XXXXXX.p8 파일 (base64 인코딩)
#
# 📝 환경 설정 (선택):
# - ENV_FILE (또는 ENV) : .env 파일 내용 (앱에서 사용하는 환경변수)
# - SECRETS_XCCONFIG : ios/Flutter/Secrets.xcconfig 내용 (선택)
#
# ===================================================================
name: PROJECT-Flutter-iOS-Test-TestFlight
on:
workflow_dispatch:
repository_dispatch:
types: [build-ios-app]
permissions:
contents: read
issues: write
pull-requests: write
# ============================================
# 🔧 프로젝트별 설정 (아래 값들을 수정하세요)
# ============================================
env:
FLUTTER_VERSION: "3.35.5"
XCODE_VERSION: "26.0"
PROJECT_TYPE: "flutter"
ENV_FILE_PATH: ".env"
jobs:
prepare-test-build:
name: 테스트 빌드 준비
runs-on: macos-26
outputs:
version: ${{ steps.test_version.outputs.version }}
build_number: ${{ steps.test_version.outputs.build_number }}
issue_url: ${{ steps.issue_info.outputs.issue_url }}
issue_title: ${{ steps.issue_info.outputs.issue_title }}
issue_number: ${{ steps.issue_info.outputs.issue_number }}
branch_name: ${{ steps.issue_info.outputs.branch_name }}
pr_number: ${{ steps.test_version.outputs.pr_number }}
commit_hash: ${{ steps.release_notes.outputs.commit_hash }}
progress_comment_id: ${{ steps.progress.outputs.comment_id }}
prepare_start: ${{ steps.progress.outputs.start_time }}
steps:
- name: 브랜치 존재 여부 사전 확인
id: verify_branch
if: github.event_name == 'repository_dispatch'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const branchName = '${{ github.event.client_payload.branch_name }}';
const prNumber = '${{ github.event.client_payload.pr_number }}';
const runId = '${{ github.run_id }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
console.log(`🔍 브랜치 존재 여부 확인: ${branchName}`);
try {
await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch: branchName
});
console.log(`✅ 브랜치 존재 확인됨: ${branchName}`);
} catch (error) {
if (error.status === 404) {
console.log(`❌ 브랜치를 찾을 수 없음: ${branchName}`);
// 에러 댓글 작성
const body = [
'🍎 ❌ **iOS TestFlight 빌드 실패 - 브랜치를 찾을 수 없습니다**',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **요청된 브랜치** | \`${branchName}\` |`,
`| **이슈/PR** | #${prNumber} |`,
'',
'### 💡 확인 사항',
'1. 브랜치가 원격 저장소에 push되었는지 확인하세요',
'2. "Guide by SUH-LAB" 댓글의 브랜치명이 올바른지 확인하세요',
'3. 브랜치명에 오타가 없는지 확인하세요',
'',
`🔗 [워크플로우 로그](${runUrl})`
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: body
});
core.setFailed(`브랜치를 찾을 수 없습니다: ${branchName}`);
} else {
core.setFailed(`브랜치 확인 중 오류: ${error.message}`);
}
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 진행 상황 댓글 시스템
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- name: 진행 상황 댓글 생성
id: progress
if: github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = parseInt('${{ github.event.client_payload.pr_number }}');
const branchName = '${{ github.event.client_payload.branch_name }}';
const buildNumber = '${{ github.event.client_payload.build_number }}';
const runId = '${{ github.run_id }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const startTime = Date.now();
const body = [
'## 🍎 iOS TestFlight 빌드 진행 중...',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
'| 🔧 준비 | ⏳ 진행 중... | - |',
'| 🔨 IPA 빌드 | ⏸️ 대기 | - |',
'| 📤 TestFlight 배포 | ⏸️ 대기 | - |',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **앱 버전** | \`0.0.0(${buildNumber})\` |`,
`| **브랜치** | \`${branchName}\` |`,
'',
`**[📋 실시간 로그 보기](${runUrl})**`,
'',
'---',
'*🤖 이 댓글은 자동으로 업데이트됩니다.*'
].join('\n');
const { data: comment } = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body
});
console.log(`✅ 진행 상황 댓글 생성 완료: #${comment.id}`);
core.setOutput('comment_id', comment.id);
core.setOutput('start_time', startTime);
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref }}
- name: Pull latest changes
run: git pull origin ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref_name }}
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer
- name: Create .env file
run: |
cat << 'EOF' > ${{ env.ENV_FILE_PATH }}
${{ secrets.ENV_FILE || secrets.ENV }}
EOF
echo "✅ ${{ env.ENV_FILE_PATH }} file created"
- name: Create Secrets.xcconfig file
run: |
mkdir -p ios/Flutter
if [ -n "${{ secrets.SECRETS_XCCONFIG }}" ]; then
echo "${{ secrets.SECRETS_XCCONFIG }}" > ios/Flutter/Secrets.xcconfig
echo "✅ Secrets.xcconfig created"
else
echo "// No secrets provided" > ios/Flutter/Secrets.xcconfig
echo "ℹ️ Empty Secrets.xcconfig created (no secrets provided)"
fi
# 테스트 빌드 버전 설정 (0.0.0 고정)
- name: 테스트 빌드 버전 설정
id: test_version
run: |
echo "version=0.0.0" >> $GITHUB_OUTPUT
# repository_dispatch 이벤트인 경우 PR 번호를 빌드 번호로 사용
if [ "${{ github.event_name }}" == "repository_dispatch" ]; then
BUILD_NUMBER="${{ github.event.client_payload.build_number }}"
PR_NUMBER="${{ github.event.client_payload.pr_number }}"
echo "📋 repository_dispatch 이벤트 감지"
echo " PR 번호: #$PR_NUMBER"
echo " 빌드 번호: $BUILD_NUMBER (PR 번호 사용)"
else
BUILD_NUMBER="${{ github.run_number }}"
PR_NUMBER=""
echo "📋 workflow_dispatch 이벤트 감지"
echo " 빌드 번호: $BUILD_NUMBER (기본값)"
fi
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "📋 테스트 빌드: 버전=0.0.0, 빌드번호=$BUILD_NUMBER"
# 브랜치명에서 이슈 번호 추출 및 GitHub API로 이슈 정보 가져오기
- name: 브랜치명에서 이슈 정보 추출
id: issue_info
run: |
# repository_dispatch인 경우 client_payload에서 브랜치명 가져오기
if [ "${{ github.event_name }}" == "repository_dispatch" ]; then
BRANCH_NAME="${{ github.event.client_payload.branch_name }}"
ISSUE_NUMBER="${{ github.event.client_payload.issue_number }}"
else
BRANCH_NAME="${{ github.ref_name }}"
# 브랜치명에서 이슈 번호 추출 (#387 형식)
ISSUE_NUMBER=$(echo "$BRANCH_NAME" | sed -n 's/.*#\([0-9]*\).*/\1/p')
fi
echo "🌿 브랜치명: $BRANCH_NAME"
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
if [ -z "$ISSUE_NUMBER" ]; then
echo "⚠️ 브랜치명에서 이슈 번호를 찾을 수 없습니다."
echo "issue_url=" >> $GITHUB_OUTPUT
echo "issue_title=" >> $GITHUB_OUTPUT
echo "issue_number=" >> $GITHUB_OUTPUT
else
echo "✅ 추출된 이슈 번호: #$ISSUE_NUMBER"
ISSUE_URL="https://github.com/${{ github.repository }}/issues/$ISSUE_NUMBER"
echo "issue_url=$ISSUE_URL" >> $GITHUB_OUTPUT
echo "issue_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT
# GitHub API로 이슈 정보 가져오기
echo "🔍 GitHub API로 이슈 정보 조회 중..."
ISSUE_RESPONSE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/issues/$ISSUE_NUMBER")
ISSUE_TITLE=$(echo "$ISSUE_RESPONSE" | jq -r '.title // "이슈 정보 없음"')
ISSUE_STATE=$(echo "$ISSUE_RESPONSE" | jq -r '.state // "unknown"')
if [ "$ISSUE_TITLE" = "null" ] || [ "$ISSUE_TITLE" = "이슈 정보 없음" ]; then
echo "⚠️ 이슈 #$ISSUE_NUMBER를 찾을 수 없습니다."
echo "issue_title=" >> $GITHUB_OUTPUT
else
echo "📋 이슈 제목: $ISSUE_TITLE"
echo "📌 이슈 상태: $ISSUE_STATE"
echo "issue_title=$ISSUE_TITLE" >> $GITHUB_OUTPUT
fi
fi
# 릴리즈 노트 생성
- name: 릴리즈 노트 생성
id: release_notes
run: |
BUILD_NUMBER="${{ steps.test_version.outputs.build_number }}"
BRANCH_NAME="${{ steps.issue_info.outputs.branch_name }}"
COMMIT_SHA="${{ github.sha }}"
COMMIT_SHORT=$(echo "$COMMIT_SHA" | cut -c1-7)
BUILD_DATE=$(date '+%Y-%m-%d %H:%M:%S')
echo "commit_hash=$COMMIT_SHORT" >> $GITHUB_OUTPUT
cat > final_release_notes.txt << EOF
테스트 빌드 #$BUILD_NUMBER
브랜치: $BRANCH_NAME
커밋: $COMMIT_SHORT
날짜: $BUILD_DATE
EOF
# 이슈 정보가 있으면 추가
if [ -n "${{ steps.issue_info.outputs.issue_number }}" ]; then
ISSUE_NUMBER="${{ steps.issue_info.outputs.issue_number }}"
ISSUE_TITLE="${{ steps.issue_info.outputs.issue_title }}"
ISSUE_URL="${{ steps.issue_info.outputs.issue_url }}"
cat >> final_release_notes.txt << EOF
관련 이슈:
- #$ISSUE_NUMBER: $ISSUE_TITLE
- URL: $ISSUE_URL
EOF
fi
echo "📋 생성된 릴리즈 노트:"
echo "----------------------------------------"
cat final_release_notes.txt
echo "----------------------------------------"
- name: Upload release notes
uses: actions/upload-artifact@v4
with:
name: release-notes
path: final_release_notes.txt
retention-days: 1
- name: Upload project files
uses: actions/upload-artifact@v4
with:
name: project-files
path: |
${{ env.ENV_FILE_PATH }}
ios/Flutter/Secrets.xcconfig
pubspec.yaml
lib/
assets/
retention-days: 1
# 진행 상황 업데이트 - 준비 완료
- name: 진행 상황 업데이트 - 준비 완료
if: github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && steps.progress.outputs.comment_id != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commentId = parseInt('${{ steps.progress.outputs.comment_id }}');
const startTime = parseInt('${{ steps.progress.outputs.start_time }}');
const branchName = '${{ github.event.client_payload.branch_name }}';
const buildNumber = '${{ github.event.client_payload.build_number }}';
const runId = '${{ github.run_id }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const now = Date.now();
const elapsed = now - startTime;
const minutes = Math.floor(elapsed / 60000);
const seconds = Math.floor((elapsed % 60000) / 1000);
const duration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
const body = [
'## 🍎 iOS TestFlight 빌드 진행 중...',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🔧 준비 | ✅ 완료 | ${duration} |`,
'| 🔨 IPA 빌드 | ⏳ 진행 중... | - |',
'| 📤 TestFlight 배포 | ⏸️ 대기 | - |',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **앱 버전** | \`0.0.0(${buildNumber})\` |`,
`| **브랜치** | \`${branchName}\` |`,
'',
`**[📋 실시간 로그 보기](${runUrl})**`,
'',
'---',
'*🤖 이 댓글은 자동으로 업데이트됩니다.*'
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
console.log(`✅ 진행 상황 업데이트 완료: 준비 완료 (${duration})`);
# ============================================
# iOS 빌드 (xcodebuild 직접 사용)
# ============================================
build-ios-test:
name: iOS 테스트 빌드
runs-on: macos-26
needs: prepare-test-build
outputs:
build_start: ${{ steps.build_start.outputs.time }}
steps:
# 빌드 시작 시간 기록 (JavaScript로 밀리초 단위 기록)
- name: 빌드 시작 시간 기록
id: build_start
uses: actions/github-script@v7
with:
script: |
core.setOutput('time', Date.now().toString());
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref }}
- name: Pull latest changes
run: git pull origin ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref_name }}
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer
- name: Download project files
uses: actions/download-artifact@v4
with:
name: project-files
path: .
- name: Ensure .env file exists
run: |
if [ ! -f "${{ env.ENV_FILE_PATH }}" ]; then
cat << 'EOF' > ${{ env.ENV_FILE_PATH }}
${{ secrets.ENV_FILE || secrets.ENV }}
EOF
echo "✅ ${{ env.ENV_FILE_PATH }} file created (fallback)"
fi
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Cache Flutter dependencies
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: ${{ runner.os }}-flutter-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: ${{ runner.os }}-flutter-pub-
- name: Install dependencies
run: flutter pub get
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1"
bundler-cache: true
- name: Install CocoaPods
run: |
gem install cocoapods
cd ios && pod install
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Install Provisioning Profiles
id: install_profile
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
# 메인 앱 프로파일 설치
echo "${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode > profile.mobileprovision
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
# UUID 추출
uuid=$(grep -A1 -a "UUID" profile.mobileprovision | grep string | sed -e "s/<string>//" -e "s/<\/string>//" -e "s/[[:space:]]//g")
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$uuid.mobileprovision
# 프로파일 이름 추출 (동적)
profile_name=$(security cms -D -i profile.mobileprovision | plutil -extract Name xml1 -o - - | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
echo "✅ Main App Provisioning Profile installed"
echo " UUID: $uuid"
echo " Name: $profile_name"
# 출력 저장
echo "profile_uuid=$uuid" >> $GITHUB_OUTPUT
echo "profile_name=$profile_name" >> $GITHUB_OUTPUT
# Share Extension 프로파일 설치 (선택적)
if [ -n "${{ secrets.APPLE_PROVISIONING_PROFILE_SHARE_BASE64 }}" ]; then
echo "${{ secrets.APPLE_PROVISIONING_PROFILE_SHARE_BASE64 }}" | base64 --decode > share_extension_profile.mobileprovision
cp share_extension_profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
# Share Extension UUID 추출
share_uuid=$(grep -A1 -a "UUID" share_extension_profile.mobileprovision | grep string | sed -e "s/<string>//" -e "s/<\/string>//" -e "s/[[:space:]]//g")
cp share_extension_profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$share_uuid.mobileprovision
# Share Extension 프로파일 이름 추출 (동적)
share_profile_name=$(security cms -D -i share_extension_profile.mobileprovision | plutil -extract Name xml1 -o - - | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
echo "✅ Share Extension Provisioning Profile installed"
echo " UUID: $share_uuid"
echo " Name: $share_profile_name"
# 출력 저장
echo "share_profile_uuid=$share_uuid" >> $GITHUB_OUTPUT
echo "share_profile_name=$share_profile_name" >> $GITHUB_OUTPUT
fi
- name: Create ExportOptions.plist
run: |
cd ios
# Share Extension 프로파일이 있는지 확인
if [ -n "${{ steps.install_profile.outputs.share_profile_name }}" ]; then
# Share Extension 포함
cat > ExportOptions.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>${{ secrets.APPLE_TEAM_ID }}</string>
<key>provisioningProfiles</key>
<dict>
<key>com.elipair.mapsy</key>
<string>${{ steps.install_profile.outputs.profile_name }}</string>
<key>com.elipair.mapsy.share</key>
<string>${{ steps.install_profile.outputs.share_profile_name }}</string>
</dict>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
EOF
echo "✅ ExportOptions.plist 생성 완료 (Share Extension 포함)"
echo " Main Profile: ${{ steps.install_profile.outputs.profile_name }}"
echo " Share Extension Profile: ${{ steps.install_profile.outputs.share_profile_name }}"
else
# 메인 앱만
cat > ExportOptions.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>${{ secrets.APPLE_TEAM_ID }}</string>
<key>provisioningProfiles</key>
<dict>
<key>com.elipair.mapsy</key>
<string>${{ steps.install_profile.outputs.profile_name }}</string>
</dict>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
EOF
echo "✅ ExportOptions.plist 생성 완료 (메인 앱만)"
echo " Main Profile: ${{ steps.install_profile.outputs.profile_name }}"
fi
- name: Flutter build (no codesign)
run: |
flutter build ios --release --no-codesign \
--build-name="0.0.0" \
--build-number="${{ needs.prepare-test-build.outputs.build_number }}"
- name: Configure Code Signing in Project
run: |
cd ios
# xcodeproj gem 설치
gem install xcodeproj
# Ruby 스크립트로 프로젝트 설정 수정
ruby <<RUBY_SCRIPT
require 'xcodeproj'
project = Xcodeproj::Project.open('Runner.xcodeproj')
# Runner 타겟 설정
runner_target = project.targets.find { |t| t.name == 'Runner' }
if runner_target
runner_target.build_configurations.each do |config|
if config.name == 'Release'
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = '${{ steps.install_profile.outputs.profile_name }}'
puts "✅ Runner target configured with profile: ${{ steps.install_profile.outputs.profile_name }}"
end
end
end
# Share Extension 타겟 설정 (있는 경우)
share_profile = '${{ steps.install_profile.outputs.share_profile_name }}'
if !share_profile.empty?
share_target = project.targets.find { |t| t.name == 'com.elipair.mapsy.share' }
if share_target
share_target.build_configurations.each do |config|
if config.name == 'Release'
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = share_profile
puts "✅ com.elipair.mapsy.share target configured with profile: #{share_profile}"
end
end
end
end
project.save
puts "✅ Project signing configuration saved"
RUBY_SCRIPT
- name: Create Archive
run: |
cd ios
echo "📦 Creating Archive with Manual Signing"
xcodebuild -workspace Runner.xcworkspace \
-scheme Runner \
-configuration Release \
-archivePath build/Runner.xcarchive \
-destination 'generic/platform=iOS' \
archive
- name: Export IPA
run: |
cd ios
xcodebuild -exportArchive \
-archivePath build/Runner.xcarchive \
-exportPath build/ipa \
-exportOptionsPlist ExportOptions.plist
- name: Create build metadata file
run: |
cat > build-metadata.json << EOF
{
"pr_number": "${{ needs.prepare-test-build.outputs.pr_number }}",
"build_number": "${{ needs.prepare-test-build.outputs.build_number }}",
"issue_number": "${{ needs.prepare-test-build.outputs.issue_number }}",
"branch_name": "${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref_name }}"
}
EOF
cat build-metadata.json
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: ios-ipa
path: |
ios/build/ipa/*.ipa
build-metadata.json
retention-days: 1
# 진행 상황 업데이트 - IPA 빌드 완료
- name: 진행 상황 업데이트 - IPA 빌드 완료
if: success() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && needs.prepare-test-build.outputs.progress_comment_id != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commentId = parseInt('${{ needs.prepare-test-build.outputs.progress_comment_id }}');
const prepareStart = parseInt('${{ needs.prepare-test-build.outputs.prepare_start }}');
const buildStart = parseInt('${{ steps.build_start.outputs.time }}');
const branchName = '${{ github.event.client_payload.branch_name }}';
const buildNumber = '${{ github.event.client_payload.build_number }}';
const runId = '${{ github.run_id }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const now = Date.now();
// 준비 단계 소요 시간
const prepareElapsed = buildStart - prepareStart;
const prepareMin = Math.floor(prepareElapsed / 60000);
const prepareSec = Math.floor((prepareElapsed % 60000) / 1000);
const prepareDuration = prepareMin > 0 ? `${prepareMin}분 ${prepareSec}초` : `${prepareSec}초`;
// 빌드 단계 소요 시간
const buildElapsed = now - buildStart;
const buildMin = Math.floor(buildElapsed / 60000);
const buildSec = Math.floor((buildElapsed % 60000) / 1000);
const buildDuration = buildMin > 0 ? `${buildMin}분 ${buildSec}초` : `${buildSec}초`;
const body = [
'## 🍎 iOS TestFlight 빌드 진행 중...',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🔧 준비 | ✅ 완료 | ${prepareDuration} |`,
`| 🔨 IPA 빌드 | ✅ 완료 | ${buildDuration} |`,
'| 📤 TestFlight 배포 | ⏳ 진행 중... | - |',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **앱 버전** | \`0.0.0(${buildNumber})\` |`,
`| **브랜치** | \`${branchName}\` |`,
'',
`**[📋 실시간 로그 보기](${runUrl})**`,
'',
'---',
'*🤖 이 댓글은 자동으로 업데이트됩니다.*'
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
console.log(`✅ 진행 상황 업데이트 완료: IPA 빌드 완료 (${buildDuration})`);
# 빌드 실패 시 progress 댓글 업데이트
- name: 빌드 실패 시 progress 댓글 업데이트
if: failure() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && needs.prepare-test-build.outputs.progress_comment_id != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commentId = parseInt('${{ needs.prepare-test-build.outputs.progress_comment_id }}');
const prepareStart = parseInt('${{ needs.prepare-test-build.outputs.prepare_start }}');
const buildStart = parseInt('${{ steps.build_start.outputs.time }}');
const branchName = '${{ github.event.client_payload.branch_name }}';
const buildNumber = '${{ github.event.client_payload.build_number }}';
const commitHash = '${{ needs.prepare-test-build.outputs.commit_hash }}';
const runId = '${{ github.run_id }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const now = Date.now();
// 준비 단계 소요 시간
const prepareElapsed = buildStart - prepareStart;
const prepareMin = Math.floor(prepareElapsed / 60000);
const prepareSec = Math.floor((prepareElapsed % 60000) / 1000);
const prepareDuration = prepareMin > 0 ? `${prepareMin}분 ${prepareSec}초` : `${prepareSec}초`;
// 빌드 단계 소요 시간
const buildElapsed = now - buildStart;
const buildMin = Math.floor(buildElapsed / 60000);
const buildSec = Math.floor((buildElapsed % 60000) / 1000);
const buildDuration = buildMin > 0 ? `${buildMin}분 ${buildSec}초` : `${buildSec}초`;
const body = [
'## 🍎 ❌ iOS TestFlight 빌드 실패',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🔧 준비 | ✅ 완료 | ${prepareDuration} |`,
`| 🔨 IPA 빌드 | ❌ 실패 | ${buildDuration} |`,
'| 📤 TestFlight 배포 | ⏸️ 취소됨 | - |',
'',
'| 항목 | 값 |',
'|------|-----|',
`| **앱 버전** | \`0.0.0(${buildNumber})\` |`,
`| **브랜치** | \`${branchName}\` |`,
`| **커밋** | \`${commitHash}\` |`,
'',
'❌ **IPA 빌드 중 오류가 발생했습니다.**',
'',
`🔗 [워크플로우 실행 로그 확인](${runUrl})`
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
console.log(`❌ 진행 상황 업데이트 완료: IPA 빌드 실패`);
# ============================================
# TestFlight 배포 (마법사 생성 Fastfile 사용)
# ============================================
deploy-testflight-test:
name: TestFlight 테스트 배포
runs-on: macos-26
needs: [prepare-test-build, build-ios-test]
steps:
# 배포 시작 시간 기록
- name: 배포 시작 시간 기록
id: deploy_start
uses: actions/github-script@v7
with:
script: |
core.setOutput('time', Date.now().toString());
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref }}
- name: Setup App Store Connect API Key
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo "${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: ios-ipa
path: ios/build/ipa/
- name: Download release notes
uses: actions/download-artifact@v4
with:
name: release-notes
path: .
- name: Verify Fastfile exists
run: |
if [ ! -f "ios/fastlane/Fastfile" ]; then
echo "❌ ios/fastlane/Fastfile이 없습니다!"
echo "웹 마법사를 실행하여 설정 파일을 생성하세요:"
echo " 브라우저에서 .github/util/flutter/ios-testflight-setup-wizard/index.html 열기"
exit 1
fi
echo "✅ Fastfile 확인됨: ios/fastlane/Fastfile"
- name: Install Fastlane
run: gem install fastlane
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
# IPA 파일 찾기
IPA_PATH=$(find $GITHUB_WORKSPACE/ios/build/ipa -name "*.ipa" | head -1)
echo "📦 Found IPA at: $IPA_PATH"
if [ ! -f "$IPA_PATH" ]; then
echo "❌ IPA 파일을 찾을 수 없습니다!"
ls -la ios/build/ipa/
exit 1
fi
# Release notes 준비
if [ -f "final_release_notes.txt" ]; then
RELEASE_NOTES=$(cat final_release_notes.txt)
echo "📝 Release Notes:"
echo "$RELEASE_NOTES"
else
RELEASE_NOTES="테스트 빌드 #${{ needs.prepare-test-build.outputs.build_number }}"
echo "📝 기본 Release Notes: $RELEASE_NOTES"
fi
# 환경변수 설정 (Fastfile에서 사용)
export API_KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8"
export IPA_PATH="$IPA_PATH"
export RELEASE_NOTES="$RELEASE_NOTES"
echo "🔧 Working directory: $(pwd)"
echo "🔧 IPA_PATH: $IPA_PATH"
echo "🔧 API_KEY_PATH: $API_KEY_PATH"
# Fastlane 실행 (마법사가 생성한 Fastfile 사용)
cd ios
fastlane upload_testflight
- name: Notify TestFlight Upload Success
if: success()
run: |
echo "✅ TestFlight 테스트 빌드 업로드 성공!"
echo "버전: 0.0.0"
echo "빌드 번호: ${{ needs.prepare-test-build.outputs.build_number }}"
if [ -n "${{ needs.prepare-test-build.outputs.pr_number }}" ]; then
echo "PR 번호: ${{ needs.prepare-test-build.outputs.pr_number }}"
fi
echo "브랜치: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref_name }}"
echo "커밋: ${{ github.sha }}"
if [ -n "${{ needs.prepare-test-build.outputs.issue_url }}" ]; then
echo "관련 이슈: ${{ needs.prepare-test-build.outputs.issue_url }}"
fi
- name: Notify on Failure
if: failure()
run: |
echo "❌ TestFlight 테스트 빌드 업로드 실패!"
echo "로그를 확인해주세요."
# 진행 상황 최종 업데이트 - 성공
- name: 진행 상황 최종 업데이트 - 성공
if: success() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && needs.prepare-test-build.outputs.progress_comment_id != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commentId = parseInt('${{ needs.prepare-test-build.outputs.progress_comment_id }}');
const prepareStart = parseInt('${{ needs.prepare-test-build.outputs.prepare_start }}');
const buildStart = parseInt('${{ needs.build-ios-test.outputs.build_start }}');
const deployStart = parseInt('${{ steps.deploy_start.outputs.time }}');
const branchName = '${{ github.event.client_payload.branch_name }}';
const buildNumber = '${{ github.event.client_payload.build_number }}';
const commitHash = '${{ needs.prepare-test-build.outputs.commit_hash }}';
const runId = '${{ github.run_id }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const now = Date.now();
// 준비 단계 소요 시간
const prepareElapsed = buildStart - prepareStart;
const prepareMin = Math.floor(prepareElapsed / 60000);
const prepareSec = Math.floor((prepareElapsed % 60000) / 1000);
const prepareDuration = prepareMin > 0 ? `${prepareMin}분 ${prepareSec}초` : `${prepareSec}초`;
// 빌드 단계 소요 시간
const buildElapsed = deployStart - buildStart;
const buildMin = Math.floor(buildElapsed / 60000);
const buildSec = Math.floor((buildElapsed % 60000) / 1000);
const buildDuration = buildMin > 0 ? `${buildMin}분 ${buildSec}초` : `${buildSec}초`;
// 배포 단계 소요 시간
const deployElapsed = now - deployStart;
const deployMin = Math.floor(deployElapsed / 60000);
const deploySec = Math.floor((deployElapsed % 60000) / 1000);
const deployDuration = deployMin > 0 ? `${deployMin}분 ${deploySec}초` : `${deploySec}초`;
// 전체 소요 시간
const totalElapsed = now - prepareStart;
const totalMin = Math.floor(totalElapsed / 60000);
const totalSec = Math.floor((totalElapsed % 60000) / 1000);
const totalDuration = totalMin > 0 ? `${totalMin}분 ${totalSec}초` : `${totalSec}초`;
const body = [
'## 🍎 ✅ iOS TestFlight 빌드 완료',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🔧 준비 | ✅ 완료 | ${prepareDuration} |`,
`| 🔨 IPA 빌드 | ✅ 완료 | ${buildDuration} |`,
`| 📤 TestFlight 배포 | ✅ 완료 | ${deployDuration} |`,
'',
`**총 소요 시간: ${totalDuration}**`,
'',
'| 항목 | 값 |',
'|------|-----|',
`| **앱 버전** | \`0.0.0(${buildNumber})\` |`,
`| **브랜치** | \`${branchName}\` |`,
`| **커밋** | \`${commitHash}\` |`,
'',
'📱 **TestFlight에서 테스트 앱을 다운로드할 수 있습니다.**',
'',
`🔗 [워크플로우 실행 로그](${runUrl})`
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
console.log(`✅ 진행 상황 최종 업데이트 완료: 전체 성공 (${totalDuration})`);
# 진행 상황 최종 업데이트 - 실패
- name: 진행 상황 최종 업데이트 - 실패
if: failure() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && needs.prepare-test-build.outputs.progress_comment_id != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commentId = parseInt('${{ needs.prepare-test-build.outputs.progress_comment_id }}');
const prepareStart = parseInt('${{ needs.prepare-test-build.outputs.prepare_start }}');
const buildStart = parseInt('${{ needs.build-ios-test.outputs.build_start }}');
const deployStart = parseInt('${{ steps.deploy_start.outputs.time }}');
const branchName = '${{ github.event.client_payload.branch_name }}';
const buildNumber = '${{ github.event.client_payload.build_number }}';
const commitHash = '${{ needs.prepare-test-build.outputs.commit_hash }}';
const runId = '${{ github.run_id }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const now = Date.now();
// 준비 단계 소요 시간
const prepareElapsed = buildStart - prepareStart;
const prepareMin = Math.floor(prepareElapsed / 60000);
const prepareSec = Math.floor((prepareElapsed % 60000) / 1000);
const prepareDuration = prepareMin > 0 ? `${prepareMin}분 ${prepareSec}초` : `${prepareSec}초`;
// 빌드 단계 소요 시간
const buildElapsed = deployStart - buildStart;
const buildMin = Math.floor(buildElapsed / 60000);
const buildSec = Math.floor((buildElapsed % 60000) / 1000);
const buildDuration = buildMin > 0 ? `${buildMin}분 ${buildSec}초` : `${buildSec}초`;
// 배포 단계 소요 시간
const deployElapsed = now - deployStart;
const deployMin = Math.floor(deployElapsed / 60000);
const deploySec = Math.floor((deployElapsed % 60000) / 1000);
const deployDuration = deployMin > 0 ? `${deployMin}분 ${deploySec}초` : `${deploySec}초`;
const body = [
'## 🍎 ❌ iOS TestFlight 빌드 실패',
'',
'| 단계 | 상태 | 소요 시간 |',
'|------|------|----------|',
`| 🔧 준비 | ✅ 완료 | ${prepareDuration} |`,
`| 🔨 IPA 빌드 | ✅ 완료 | ${buildDuration} |`,
`| 📤 TestFlight 배포 | ❌ 실패 | ${deployDuration} |`,
'',
'| 항목 | 값 |',
'|------|-----|',
`| **앱 버전** | \`0.0.0(${buildNumber})\` |`,
`| **브랜치** | \`${branchName}\` |`,
`| **커밋** | \`${commitHash}\` |`,
'',
'❌ **TestFlight 배포 중 오류가 발생했습니다.**',
'',
`🔗 [워크플로우 실행 로그 확인](${runUrl})`
].join('\n');
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body: body
});
console.log(`❌ 진행 상황 최종 업데이트 완료: TestFlight 배포 실패`);