diff --git a/.github/config/breaking-changes.json b/.github/config/breaking-changes.json index 86bfcf81..288f9cef 100644 --- a/.github/config/breaking-changes.json +++ b/.github/config/breaking-changes.json @@ -25,5 +25,17 @@ "severity": "critical", "title": "Skills 배포 방식 변경 - 마켓플레이스 전용 전환", "message": "Skills(IDE 명령어)가 template_integrator 복사 방식에서 Claude Code 플러그인 마켓플레이스 전용으로 변경되었습니다. 기존 .claude/skills/, .claude/scripts/ 경로가 루트 skills/, scripts/로 이동했습니다. Claude Code 사용자는 'claude plugin marketplace add Cassiiopeia/SUH-DEVOPS-TEMPLATE && claude plugin install cassiiopeia@cassiiopeia-marketplace --scope user' 명령어로 설치하세요. Cursor 사용자는 통합 시 자동으로 .cursor 폴더 복사 여부를 물어봅니다. 기존 .claude/skills/, .claude/scripts/ 폴더는 더 이상 업데이트되지 않습니다." + }, + + "3.0.133": { + "severity": "warning", + "title": "Spring 배포 워크플로우 SSH 인증 방식(password/key) 추가", + "message": "Spring 배포 워크플로우(SYNOLOGY-SIMPLE-CICD, SYNOLOGY-NONSTOP-NGINX-CICD)에 SSH_AUTH_METHOD 환경변수가 추가되어 Synology(password) 외에 AWS EC2 등 .pem 키(key) 인증 서버에도 배포할 수 있습니다. 기존 Synology 배포는 SSH_AUTH_METHOD 기본값이 password라 영향이 없습니다. 워크플로우를 새로 받으면 env의 SSH_AUTH_METHOD 값을 확인하고, key 인증을 쓰는 경우 GitHub Secret에 SSH_KEY(.pem 내용)를 등록하세요. 자세한 설정은 docs/SYNOLOGY-DEPLOYMENT-GUIDE.md의 'SSH 인증 방식과 다른 서버(AWS EC2 등) 배포' 섹션을 참고하세요." + }, + + "3.0.137": { + "severity": "warning", + "title": "배포 워크플로우 synology 폴더/옵션 제거 — SSH+Docker 엔진으로 통일", + "message": "배포 워크플로우의 synology/ 폴더 구분이 완전히 제거되었습니다. Spring 배포(SIMPLE-CICD, NONSTOP-NGINX-CICD, NONSTOP-TRAEFIK-CICD, PR-PREVIEW)와 Python 배포(SIMPLE-CICD, PR-PREVIEW)는 각 타입 루트로 이동해 기본 포함되고, Flutter Synology APK 배포는 PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD로 개명되었습니다. Nexus는 spring/nexus/, Secret 백업은 common/secret-backup/(PROJECT-COMMON-SECRET-FILE-UPLOAD로 개명)으로 분리되어 통합 마법사에서 --nexus / --secret-backup 옵션(.ps1은 -Nexus / -SecretBackup)으로 선택합니다. version.yml의 옵션 키가 options.synology에서 options.nexus / options.secret_backup으로 변경되었습니다 — 기존 synology 키는 더 이상 사용되지 않으니 통합 마법사를 다시 실행해 새 옵션을 설정하세요. 다른 서버(AWS EC2 등) 배포는 SSH_AUTH_METHOD=key + SSH_KEY secret으로 설정하세요. 자세한 내용은 docs/SSH-DOCKER-DEPLOYMENT-GUIDE.md를 참고하세요." } } diff --git a/.github/config/wizard-prompts.yml b/.github/config/wizard-prompts.yml new file mode 100644 index 00000000..ab89f6df --- /dev/null +++ b/.github/config/wizard-prompts.yml @@ -0,0 +1,62 @@ +# @wizard ask 마커의 사용자 질문 문구. +# 형식 1 (구형, 지원 유지): KEY: "라벨" +# 형식 2 (신규 블록): KEY 아래 label/help/example 들여쓰기 2칸 +# 형식 3 (타입 오버라이드): "{type}.KEY" 키로 타입별 문구 분리 +# 조회 우선순위: {type}.KEY -> KEY -> 구형 -> 폴백(KEY명) +# 기본값/resolver는 여기 두지 않는다(@wizard 마커 소관). + +PROJECT_NAME: + label: "서비스 식별자 (영문 슬러그)" + help: "Docker 컨테이너명·이미지명·배포 도메인 prefix에 그대로 사용됩니다. 소문자-하이픈 권장." + example: "my-service" + +flutter.APP_ARTIFACT_NAME: + label: "앱 산출물 이름 (영문)" + help: "빌드된 APK 파일명·아티팩트명·빌드 히스토리 파일명에 사용됩니다." + example: "my-app" + +SERVICE_DOMAIN: + label: "서비스 도메인" + help: "배포 후 외부에서 접속할 운영 도메인입니다. 실제 보유한 도메인으로 바꿔야 합니다." + example: "api.example.com" + +JAVA_VERSION: + label: "빌드 JDK 버전" + help: "GitHub Actions 빌드 러너의 JDK 버전입니다. Spring은 보통 21, Flutter Android는 17." + example: "21" + +DEPLOY_PORT: + label: "외부 노출 포트" + help: "호스트(서버)에서 컨테이너로 연결할 포트입니다. 다른 서비스와 겹치지 않게 정하세요." + example: "8080" + +VOLUME_HOST_PATH: + label: "호스트(NAS) 볼륨 경로" + help: "서버에 데이터를 영구 저장할 실제 디렉토리 경로입니다." + example: "/volume1/projects/my-service" + +VOLUME_CONTAINER_PATH: + label: "컨테이너 내부 마운트 경로" + help: "컨테이너 안에서 위 볼륨이 보일 위치입니다." + example: "/mnt/my-service" + +SSH_AUTH_METHOD: + label: "SSH 인증 방식" + help: "배포 서버 접속 방식입니다. 둘 중 하나를 그대로 입력하세요." + example: "password 또는 key" + +# workflow filename (partial match) -> human readable short name +# wizard uses this to build "[usage]" in env questions. +_workflow_names: + NONSTOP-NGINX: "무중단배포(Nginx)" + NONSTOP-TRAEFIK: "무중단배포(Traefik)" + PR-PREVIEW: "PR 프리뷰" + SIMPLE-CICD: "단일 서버 배포" + REACT-CICD: "프론트 배포" + REACT-CI: "프론트 빌드" + NEXT-CICD: "프론트 배포" + NEXT-CI: "프론트 빌드" + PYTHON-CI: "빌드 검증" + FLUTTER-ANDROID-SELFHOSTED: "안드로이드 자체배포" + FLUTTER-ANDROID-PLAYSTORE: "플레이스토어 배포" + FLUTTER-ANDROID-FIREBASE: "Firebase 배포" diff --git a/.github/util/flutter/playstore-wizard/playstore-wizard-setup.ps1 b/.github/util/flutter/playstore-wizard/playstore-wizard-setup.ps1 index cb055da0..17717e22 100644 --- a/.github/util/flutter/playstore-wizard/playstore-wizard-setup.ps1 +++ b/.github/util/flutter/playstore-wizard/playstore-wizard-setup.ps1 @@ -777,6 +777,13 @@ function Show-Summary { Write-Host "" Write-Host " 3. Push to deploy branch to test build" Write-Host "" + Write-Host "🎛️ 배포 모드 설정 (선택):" + Write-Host " GitHub repo Variables에 ANDROID_DEPLOY_MODE 를 설정하면 기본 배포 범위를 정할 수 있습니다." + Write-Host " store_only : 내부 테스트(internal) 업로드까지만 (기본)" + Write-Host " store_prepare : production draft 승급 (콘솔에서 '출시 시작' 대기)" + Write-Host " store_submit : production 심사 자동 등록 (정식 출시 1회 수동 이후부터 가능)" + Write-Host " 워크플로우 수동 실행 시 deploy_mode 입력이 이 변수보다 우선합니다." + Write-Host "" Write-Host "================================================================" -ForegroundColor Cyan } diff --git a/.github/util/flutter/playstore-wizard/playstore-wizard-setup.sh b/.github/util/flutter/playstore-wizard/playstore-wizard-setup.sh index d5966c40..3a3bdab5 100644 --- a/.github/util/flutter/playstore-wizard/playstore-wizard-setup.sh +++ b/.github/util/flutter/playstore-wizard/playstore-wizard-setup.sh @@ -593,15 +593,25 @@ patch_build_gradle() { exit 1 fi - # Python 3 설치 확인 - if ! command -v python3 &> /dev/null; then - print_error "Python 3가 설치되어 있지 않습니다" - print_warning "Python 3를 설치한 후 다시 시도하세요" + # Python 설치 확인. + # ⚠️ Windows는 python3가 "WindowsApps/python3" 스텁(실행 시 안내문만 출력하고 종료)을 가리키는 경우가 흔하다. + # command -v 만으로는 스텁도 "있음"으로 잡혀 폴백이 안 된다 → 실제 실행(-c)까지 검증해 진짜 Python을 고른다. + local PYTHON _py _path + PYTHON="" + for _py in python3 python; do + _path=$(command -v "$_py" 2>/dev/null) || continue + "$_path" -c "import sys; sys.exit(0)" 2>/dev/null && { PYTHON="$_path"; break; } + done + if [ -z "$PYTHON" ]; then + print_error "실행 가능한 Python을 찾을 수 없습니다 (python3/python 모두 없음 또는 스텁)" + print_warning "Python 3를 설치한 후 다시 시도하세요 (Windows는 Microsoft Store 스텁이 아닌 정식 설치본 필요)" exit 1 fi - # Python 스크립트 실행 - python3 "$patch_script" "$gradle_file" + # Python 스크립트 실행. + # ⚠️ PYTHONIOENCODING=utf-8 필수: 한국어 Windows 콘솔 기본 인코딩(cp949)은 패치 스크립트가 + # 출력하는 이모지(✅/⚠️/❌)를 인코딩하지 못해 UnicodeEncodeError로 죽는다. + PYTHONIOENCODING=utf-8 "$PYTHON" "$patch_script" "$gradle_file" if [ $? -ne 0 ]; then print_error "build.gradle.kts 패치 실패!" @@ -764,6 +774,12 @@ print_completion() { echo "" echo " 3. deploy 브랜치로 푸시하여 빌드 테스트" echo "" + echo "🎛️ 배포 모드 설정 (선택):" + echo " GitHub repo Variables에 ANDROID_DEPLOY_MODE 를 설정하면 기본 배포 범위를 정할 수 있습니다." + echo " store_only : 내부 테스트(internal) 업로드까지만 (기본)" + echo " store_prepare : production draft 승급 (콘솔에서 '출시 시작' 대기)" + echo " store_submit : production 심사 자동 등록 (정식 출시 1회 수동 이후부터 가능)" + echo " 워크플로우 수동 실행 시 deploy_mode 입력이 이 변수보다 우선합니다." } diff --git a/.github/util/flutter/playstore-wizard/playstore-wizard.html b/.github/util/flutter/playstore-wizard/playstore-wizard.html index 780adf92..06251f53 100644 --- a/.github/util/flutter/playstore-wizard/playstore-wizard.html +++ b/.github/util/flutter/playstore-wizard/playstore-wizard.html @@ -1846,6 +1846,17 @@

+
+

🎛️ 배포 모드 & 출시 로드맵 (Android)

+

GitHub repo Variables → ANDROID_DEPLOY_MODE 로 기본 배포 범위를 정합니다. 워크플로우 수동 실행 시 deploy_mode 입력이 우선합니다.

+ +

⚠️ 신규 앱은 최초 1회 Play Console에서 수동 출시가 필요하고, 이후 업데이트부터 store_submit으로 자동화됩니다.

+
+
diff --git a/.github/util/flutter/playstore-wizard/templates/Fastfile.playstore.template b/.github/util/flutter/playstore-wizard/templates/Fastfile.playstore.template index ccb2de80..0a1d2a2d 100644 --- a/.github/util/flutter/playstore-wizard/templates/Fastfile.playstore.template +++ b/.github/util/flutter/playstore-wizard/templates/Fastfile.playstore.template @@ -1,109 +1,145 @@ -# Fastfile for Play Store Internal Testing Deployment -# Path: android/fastlane/Fastfile.playstore -# Generated by Flutter Play Store CI/CD Helper -# -# This file is used by the CI/CD workflow to deploy to Play Store. -# Copy this file to android/fastlane/Fastfile.playstore -# Replace {{APPLICATION_ID}} with your actual application ID. - default_platform(:android) platform :android do - desc "Deploy to Play Store Internal Testing" - lane :deploy_internal do - # Environment variables - aab_path = ENV["AAB_PATH"] || "../build/app/outputs/bundle/release/app-release.aab" - json_key = ENV["GOOGLE_PLAY_JSON_KEY"] || "~/.config/gcloud/service-account.json" + desc "Build AAB" + lane :build_aab do + flutter_build( + build: 'appbundle', + project_dir: '..' + ) + end - puts "=========================================" - puts "Deploying to Play Store Internal Testing" - puts "=========================================" - puts "AAB Path: #{aab_path}" - puts "Service Account: #{json_key}" + # ============================================================ + # ⚠️ 이 파일을 처음 사용하는 경우 반드시 읽어주세요 + # ============================================================ + # Google Play Store에 한 번도 프로덕션 출시를 하지 않은 앱(Draft App)은 + # release_status: 'completed' 및 프로덕션 자동 승급이 불가합니다. + # + # 최초 1회는 Play Console에서 수동으로 프로덕션 출시를 완료해야 합니다. + # 그 이후부터 아래 자동화 흐름이 정상 동작합니다: + # internal(completed) → promote_internal_to_production → Google 심사 요청 자동화 + # ============================================================ + + # ============================================================ + # 배포 모드(DEPLOY_MODE): + # store_only : 내부 테스트(internal) 트랙 업로드까지만 + # store_prepare : internal 업로드 + production에 draft로 승급(콘솔에서 "출시 시작" 대기) + # store_submit : internal 업로드 + production completed 승급(Google 심사 자동 등록) — 완전 자동 + # (구 testflight_only/appstore_prepare/appstore_submit 별칭도 호환) + # + # ⚠️ 신규 앱(Draft App)은 release_status: 'completed' 및 프로덕션 자동 승급이 불가합니다. + # 최초 정식 출시 1회는 Play Console에서 수동으로 하고, 그 이후부터 이 lane으로 자동화됩니다. + # ============================================================ + desc "Deploy to Play Store (DEPLOY_MODE: store_only | store_prepare | store_submit)" + lane :deploy_internal do + version_name = ENV["VERSION_NAME"] || "1.0.0" + version_code = ENV["VERSION_CODE"] || "1" + + # 배포 모드 (iOS와 통일된 3단계 — store_ 접두사로 플랫폼 공통): + # store_only : 내부 테스트(internal) 트랙 업로드까지만 + # store_prepare : internal 업로드 + production에 draft로 올림 + # → Play Console에서 사람이 "출시 시작" 눌러야 심사 진행 (제출 직전 상태) + # store_submit : internal 업로드 + production completed 승급(심사 자동 등록) — 완전 자동 + # (구 testflight_only/appstore_prepare/appstore_submit 별칭도 호환) + deploy_mode = (ENV["DEPLOY_MODE"] || "store_submit").strip + promote = ["store_prepare", "store_submit", "appstore_prepare", "appstore_submit"].include?(deploy_mode) + submit = ["store_submit", "appstore_submit"].include?(deploy_mode) + + puts "📦 배포 정보: #{version_name} (#{version_code}) / 모드: #{deploy_mode} (프로덕션 승급: #{promote} / 심사 자동등록: #{submit})" + + # ── Step 1: 내부 테스트 트랙에 업로드 ────────────────────── puts "" + puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + puts "📤 1단계: Play Store 내부 테스트 트랙에 업로드 중..." + puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # Verify AAB exists - unless File.exist?(aab_path) - UI.user_error!("AAB file not found: #{aab_path}") - end - - # Verify Service Account exists - unless File.exist?(json_key) - UI.user_error!("Service Account JSON not found: #{json_key}") - end - - # Upload to Play Store - # ⚠️ release_status 설정 가이드: - # - "draft": 앱이 Play Console에서 아직 한 번도 출시되지 않은 경우 (신규 앱) - # - "completed": 앱이 이미 Play Console에서 검토 완료되어 활성화된 경우 - # - # 신규 앱은 반드시 "draft"로 시작해야 합니다. - # Play Console에서 첫 번째 릴리즈를 수동으로 검토 제출 후 승인되면 - # "completed"로 변경하여 자동 배포가 가능합니다. upload_to_play_store( - package_name: "{{APPLICATION_ID}}", - track: "internal", - aab: aab_path, - json_key: json_key, - skip_upload_metadata: true, - skip_upload_images: true, - skip_upload_screenshots: true, - release_status: "draft" # 신규 앱: "draft" → 승인 후: "completed"로 변경 + package_name: "{{APPLICATION_ID}}", # Android 패키지 이름 + track: 'internal', # 내부 테스트 트랙 + aab: ENV["AAB_PATH"], # AAB 파일 경로 + json_key: ENV["GOOGLE_PLAY_JSON_KEY"], # Service Account JSON 경로 + skip_upload_apk: true, # APK 업로드 안함 + skip_upload_metadata: true, # 메타데이터 업로드 안함 + skip_upload_images: true, # 이미지 업로드 안함 + skip_upload_screenshots: true, # 스크린샷 업로드 안함 + skip_upload_changelogs: false, # Changelog 업로드 (metadata 디렉토리에서 자동 인식) + # release_status: 'draft', # ⚠️ Draft App(미출시)일 때 사용 - completed/프로덕션 승급 불가 + release_status: 'completed', # 정식 출시(Published App) 이후 completed로 즉시 배포 + version_name: ENV["VERSION_NAME"], # 버전 이름 + version_code: ENV["VERSION_CODE"] # 버전 코드 ) - puts "" - puts "=========================================" - puts "Successfully deployed to Internal Testing!" - puts "=========================================" - end + puts "✅ 내부 테스트 업로드 완료!" - desc "Validate Service Account JSON" - lane :validate do - json_key = ENV["GOOGLE_PLAY_JSON_KEY"] || "~/.config/gcloud/service-account.json" + unless promote + puts "" + puts "⏭️ store_only 모드 — 프로덕션 승급 건너뜀 (내부 테스트 업로드만 완료)" + next + end - validate_play_store_json_key( - json_key: json_key - ) + # ── Step 2: 내부 테스트 → 프로덕션 승급 ── + # store_submit : completed로 승급 → Google 심사 자동 등록 (완전 자동) + # store_prepare : draft로 승급 → Play Console에서 사람이 "출시 시작" 눌러야 심사 진행 + promote_status = submit ? 'completed' : 'draft' + puts "" + puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + puts "🚀 2단계: 프로덕션 트랙으로 승급 중... (release_status: #{promote_status})" + puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - puts "Service Account validation successful!" - end + promote_internal_to_production(promote_status: promote_status) - desc "Promote internal to beta" - lane :promote_to_beta do - json_key = ENV["GOOGLE_PLAY_JSON_KEY"] || "~/.config/gcloud/service-account.json" + puts "" + puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + if submit + puts "✅ 전체 배포 완료! 프로덕션: Google 심사 자동 등록됨 (심사 통과 후 전체 출시)" + else + puts "✅ 프로덕션 버전 준비 완료 (draft) — Play Console에서 '출시 시작'만 누르면 심사 진행됩니다." + end + puts "📦 버전: #{version_name} (#{version_code})" + puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + end + desc "Promote internal track to production (promote_status: completed=심사자동등록 | draft=출시직전대기)" + lane :promote_internal_to_production do |options| + # ⚠️ Draft App(미출시)일 때는 이 lane이 동작하지 않습니다. + # Play Console에서 최초 1회 수동 프로덕션 출시 후 사용 가능합니다. + # promote_status: 'completed' → 즉시 심사 등록 (store_submit) + # promote_status: 'draft' → 프로덕션 draft로만 올림, 사람이 콘솔에서 "출시 시작" (store_prepare) + status = options[:promote_status] || 'completed' upload_to_play_store( package_name: "{{APPLICATION_ID}}", - track: "internal", - track_promote_to: "beta", - json_key: json_key, + track: 'internal', + track_promote_to: 'production', + track_promote_release_status: status, # completed(심사등록) | draft(출시 직전 대기) + json_key: ENV["GOOGLE_PLAY_JSON_KEY"], skip_upload_apk: true, skip_upload_aab: true, skip_upload_metadata: true, skip_upload_images: true, - skip_upload_screenshots: true + skip_upload_screenshots: true, + skip_upload_changelogs: true, # 승급 시에는 changelog 업로드 불필요 (이미 Step 1에서 업로드됨) + rollout: '1.0', # 100% 전체 출시 (심사 통과 후 자동 적용) + changes_not_sent_for_review: false, # 변경을 Google 심사로 전송 (기본값이지만 의도 명시) + rescue_changes_not_sent_for_review: false # ⚠️ 거짓 성공 차단: 심사 자동제출 실패 시 조용히 넘어가지 않고 워크플로우를 실패시킨다 ) - - puts "Promoted from internal to beta!" end - desc "Promote beta to production" - lane :promote_to_production do + desc "Validate Play Store credentials" + lane :validate do json_key = ENV["GOOGLE_PLAY_JSON_KEY"] || "~/.config/gcloud/service-account.json" + validate_play_store_json_key(json_key: json_key) + end +end - upload_to_play_store( - package_name: "{{APPLICATION_ID}}", - track: "beta", - track_promote_to: "production", - json_key: json_key, - skip_upload_apk: true, - skip_upload_aab: true, - skip_upload_metadata: true, - skip_upload_images: true, - skip_upload_screenshots: true, - rollout: "0.1" # 10% rollout +def flutter_build(options) + Dir.chdir(options[:project_dir]) do + sh( + "flutter", + "build", + options[:build], + "--release", + "--no-tree-shake-icons", + "-v" ) - - puts "Promoted from beta to production (10% rollout)!" end end diff --git a/.github/util/flutter/testflight-wizard/templates/Fastfile.ios.template b/.github/util/flutter/testflight-wizard/templates/Fastfile.ios.template new file mode 100644 index 00000000..0f511741 --- /dev/null +++ b/.github/util/flutter/testflight-wizard/templates/Fastfile.ios.template @@ -0,0 +1,106 @@ +# ============================================================ +# iOS Fastfile (TestFlight 업로드 + App Store 심사 자동 제출) +# ============================================================ +# ★ 이 파일은 마법사(testflight-wizard-setup.sh)가 ios/fastlane/Fastfile 로 깔며, +# GitHub Actions 워크플로우(PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml)가 그대로 실행합니다. +# (Android의 Fastfile.playstore 와 동일한 "깐 파일 = 도는 파일" 구조) +# +# 배포 모드(DEPLOY_MODE) — 공통 네이밍 + iOS 하위호환 별칭 둘 다 인식: +# store_only (= testflight_only, 기본) : TestFlight 업로드까지만 +# store_prepare (= appstore_prepare) : TestFlight + ASC 버전 생성·빌드 선택·What's New 채움(제출 직전) +# store_submit (= appstore_submit) : 위 전부 + 심사 자동 제출(submit_for_review) +# +# ⚠️ 신규 앱은 최초 1회 App Store Connect에서 수동 제출이 필요합니다. +# 그 이후 업데이트부터 store_submit으로 완전 자동 제출됩니다. +# ============================================================ +default_platform(:ios) + +platform :ios do + desc "TestFlight 업로드 (+ DEPLOY_MODE=store_submit이면 App Store 심사 자동 제출)" + lane :deploy do + api_key = app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"], + key_filepath: ENV["API_KEY_PATH"] + ) + + app_id = ENV["APP_IDENTIFIER"] + UI.user_error!("APP_IDENTIFIER(번들 ID) 환경변수가 필요합니다.") if app_id.nil? || app_id.strip.empty? + + deploy_mode = (ENV["DEPLOY_MODE"] || "testflight_only").strip + prepare_appstore = ["appstore_prepare", "appstore_submit", "store_prepare", "store_submit"].include?(deploy_mode) + submit_to_appstore = ["appstore_submit", "store_submit"].include?(deploy_mode) + UI.message("🎛️ 배포 모드: #{deploy_mode} (ASC 버전 준비: #{prepare_appstore} / 심사 자동제출: #{submit_to_appstore})") + + # 1) TestFlight 업로드 (항상 수행). ASC 준비/제출 모드면 빌드 처리 완료까지 대기. + # ⚠️ Apple Processing(빌드 처리)은 보통 5~15분, 때때로 1시간+ (Apple 서버 사정, 통제 불가). + # store_prepare/store_submit은 이 대기에 종속된다. 급할 땐 store_only. + pilot( + api_key: api_key, + app_identifier: app_id, + ipa: ENV["IPA_PATH"], + changelog: ENV["RELEASE_NOTES"], + skip_waiting_for_build_processing: !prepare_appstore, + distribute_external: false, + notify_external_testers: false, + uses_non_exempt_encryption: false + ) + puts "✅ TESTFLIGHT 업로드 완료!" + + # 2) App Store 버전 준비 (+ store_submit이면 심사 자동 제출) + if prepare_appstore + app_ver = ENV["APP_VERSION"] + release_notes = (ENV["RELEASE_NOTES"] || "버그를 수정하고 안정성을 개선했습니다.").strip + release_notes = "버그를 수정하고 안정성을 개선했습니다." if release_notes.empty? + + # whatsNew 메타를 절대경로로 생성 (상대경로는 fastlane CWD와 어긋나 공란 → 제출 거부). + # release_notes.txt만 만들고 다른 메타는 안 만듦 → ASC 기존 메타(설명/키워드 등) 보존. + workspace = ENV["GITHUB_WORKSPACE"] || Dir.pwd + metadata_path = File.join(workspace, "ios", "fastlane", "metadata") + require "fileutils" + # ⚠️ 로케일 디렉토리명은 deliver가 인정하는 코드만 (ko 유효, ko-KR 무효). + locales = (ENV["DELIVER_LOCALES"] || "ko").split(",").map(&:strip).reject(&:empty?) + locales.each do |loc| + dir = File.join(metadata_path, loc) + FileUtils.mkdir_p(dir) + File.write(File.join(dir, "release_notes.txt"), release_notes) + end + UI.message("📝 whatsNew 메타 작성: #{release_notes}") + + # 심사 메모(Notes) 초기화: 이전 버전 거절 대응 소명서가 자동 승계되는 문제 방지. + # review_information/notes.txt만 공란으로 → 연락처·데모계정 등 다른 심사정보는 ASC 기존값 보존. + review_dir = File.join(metadata_path, "review_information") + FileUtils.mkdir_p(review_dir) + File.write(File.join(review_dir, "notes.txt"), "") + UI.message("🧹 심사 Notes 초기화 (review_information/notes.txt 공란)") + + UI.message("📂 metadata_path=#{metadata_path}") + + deliver( + api_key: api_key, + app_identifier: app_id, + app_version: app_ver, + build_number: ENV["BUILD_NUMBER"], + submit_for_review: submit_to_appstore, + automatic_release: submit_to_appstore, + force: true, + run_precheck_before_submit: false, + skip_binary_upload: true, + skip_metadata: false, + metadata_path: metadata_path, + skip_screenshots: true, + precheck_include_in_app_purchases: false, + submission_information: { + add_id_info_uses_idfa: false + } + ) + if submit_to_appstore + puts "✅ APP STORE 심사 자동 제출 완료!" + else + puts "✅ APP STORE 버전 준비 완료 (Prepare for Submission) — ASC에서 검토 후 'Add for Review'만 누르면 제출됩니다." + end + else + puts "⏭️ store_only(testflight_only) 모드 — App Store 단계 건너뜀 (TestFlight 업로드만 완료)" + end + end +end diff --git a/.github/util/flutter/testflight-wizard/testflight-wizard-setup.sh b/.github/util/flutter/testflight-wizard/testflight-wizard-setup.sh index ff14ff14..500f37b4 100644 --- a/.github/util/flutter/testflight-wizard/testflight-wizard-setup.sh +++ b/.github/util/flutter/testflight-wizard/testflight-wizard-setup.sh @@ -65,6 +65,27 @@ print_error() { echo -e "${RED}✗${NC} $1" } +# 크로스플랫폼 in-place sed. +# BSD sed(macOS): -i 뒤에 백업 접미사(빈 문자열 '')가 필수. +# GNU sed(Linux/Windows Git Bash): -i 뒤에 바로 식. '' 를 주면 식으로 오인해 깨진다. +# 첫 호출 시 1회만 OS 종류를 감지해 캐시한다. 사용법은 GNU 형태와 동일: sed_inplace 's/a/b/' file +SED_FLAVOR="" +sed_inplace() { + if [ -z "$SED_FLAVOR" ]; then + # BSD sed는 --version을 모른다(에러). GNU sed만 --version 성공. + if sed --version >/dev/null 2>&1; then + SED_FLAVOR="gnu" + else + SED_FLAVOR="bsd" + fi + fi + if [ "$SED_FLAVOR" = "gnu" ]; then + sed -i "$@" + else + sed -i '' "$@" + fi +} + # 도움말 show_help() { cat << EOF @@ -211,7 +232,7 @@ create_fastfile() { local fastlane_dir="$PROJECT_PATH/ios/fastlane" local fastfile_path="$fastlane_dir/Fastfile" - local template_fastfile="$TEMPLATE_DIR/Fastfile" + local template_fastfile="$TEMPLATE_DIR/Fastfile.ios.template" # fastlane 디렉토리 생성 mkdir -p "$fastlane_dir" @@ -328,10 +349,10 @@ update_info_plist_encryption() { print_info "ITSAppUsesNonExemptEncryption이 이미 설정되어 있습니다" # 기존 값을 업데이트 if [ "$USES_NON_EXEMPT_ENCRYPTION" = "true" ]; then - sed -i '' 's/ITSAppUsesNonExemptEncryption<\/key>[[:space:]]*/ITSAppUsesNonExemptEncryption<\/key>\ + sed_inplace 's/ITSAppUsesNonExemptEncryption<\/key>[[:space:]]*/ITSAppUsesNonExemptEncryption<\/key>\ /g' "$info_plist_path" else - sed -i '' 's/ITSAppUsesNonExemptEncryption<\/key>[[:space:]]*/ITSAppUsesNonExemptEncryption<\/key>\ + sed_inplace 's/ITSAppUsesNonExemptEncryption<\/key>[[:space:]]*/ITSAppUsesNonExemptEncryption<\/key>\ /g' "$info_plist_path" fi print_success "ITSAppUsesNonExemptEncryption 값 업데이트 완료" @@ -351,7 +372,7 @@ update_info_plist_encryption() { fi # macOS sed 사용 - 앞에 새 키 추가 - sed -i '' "s/<\/dict>/ITSAppUsesNonExemptEncryption<\/key>\\ + sed_inplace "s/<\/dict>/ITSAppUsesNonExemptEncryption<\/key>\\ <${encryption_value}\/>\\ <\/dict>/g" "$info_plist_path" @@ -404,12 +425,12 @@ update_bundle_id() { cp "$pbxproj_path" "${pbxproj_path}.bundleid.bak" # Runner 앱의 Bundle ID 변경 (정확히 매칭) - sed -i '' "s/PRODUCT_BUNDLE_IDENTIFIER = $CURRENT_BUNDLE_ID;/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;/g" "$pbxproj_path" + sed_inplace "s/PRODUCT_BUNDLE_IDENTIFIER = $CURRENT_BUNDLE_ID;/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;/g" "$pbxproj_path" # RunnerTests의 Bundle ID도 함께 변경 (Runner 앱의 Bundle ID + .RunnerTests) local CURRENT_TESTS_BUNDLE_ID="${CURRENT_BUNDLE_ID}.RunnerTests" local NEW_TESTS_BUNDLE_ID="${BUNDLE_ID}.RunnerTests" - sed -i '' "s/PRODUCT_BUNDLE_IDENTIFIER = $CURRENT_TESTS_BUNDLE_ID;/PRODUCT_BUNDLE_IDENTIFIER = $NEW_TESTS_BUNDLE_ID;/g" "$pbxproj_path" + sed_inplace "s/PRODUCT_BUNDLE_IDENTIFIER = $CURRENT_TESTS_BUNDLE_ID;/PRODUCT_BUNDLE_IDENTIFIER = $NEW_TESTS_BUNDLE_ID;/g" "$pbxproj_path" # 변경 확인 if grep -q "PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;" "$pbxproj_path"; then @@ -455,10 +476,10 @@ patch_xcode_project() { print_info "CODE_SIGN_STYLE = Manual 추가 중..." # Automatic을 Manual로 변경하거나 새로 추가 if grep -q "CODE_SIGN_STYLE = Automatic" "$pbxproj_path"; then - sed -i '' "s/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/g" "$pbxproj_path" + sed_inplace "s/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/g" "$pbxproj_path" else # DEVELOPMENT_TEAM 라인 다음에 CODE_SIGN_STYLE 추가 - sed -i '' "s/DEVELOPMENT_TEAM = $TEAM_ID;/DEVELOPMENT_TEAM = $TEAM_ID;\\ + sed_inplace "s/DEVELOPMENT_TEAM = $TEAM_ID;/DEVELOPMENT_TEAM = $TEAM_ID;\\ CODE_SIGN_STYLE = Manual;/g" "$pbxproj_path" fi print_success "CODE_SIGN_STYLE = Manual 설정 완료" @@ -466,7 +487,7 @@ patch_xcode_project() { # PROVISIONING_PROFILE_SPECIFIER 업데이트 if grep -q "PROVISIONING_PROFILE_SPECIFIER" "$pbxproj_path"; then - sed -i '' "s/\"PROVISIONING_PROFILE_SPECIFIER\" = \"[^\"]*\";/\"PROVISIONING_PROFILE_SPECIFIER\" = \"$PROFILE_NAME\";/g" "$pbxproj_path" + sed_inplace "s/\"PROVISIONING_PROFILE_SPECIFIER\" = \"[^\"]*\";/\"PROVISIONING_PROFILE_SPECIFIER\" = \"$PROFILE_NAME\";/g" "$pbxproj_path" print_success "PROVISIONING_PROFILE_SPECIFIER 업데이트 완료" fi @@ -478,40 +499,40 @@ patch_xcode_project() { # DEVELOPMENT_TEAM이 있지만 다른 값이면 교체 if grep -q "DEVELOPMENT_TEAM = " "$pbxproj_path"; then print_info "기존 DEVELOPMENT_TEAM 값을 업데이트합니다" - sed -i '' "s/DEVELOPMENT_TEAM = [^;]*;/DEVELOPMENT_TEAM = $TEAM_ID;/g" "$pbxproj_path" + sed_inplace "s/DEVELOPMENT_TEAM = [^;]*;/DEVELOPMENT_TEAM = $TEAM_ID;/g" "$pbxproj_path" print_success "DEVELOPMENT_TEAM 업데이트 완료" # CODE_SIGN_STYLE = Manual 설정 if grep -q "CODE_SIGN_STYLE = Automatic" "$pbxproj_path"; then - sed -i '' "s/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/g" "$pbxproj_path" + sed_inplace "s/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/g" "$pbxproj_path" print_success "CODE_SIGN_STYLE = Manual 설정 완료" elif ! grep -q "CODE_SIGN_STYLE = Manual" "$pbxproj_path"; then - sed -i '' "s/DEVELOPMENT_TEAM = $TEAM_ID;/DEVELOPMENT_TEAM = $TEAM_ID;\\ + sed_inplace "s/DEVELOPMENT_TEAM = $TEAM_ID;/DEVELOPMENT_TEAM = $TEAM_ID;\\ CODE_SIGN_STYLE = Manual;/g" "$pbxproj_path" print_success "CODE_SIGN_STYLE = Manual 추가 완료" fi # CODE_SIGN_IDENTITY 설정 if ! grep -q 'CODE_SIGN_IDENTITY = "Apple Distribution"' "$pbxproj_path"; then - sed -i '' "s/CODE_SIGN_STYLE = Manual;/CODE_SIGN_STYLE = Manual;\\ + sed_inplace "s/CODE_SIGN_STYLE = Manual;/CODE_SIGN_STYLE = Manual;\\ CODE_SIGN_IDENTITY = \"Apple Distribution\";/g" "$pbxproj_path" print_success "CODE_SIGN_IDENTITY = Apple Distribution 추가 완료" fi # PROVISIONING_PROFILE_SPECIFIER 설정 (핵심!) if ! grep -q "PROVISIONING_PROFILE_SPECIFIER" "$pbxproj_path"; then - sed -i '' "s/CODE_SIGN_IDENTITY = \"Apple Distribution\";/CODE_SIGN_IDENTITY = \"Apple Distribution\";\\ + sed_inplace "s/CODE_SIGN_IDENTITY = \"Apple Distribution\";/CODE_SIGN_IDENTITY = \"Apple Distribution\";\\ \"PROVISIONING_PROFILE_SPECIFIER\" = \"$PROFILE_NAME\";/g" "$pbxproj_path" print_success "PROVISIONING_PROFILE_SPECIFIER = $PROFILE_NAME 추가 완료" else # 기존 값이 있으면 업데이트 - sed -i '' "s/\"PROVISIONING_PROFILE_SPECIFIER\" = \"[^\"]*\";/\"PROVISIONING_PROFILE_SPECIFIER\" = \"$PROFILE_NAME\";/g" "$pbxproj_path" + sed_inplace "s/\"PROVISIONING_PROFILE_SPECIFIER\" = \"[^\"]*\";/\"PROVISIONING_PROFILE_SPECIFIER\" = \"$PROFILE_NAME\";/g" "$pbxproj_path" print_success "PROVISIONING_PROFILE_SPECIFIER 업데이트 완료" fi # 구버전 CODE_SIGN_IDENTITY 설정 업데이트 if grep -q '"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = "iPhone Developer"' "$pbxproj_path"; then - sed -i '' 's/"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = "iPhone Developer"/"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"/g' "$pbxproj_path" + sed_inplace 's/"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = "iPhone Developer"/"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"/g' "$pbxproj_path" print_success "CODE_SIGN_IDENTITY[sdk=iphoneos*] 업데이트 완료" fi @@ -551,7 +572,7 @@ patch_xcode_project() { # - CODE_SIGN_STYLE: Manual (자동 서명 비활성화) # - CODE_SIGN_IDENTITY: Apple Distribution (배포용 인증서) # - PROVISIONING_PROFILE_SPECIFIER: 프로비저닝 프로파일 이름 - sed -i '' "s/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;\\ + sed_inplace "s/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_ID;\\ DEVELOPMENT_TEAM = $TEAM_ID;\\ CODE_SIGN_STYLE = Manual;\\ CODE_SIGN_IDENTITY = \"Apple Distribution\";\\ @@ -559,7 +580,7 @@ patch_xcode_project() { # 구버전 CODE_SIGN_IDENTITY 설정이 있으면 Apple Distribution으로 변경 if grep -q '"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = "iPhone Developer"' "$pbxproj_path"; then - sed -i '' 's/"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = "iPhone Developer"/"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"/g' "$pbxproj_path" + sed_inplace 's/"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = "iPhone Developer"/"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"/g' "$pbxproj_path" print_success "CODE_SIGN_IDENTITY[sdk=iphoneos*] 업데이트 완료" fi @@ -643,6 +664,12 @@ print_completion() { echo "" echo " 3. deploy 브랜치로 푸시하여 빌드 테스트" echo "" + echo "🎛️ 배포 모드 설정 (선택):" + echo " GitHub repo Variables에 IOS_DEPLOY_MODE 를 설정하면 기본 배포 범위를 정할 수 있습니다." + echo " store_only : TestFlight 업로드까지만 (기본)" + echo " store_prepare : App Store 제출 직전까지 (사람이 ASC에서 Add for Review)" + echo " store_submit : App Store 심사 자동 제출 (정식 출시 1회 수동 이후부터 가능)" + echo " 워크플로우 수동 실행 시 deploy_mode 입력이 이 변수보다 우선합니다." } # =================================================================== diff --git a/.github/util/flutter/testflight-wizard/testflight-wizard.html b/.github/util/flutter/testflight-wizard/testflight-wizard.html index fed081e9..1f446ce5 100644 --- a/.github/util/flutter/testflight-wizard/testflight-wizard.html +++ b/.github/util/flutter/testflight-wizard/testflight-wizard.html @@ -1674,6 +1674,17 @@

+
+

🎛️ 배포 모드 & 출시 로드맵 (iOS)

+

GitHub repo Variables → IOS_DEPLOY_MODE 로 기본 배포 범위를 정합니다. 워크플로우 수동 실행 시 deploy_mode 입력이 우선합니다.

+ +

⚠️ 신규 앱은 최초 1회 App Store Connect에서 수동 제출이 필요하고, 이후 업데이트부터 store_submit으로 자동화됩니다.

+
+
diff --git a/.github/workflows/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml b/.github/workflows/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml index 5e8d0fce..4dc64352 100644 --- a/.github/workflows/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +++ b/.github/workflows/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml @@ -32,7 +32,7 @@ # # 🔧 프로젝트별 설정 (env 섹션에서 수정) # =================================================================== -# PROJECT_NAME : 프로젝트 이름 +# APP_ARTIFACT_NAME : 프로젝트 이름 # FLUTTER_VERSION : Flutter SDK 버전 # JAVA_VERSION : Java 버전 # FIREBASE_APP_ID : Firebase 앱 ID (Firebase Console에서 확인) @@ -63,7 +63,7 @@ env: # 🔧 프로젝트별 설정 (아래 값들을 수정하세요) # ============================================ # 프로젝트 이름 - PROJECT_NAME: "your-project" + APP_ARTIFACT_NAME: "passQL" # Flutter/Java 버전 FLUTTER_VERSION: "3.35.5" JAVA_VERSION: "17" diff --git a/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml b/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml index 1fa5a898..7fa81566 100644 --- a/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +++ b/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml @@ -27,22 +27,40 @@ on: types: [completed] branches: [main] workflow_dispatch: + inputs: + deploy_mode: + description: "배포 모드 (store_only=internal까지 / store_prepare=production draft / store_submit=심사 자동등록)" + required: false + default: "store_only" + type: choice + options: + - store_only + - store_prepare + - store_submit # ============================================ # 🔧 프로젝트별 설정 (아래 값들을 수정하세요) # ============================================ env: # 프로젝트 이름 - PROJECT_NAME: "your-project" + APP_ARTIFACT_NAME: "passQL" # Flutter/Java 버전 FLUTTER_VERSION: "3.35.5" JAVA_VERSION: "17" PROJECT_TYPE: "flutter" + # Flutter 루트 경로(레포 루트 기준). 단일레포면 ".", 모노레포면 "app" 등. + # 통합 시점에 integrator의 flutter-root resolver가 project_paths.flutter 값으로 1회 치환. + FLUTTER_ROOT: "app" + # 배포 모드 (store_only | store_prepare | store_submit) + DEPLOY_MODE: ${{ github.event.inputs.deploy_mode || vars.ANDROID_DEPLOY_MODE || 'store_only' }} jobs: prepare-build: name: 환경 설정 및 준비 runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ env.FLUTTER_ROOT }} # CHANGELOG 워크플로우가 성공한 경우에만 실행 if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name != 'workflow_run' }} @@ -86,6 +104,7 @@ jobs: # 버전 관리 스크립트 권한 설정 - name: 버전 관리 스크립트 권한 설정 + working-directory: ${{ github.workspace }} # 레포 루트 자산(.github/scripts)은 FLUTTER_ROOT가 아닌 레포 루트 기준 run: | chmod +x ./.github/scripts/version_manager.sh chmod +x ./.github/scripts/changelog_manager.py @@ -93,6 +112,7 @@ jobs: # version_manager.sh를 사용하여 현재 버전 정보 가져오기 - name: 현재 버전 정보 가져오기 id: current_version + working-directory: ${{ github.workspace }} # version_manager.sh가 레포 루트 version.yml을 읽어야 함 run: | # VERSION 가져오기 (x.y.z 형식) VERSION=$(./.github/scripts/version_manager.sh get | tail -n 1) @@ -120,6 +140,7 @@ jobs: # CHANGELOG.json에서 릴리즈 노트 추출 - changelog_manager.py 사용 - name: 릴리즈 노트 생성 id: release_notes + working-directory: ${{ github.workspace }} # CHANGELOG.json·changelog_manager.py·final_release_notes.txt(upload 짝)는 레포 루트 기준 run: | VERSION="${{ steps.current_version.outputs.version }}" @@ -173,6 +194,9 @@ jobs: name: Android AAB 빌드 runs-on: ubuntu-latest needs: prepare-build + defaults: + run: + working-directory: ${{ env.FLUTTER_ROOT }} steps: - name: Checkout repository @@ -489,13 +513,16 @@ jobs: uses: actions/upload-artifact@v4 with: name: android-aab - path: build/app/outputs/bundle/release/app-release.aab + path: ${{ env.FLUTTER_ROOT }}/build/app/outputs/bundle/release/app-release.aab retention-days: 1 deploy-playstore: name: Play Store 내부 테스트 배포 runs-on: ubuntu-latest needs: [prepare-build, build-android] + defaults: + run: + working-directory: ${{ env.FLUTTER_ROOT }} steps: - name: Checkout repository @@ -516,7 +543,7 @@ jobs: uses: actions/download-artifact@v4 with: name: android-aab - path: build/app/outputs/bundle/release/ + path: ${{ env.FLUTTER_ROOT }}/build/app/outputs/bundle/release/ # 릴리즈 노트 다운로드 - name: Download release notes @@ -532,7 +559,7 @@ jobs: ruby-version: "3.4.1" - name: Install Fastlane - working-directory: android + working-directory: ${{ env.FLUTTER_ROOT }}/android run: | # multi_json: google-apis transitive 의존성이 선언 누락한 upstream 버그 회피 (gemspec 미선언 → Gem::LoadError) printf 'source "https://rubygems.org"\ngem "fastlane"\ngem "multi_json"\n' > Gemfile @@ -552,6 +579,8 @@ jobs: # Play Store에 업로드 - name: Upload to Play Store Internal Testing + env: + DEPLOY_MODE: ${{ env.DEPLOY_MODE }} run: | echo "==========================================" echo "🚀 Play Store 배포 시작" @@ -629,10 +658,11 @@ jobs: CHANGELOG_DIR="android/fastlane/metadata/android/ko-KR/changelogs" mkdir -p "$CHANGELOG_DIR" - if [ -f "final_release_notes.txt" ]; then + if [ -f "$GITHUB_WORKSPACE/final_release_notes.txt" ]; then # Google Play release notes 한도(500 글자)에 맞춰 절단 (마진 두어 480자) - bash ./.github/scripts/truncate_release_notes.sh final_release_notes.txt 480 char - cp final_release_notes.txt "$CHANGELOG_DIR/${VERSION_CODE}.txt" + # final_release_notes.txt·truncate 스크립트는 레포 루트 기준 — working-directory가 FLUTTER_ROOT이므로 절대경로 사용 + bash "$GITHUB_WORKSPACE/.github/scripts/truncate_release_notes.sh" "$GITHUB_WORKSPACE/final_release_notes.txt" 480 char + cp "$GITHUB_WORKSPACE/final_release_notes.txt" "$CHANGELOG_DIR/${VERSION_CODE}.txt" echo "✅ Changelog 준비 완료 (버전 코드: $VERSION_CODE)" else echo "v$VERSION_NAME 업데이트" > "$CHANGELOG_DIR/${VERSION_CODE}.txt" diff --git a/.github/workflows/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml b/.github/workflows/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml new file mode 100644 index 00000000..80e351ad --- /dev/null +++ b/.github/workflows/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml @@ -0,0 +1,314 @@ +# =================================================================== +# Flutter Android 자체 서버(SMB) 배포 워크플로우 +# =================================================================== +# +# deploy 브랜치 푸시 시 Android APK를 빌드하고 +# 자체 서버(예: Synology NAS·NAS·Linux)의 SMB 공유에 APK를 배포합니다. +# +# =================================================================== +# 🔑 필수 GitHub Secrets +# =================================================================== +# ENV_FILE 또는 ENV: .env 파일 내용 (둘 중 하나, ENV_FILE 우선) +# RELEASE_KEYSTORE_BASE64: 서명용 keystore (base64) +# RELEASE_KEYSTORE_PASSWORD: keystore 비밀번호 +# RELEASE_KEY_ALIAS: key alias +# RELEASE_KEY_PASSWORD: key 비밀번호 +# GOOGLE_SERVICES_JSON (선택): Firebase google-services.json +# SERVER_HOST: 서버 호스트(SMB 접속 주소) +# SERVER_USER: SMB 사용자명 +# SERVER_PASSWORD: SMB 비밀번호 +# =================================================================== + +name: PROJECT-Android-SelfHosted-CICD + +on: + # ⚠️ Flutter 앱은 아직 개발 중 — deploy마다 APK 빌드가 돌지 않도록 + # 자동 트리거 비활성화 (앱 배포 시작 시 주석 해제) + # push: + # branches: ["deploy"] + # workflow_run: + # workflows: ["CHANGELOG 자동 업데이트"] + # types: [completed] + # branches: [main] + workflow_dispatch: # 수동 실행 옵션 추가 + +# ============================================ +# 🔧 프로젝트별 설정 (아래 값들을 수정하세요) +# ============================================ +env: + # 프로젝트 이름 (APK 파일명, 아티팩트명, 히스토리 파일명에 사용) + APP_ARTIFACT_NAME: "passQL" + FLUTTER_VERSION: "3.35.5" + JAVA_VERSION: "17" + # SMB 서버 설정 (자체 서버 — Synology NAS·Linux 등) + SMB_PORT: "44445" # 기본 445 + SMB_SHARE: "web" + SMB_PATH_IOS: "/your-project/ios/download" # SMB 경로: /{APP_ARTIFACT_NAME}/ios/download 형식으로 사용 +jobs: + build-android: + name: Build Android APK + runs-on: ubuntu-latest + # CHANGELOG 워크플로우가 성공한 경우에만 실행 + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name != 'workflow_run' }} + + steps: + # Repository 체크아웃 및 목록 출력 + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: List repository contents + run: | + echo "Repository checked out" + ls -la + + # Debug Keystore 설정 (디버깅 용도 유지) + - name: Setup Debug Keystore + run: | + mkdir -p ~/.android + echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > ~/.android/debug.keystore || echo "Base64 decoding failed" + echo "Debug Keystore created (for debugging)" + ls -la ~/.android/ + keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android || echo "Keytool failed" + + # .env 파일 생성 + - name: Create .env file from GitHub Secret + env: + ENV_CONTENT: ${{ secrets.ENV_FILE || secrets.ENV }} + run: | + printf '%s\n' "$ENV_CONTENT" > .env + echo ".env file created" + ls -la + + # Keystore와 key.properties 설정 + - name: Setup Keystore and key.properties + run: | + mkdir -p android/app/keystore + echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > android/app/keystore/key.jks || echo "Base64 decoding failed" + echo "Keystore created from DEBUG_KEYSTORE" + ls -la android/app/keystore + echo "storeFile=app/keystore/key.jks" > android/key.properties + echo "storePassword=android" >> android/key.properties + echo "keyAlias=androiddebugkey" >> android/key.properties + echo "keyPassword=android" >> android/key.properties + echo "key.properties created" + ls -la android/ + + # Google-services.json 생성 + - name: Create Google-services.json + run: | + cat << 'EOF' > android/app/google-services.json + ${{ secrets.GOOGLE_SERVICES_JSON }} + EOF + echo "✅ Google-services.json 생성 완료" + ls -la android/app/ + + # Flutter 설정 및 버전 확인 + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + cache-key: flutter-${{ runner.os }}-${{ env.FLUTTER_VERSION }} + + - name: Verify Flutter version + run: | + echo "Flutter setup completed" + flutter --version + + # Flutter 및 Gradle 캐시 + - 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: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # 프로젝트 의존성 설치 + - name: Install dependencies + run: | + flutter pub get + echo "Dependencies installed" + ls -la + + # Gradle 셋업 + - name: Setup Gradle + working-directory: android + run: | + chmod +x gradlew + echo "Gradle wrapper permissions set" + + # Java 설정 및 버전 확인 + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Verify Java version + run: | + echo "Java setup completed" + java -version + + # Ruby 설정 및 확인 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.1" + + - name: Verify Ruby version + run: | + echo "Ruby setup completed" + ruby -v + + # Fastlane 설치 (Bundler 방식 - gem 충돌 방지) + - name: Install Fastlane + working-directory: android + run: | + # multi_json: google-apis transitive 의존성이 선언 누락한 upstream 버그 회피 (gemspec 미선언 → Gem::LoadError) + printf 'source "https://rubygems.org"\ngem "fastlane"\ngem "multi_json"\n' > Gemfile + bundle install + echo "Fastlane installed (Bundler)" + bundle exec fastlane --version + + # 현재 버전 정보 및 짧은 커밋 해시 계산 + - name: Get version info and calculate short commit hash + id: version_info + run: | + CURRENT_VERSION=$(grep 'version:' pubspec.yaml | sed 's/version: //' | tr -d ' ') + VERSION=$(echo $CURRENT_VERSION | cut -d'+' -f1) + BUILD_NUMBER=$(echo $CURRENT_VERSION | cut -d'+' -f2) + SHORT_COMMIT_HASH=$(echo ${{ github.sha }} | cut -c1-7) + + echo "CURRENT_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV + echo "SHORT_COMMIT_HASH=$SHORT_COMMIT_HASH" >> $GITHUB_ENV + + echo "현재 버전: $CURRENT_VERSION" + echo "짧은 커밋 해시: $SHORT_COMMIT_HASH" + + # Fastlane을 이용하여 APK 빌드 (릴리스 빌드 유지) + - name: Build APK with Fastlane + run: | + cd android + bundle exec fastlane build --verbose + ls -la ../build/app/outputs/flutter-apk/ || true + echo "APK built with Fastlane" + + # APK 파일 이름 변경 및 준비 + - name: Rename and Prepare APK + run: | + mkdir -p ./android/app/build/outputs/apk/release/ + mv ./build/app/outputs/flutter-apk/app-release.apk ./android/app/build/outputs/apk/release/${{ env.APP_ARTIFACT_NAME }}-v${{ env.VERSION }}-${{ env.SHORT_COMMIT_HASH }}.apk + echo "APK renamed to ${{ env.APP_ARTIFACT_NAME }}-v${{ env.VERSION }}-${{ env.SHORT_COMMIT_HASH }}.apk" + ls -la ./android/app/build/outputs/apk/release/ + + # APK를 아티팩트로 업로드 + - name: Upload APK as Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.APP_ARTIFACT_NAME }}-apk + path: ./android/app/build/outputs/apk/release/${{ env.APP_ARTIFACT_NAME }}-v${{ env.VERSION }}-${{ env.SHORT_COMMIT_HASH }}.apk + retention-days: 1 + + deploy-android: + name: Deploy Android APK + runs-on: ubuntu-latest + needs: build-android + if: github.ref == 'refs/heads/deploy' + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: List repository contents for deploy + run: | + echo "Repository checked out for deploy" + ls -la + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y smbclient jq + echo "SMB and jq dependencies installed" + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ env.APP_ARTIFACT_NAME }}-apk + path: android/app/build/outputs/ + + - name: List downloaded structure (Debug) + run: | + ls -alR android/app/build/outputs + echo "Downloaded structure listed" + + - name: Find APK file + run: | + APK_FILE=$(find android/app/build/outputs/ -name "*.apk" | head -n 1) + echo "APK_FILE=$APK_FILE" >> $GITHUB_ENV + APK_NAME=$(basename "$APK_FILE") + echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV + echo "APK file found: $APK_FILE" + + - name: Upload files to server via SMB + env: + SMB_USERNAME: ${{ secrets.SERVER_USER }} + SMB_PASSWORD: ${{ secrets.SERVER_PASSWORD }} + run: | + SMB_SERVER=${{ secrets.SERVER_HOST }} + SMB_PORT=${{ env.SMB_PORT }} + SMB_SHARE=${{ env.SMB_SHARE }} + SMB_PATH="/${{ env.APP_ARTIFACT_NAME }}${{ env.SMB_PATH_ANDROID }}" + echo "Uploading APK file ${{ env.APK_NAME }} to SMB..." + echo "SMB Path: $SMB_PATH" + smbclient "//$SMB_SERVER/$SMB_SHARE" -p "$SMB_PORT" -U "$SMB_USERNAME%$SMB_PASSWORD" -m SMB3 -d 10 -c "mkdir $SMB_PATH; cd $SMB_PATH; put ${{ env.APK_FILE }} ${{ env.APK_NAME }}" + echo "APK uploaded to SMB" + + - name: Update CICD history JSON + env: + SMB_USERNAME: ${{ secrets.SERVER_USER }} + SMB_PASSWORD: ${{ secrets.SERVER_PASSWORD }} + run: | + export TZ='Asia/Seoul' + BUILD_DATE=$(date +"%Y-%m-%d %H:%M") + FILE_SIZE=$(stat -c%s "${{ env.APK_FILE }}") + COMMIT_LINK="https://github.com/${{ github.repository }}/commit/${{ github.sha }}" + FULL_COMMIT_HASH="${{ github.sha }}" + HISTORY_FILE="${{ env.APP_ARTIFACT_NAME }}-android-cicd-history.json" + + NEW_BUILD_INFO=$(jq -n \ + --arg apk_name "${{ env.APK_NAME }}" \ + --arg file_size "$FILE_SIZE" \ + --arg build_date "$BUILD_DATE" \ + --arg commit_link "$COMMIT_LINK" \ + --arg full_commit_hash "$FULL_COMMIT_HASH" \ + '{apk_name: $apk_name, file_size: $file_size, build_date: $build_date, commit_link: $commit_link, full_commit_hash: $full_commit_hash}') + + SMB_SERVER=${{ secrets.SERVER_HOST }} + SMB_PORT=${{ env.SMB_PORT }} + SMB_SHARE=${{ env.SMB_SHARE }} + SMB_PATH="/${{ env.APP_ARTIFACT_NAME }}${{ env.SMB_PATH_ANDROID }}" + + smbclient "//$SMB_SERVER/$SMB_SHARE" -p "$SMB_PORT" -U "$SMB_USERNAME%$SMB_PASSWORD" -m SMB3 -d 10 -c "cd $SMB_PATH; get $HISTORY_FILE" || echo '{"files": []}' > $HISTORY_FILE + echo "Downloaded or initialized $HISTORY_FILE" + cat $HISTORY_FILE + + jq --argjson new_build "$NEW_BUILD_INFO" '.files += [$new_build]' $HISTORY_FILE > updated.json + echo "Updated $HISTORY_FILE with new build info" + cat updated.json + + smbclient "//$SMB_SERVER/$SMB_SHARE" -p "$SMB_PORT" -U "$SMB_USERNAME%$SMB_PASSWORD" -m SMB3 -d 10 -c "cd $SMB_PATH; put updated.json $HISTORY_FILE" + echo "Updated $HISTORY_FILE uploaded to SMB" diff --git a/.github/workflows/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml b/.github/workflows/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml index aa7724ad..74891849 100644 --- a/.github/workflows/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +++ b/.github/workflows/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml @@ -14,7 +14,7 @@ # 1. flutter build ios --no-codesign (Flutter 빌드) # 2. xcodebuild archive (Xcode 아카이브 생성) # 3. xcodebuild -exportArchive (IPA 생성, ExportOptions.plist 사용) -# 4. fastlane upload_testflight (마법사 생성 Fastfile 사용) +# 4. fastlane deploy (마법사 생성 Fastfile 사용, DEPLOY_MODE에 따라 TestFlight/심사 제출) # # =================================================================== # 🔑 필수 GitHub Secrets @@ -63,11 +63,22 @@ on: types: [completed] branches: [main] workflow_dispatch: + inputs: + deploy_mode: + description: "배포 모드 (store_only=TestFlight까지 / store_prepare=ASC 제출직전 / store_submit=심사 자동제출)" + required: false + default: "store_only" + type: choice + options: + - store_only + - store_prepare + - store_submit # ============================================ # 🔧 프로젝트별 설정 (아래 값들을 수정하세요) # ============================================ env: + FLUTTER_ROOT: "app" FLUTTER_VERSION: "3.35.5" XCODE_VERSION: "26.0" PROJECT_TYPE: "flutter" @@ -83,10 +94,18 @@ env: # ============================================ SKIP_WAITING_FOR_BUILD_PROCESSING: "false" + # ============================================ + # 📝 배포 모드 (store_only | store_prepare | store_submit) + # ============================================ + DEPLOY_MODE: ${{ github.event.inputs.deploy_mode || vars.IOS_DEPLOY_MODE || 'store_only' }} + jobs: prepare-build: name: 환경 설정 및 준비 runs-on: macos-26 + defaults: + run: + working-directory: ${{ env.FLUTTER_ROOT }} if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name != 'workflow_run' }} outputs: @@ -152,12 +171,14 @@ jobs: cd ios && pod install - name: 버전 관리 스크립트 권한 설정 + working-directory: ${{ github.workspace }} # 레포 루트 자산(.github/scripts)은 FLUTTER_ROOT가 아닌 레포 루트 기준 run: | chmod +x ./.github/scripts/version_manager.sh chmod +x ./.github/scripts/changelog_manager.py - name: 현재 버전 정보 가져오기 id: current_version + working-directory: ${{ github.workspace }} # version_manager.sh가 레포 루트 version.yml을 읽어야 함 run: | VERSION=$(./.github/scripts/version_manager.sh get | tail -n 1) BUILD_NUMBER=$(./.github/scripts/version_manager.sh get-code | tail -n 1) @@ -170,6 +191,7 @@ jobs: - name: 릴리즈 노트 생성 id: release_notes + working-directory: ${{ github.workspace }} # CHANGELOG.json·changelog_manager.py·final_release_notes.txt(upload 짝)는 레포 루트 기준 run: | VERSION="${{ steps.current_version.outputs.version }}" @@ -213,6 +235,9 @@ jobs: build-ios: name: iOS 앱 빌드 runs-on: macos-26 + defaults: + run: + working-directory: ${{ env.FLUTTER_ROOT }} needs: prepare-build steps: @@ -345,7 +370,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: ios-ipa - path: ios/build/ipa/*.ipa + path: ${{ env.FLUTTER_ROOT }}/ios/build/ipa/*.ipa retention-days: 1 # ============================================ @@ -354,6 +379,9 @@ jobs: deploy-testflight: name: TestFlight 배포 runs-on: macos-26 + defaults: + run: + working-directory: ${{ env.FLUTTER_ROOT }} needs: [prepare-build, build-ios] steps: @@ -372,7 +400,7 @@ jobs: uses: actions/download-artifact@v4 with: name: ios-ipa - path: ios/build/ipa/ + path: ${{ env.FLUTTER_ROOT }}/ios/build/ipa/ - name: Download release notes uses: actions/download-artifact@v4 @@ -391,34 +419,32 @@ jobs: echo "✅ Fastfile 확인됨: ios/fastlane/Fastfile" - name: Install Fastlane - working-directory: ios + working-directory: ${{ env.FLUTTER_ROOT }}/ios run: | # multi_json: google-apis transitive 의존성이 선언 누락한 upstream 버그 회피 (gemspec 미선언 → Gem::LoadError) printf 'source "https://rubygems.org"\ngem "fastlane"\ngem "multi_json"\n' > Gemfile bundle install echo "✅ Fastlane installed (Bundler)" - - name: Upload to TestFlight + - name: Upload to TestFlight (+ App Store Submit if store_submit) 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 }} SKIP_WAITING_FOR_BUILD_PROCESSING: ${{ env.SKIP_WAITING_FOR_BUILD_PROCESSING }} run: | - # IPA 파일 찾기 - IPA_PATH=$(find $GITHUB_WORKSPACE/ios/build/ipa -name "*.ipa" | head -1) + # IPA 절대경로 탐색 + IPA_PATH=$(find "$GITHUB_WORKSPACE/${FLUTTER_ROOT}/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 + echo "❌ IPA 파일을 찾을 수 없습니다!"; ls -la ios/build/ipa/ || true; exit 1 fi - # Release notes 준비 - if [ -f "final_release_notes.txt" ]; then + # Release notes 준비 (final_release_notes.txt·truncate 스크립트는 레포 루트 기준 — working-directory가 FLUTTER_ROOT이므로 절대경로 사용) + if [ -f "$GITHUB_WORKSPACE/final_release_notes.txt" ]; then # App Store Connect "What to Test" 한도(4000 바이트)에 맞춰 절단 (마진 두어 3800byte) - bash ./.github/scripts/truncate_release_notes.sh final_release_notes.txt 3800 byte - RELEASE_NOTES=$(cat final_release_notes.txt) + bash "$GITHUB_WORKSPACE/.github/scripts/truncate_release_notes.sh" "$GITHUB_WORKSPACE/final_release_notes.txt" 3800 byte + RELEASE_NOTES=$(cat "$GITHUB_WORKSPACE/final_release_notes.txt") echo "📝 Release Notes:" echo "$RELEASE_NOTES" else @@ -426,19 +452,19 @@ jobs: echo "📝 기본 Release Notes: $RELEASE_NOTES" fi - # 환경변수 설정 (Fastfile에서 사용) + export APP_STORE_CONNECT_API_KEY_ID="${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" + export APP_STORE_CONNECT_ISSUER_ID="${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" 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" + export APP_IDENTIFIER="${{ secrets.IOS_BUNDLE_ID || vars.IOS_BUNDLE_ID }}" + export DEPLOY_MODE="${{ env.DEPLOY_MODE }}" + export APP_VERSION="${{ needs.prepare-build.outputs.version }}" + export BUILD_NUMBER="${{ needs.prepare-build.outputs.build_number }}" - # 디버깅 정보 - echo "🔧 Working directory: $(pwd)" - echo "🔧 IPA_PATH: $IPA_PATH" - echo "🔧 API_KEY_PATH: $API_KEY_PATH" - - # Fastlane 실행 (마법사가 생성한 Fastfile 사용) + echo "🔧 DEPLOY_MODE: $DEPLOY_MODE / APP_IDENTIFIER: $APP_IDENTIFIER" cd ios - bundle exec fastlane upload_testflight + bundle exec fastlane deploy - name: Notify TestFlight Upload Success if: success() diff --git a/.github/workflows/PROJECT-PYTHON-CI.yaml b/.github/workflows/PROJECT-PYTHON-CI.yaml index 80a90a6c..14e7798c 100644 --- a/.github/workflows/PROJECT-PYTHON-CI.yaml +++ b/.github/workflows/PROJECT-PYTHON-CI.yaml @@ -27,7 +27,7 @@ on: # =================================================================== env: # 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용) - PROJECT_NAME: "프로젝트명-ai" + PROJECT_NAME: "passQL" # 🐍 Python 설정 PYTHON_VERSION: "3.13" diff --git a/.github/workflows/PROJECT-PYTHON-PR-PREVIEW.yaml b/.github/workflows/PROJECT-PYTHON-PR-PREVIEW.yaml new file mode 100644 index 00000000..6ee9f038 --- /dev/null +++ b/.github/workflows/PROJECT-PYTHON-PR-PREVIEW.yaml @@ -0,0 +1,2191 @@ +# =================================================================== +# Python/FastAPI PR Preview (SSH + Docker + Traefik) +# =================================================================== +# +# PR 또는 Issue 코멘트 명령어를 통해 Preview 환경을 자동으로 관리합니다. +# Traefik 리버스 프록시를 통해 동적 라우팅됩니다. +# +# =================================================================== +# 🔑 필수 GitHub Secrets +# =================================================================== +# ENV_FILE: .env 파일 전체 내용 +# DOCKERHUB_USERNAME: Docker Hub 사용자명 +# DOCKERHUB_TOKEN: Docker Hub 액세스 토큰 +# SERVER_HOST: 서버 호스트(SSH 접속 주소) +# SERVER_USER: SSH 사용자명 +# SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때) +# SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등) +# =================================================================== +# +# 🚀 사용법: +# @suh-lab server build - Preview 빌드 및 배포 +# @suh-lab server destroy - Preview 환경 삭제 +# @suh-lab server status - 현재 상태 확인 +# +# 📊 리소스 네이밍: +# - 컨테이너: {PROJECT_NAME}-pr-{번호} +# - 도메인: {PROJECT_NAME}-pr-{번호}.pr.suhsaechan.kr +# +# 📋 사전 요구사항: +# - Traefik 컨테이너 실행 중 (traefik-network) +# - 와일드카드 DNS 설정: *.pr.suhsaechan.kr → 서버 +# =================================================================== + +name: PROJECT-PYTHON-PR-PREVIEW + +# =================================================================== +# ⚠️ [영역 1] 프로젝트별 설정 - 다른 프로젝트에서 사용 시 이 섹션만 수정하세요 +# =================================================================== +env: + # 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용) + PROJECT_NAME: "passQL" + + # Docker 설정 + DOCKERFILE_PATH: './Dockerfile' + INTERNAL_PORT: '8000' + + # 🗂️ 볼륨 마운트 설정 (프로젝트별 수정 필요, 빈값이면 마운트 안함) + PROJECT_TARGET_DIR: "/volume1/projects/프로젝트명/ai" # 서버의 데이터 디렉토리 + PROJECT_MNT_DIR: "/mnt/프로젝트명" # 컨테이너 내부 마운트 경로 + + # Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지) + TRAEFIK_NETWORK: traefik-network + PREVIEW_DOMAIN_SUFFIX: pr.suhsaechan.kr + PREVIEW_PORT: '8079' + + # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem) + # password → SERVER_PASSWORD secret 사용 + # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로) + SSH_AUTH_METHOD: "password" + + # SSH 포트 (서버 포트 환경 구축 후 수정 금지) + SSH_PORT: '2022' + + # Issue Helper 댓글 마커 (프로젝트별 수정 필요) + # Issue 댓글에서 브랜치명을 추출할 때 사용 + ISSUE_HELPER_MARKER: 'Guide by SUH-LAB' + + # Health Check 설정 (프로젝트별 수정 필요) + # - HEALTH_CHECK_PATH: HTTP Health Check 경로 (빈값이면 HTTP 체크 건너뜀) + # - HEALTH_CHECK_LOG_PATTERN: 로그 패턴 매칭 (HTTP 실패 시 폴백) + # - API_DOCS_PATH: API 문서 URL (빈값이면 배포 코멘트에 미표시) + # + # 프레임워크별 기본값 예시: + # FastAPI: '/docs', 'Uvicorn running on|Application startup complete' + # Spring Boot: '/actuator/health', 'Started.*Application|Tomcat started on port' + # Express: '/health', 'Server listening on port' + HEALTH_CHECK_PATH: '/docs' + HEALTH_CHECK_LOG_PATTERN: 'Uvicorn running on|Application startup complete' + API_DOCS_PATH: '/docs' + +# =================================================================== +# 트리거 설정 +# =================================================================== +on: + issue_comment: + types: [created] # PR 댓글 + Issue 댓글 + issues: + types: [closed] # Issue 닫힘 시 destroy + pull_request: + types: [closed] # PR 닫힘 시 destroy + +permissions: + contents: read + pull-requests: write + issues: write + +# =================================================================== +# Jobs +# =================================================================== +jobs: + # ----------------------------------------------------------------- + # Job 1: 명령어 파싱 (PR 댓글 + Issue 댓글 모두 지원) + # ----------------------------------------------------------------- + check-command: + name: 명령어 확인 + if: github.event_name == 'issue_comment' + runs-on: ubuntu-latest + outputs: + command: ${{ steps.parse.outputs.command }} + is_valid: ${{ steps.parse.outputs.is_valid }} + is_pr: ${{ steps.parse.outputs.is_pr }} + custom_branch: ${{ steps.parse.outputs.custom_branch }} + is_custom_branch: ${{ steps.parse.outputs.is_custom_branch }} + steps: + - name: 댓글에 👀 리액션 추가 + if: contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server') + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes' + }); + + - name: 커맨드 파싱 + id: parse + env: + COMMENT: ${{ github.event.comment.body }} + IS_PR: ${{ github.event.issue.pull_request != null }} + run: | + # PR인지 Issue인지 확인 + if [[ "$IS_PR" == "true" ]]; then + echo "is_pr=true" >> $GITHUB_OUTPUT + echo "ℹ️ PR 댓글에서 명령어 감지" + else + echo "is_pr=false" >> $GITHUB_OUTPUT + echo "ℹ️ Issue 댓글에서 명령어 감지" + fi + + # 명령어 파싱 (브랜치 파라미터 지원) + # 형식: @suh-lab server [브랜치명] + if [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+build([[:space:]]+([^[:space:]]+))? ]]; then + echo "command=build" >> $GITHUB_OUTPUT + echo "is_valid=true" >> $GITHUB_OUTPUT + if [[ -n "${BASH_REMATCH[2]}" ]]; then + echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "is_custom_branch=true" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: build (브랜치: ${BASH_REMATCH[2]})" + else + echo "is_custom_branch=false" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: build" + fi + elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+destroy([[:space:]]+([^[:space:]]+))? ]]; then + echo "command=destroy" >> $GITHUB_OUTPUT + echo "is_valid=true" >> $GITHUB_OUTPUT + if [[ -n "${BASH_REMATCH[2]}" ]]; then + echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "is_custom_branch=true" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: destroy (브랜치: ${BASH_REMATCH[2]})" + else + echo "is_custom_branch=false" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: destroy" + fi + elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+status([[:space:]]+([^[:space:]]+))? ]]; then + echo "command=status" >> $GITHUB_OUTPUT + echo "is_valid=true" >> $GITHUB_OUTPUT + if [[ -n "${BASH_REMATCH[2]}" ]]; then + echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "is_custom_branch=true" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: status (브랜치: ${BASH_REMATCH[2]})" + else + echo "is_custom_branch=false" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: status" + fi + else + echo "is_valid=false" >> $GITHUB_OUTPUT + echo "ℹ️ @suh-lab server 명령어가 아님" + fi + + # ----------------------------------------------------------------- + # Job 2-1: 빌드 & 배포 (PR 댓글에서 실행) + # ----------------------------------------------------------------- + build-preview-pr: + name: Preview 빌드 & 배포 (PR) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_pr == 'true' && + needs.check-command.outputs.is_custom_branch != 'true' + runs-on: ubuntu-latest + steps: + - name: PR 정보 가져오기 + id: pr + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('ref', pr.data.head.ref); + core.setOutput('sha', pr.data.head.sha.substring(0, 7)); + return pr.data.number; + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 진행 상황 댓글 시스템 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + - name: 진행 상황 댓글 생성 + id: progress + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.issue.number; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const startTime = Date.now(); + + const body = [ + '## 🚀 PR Preview 빌드 중...', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + + core.setOutput('comment_id', comment.id); + core.setOutput('start_time', startTime); + core.setOutput('docker_start', startTime); + + - name: 코드 체크아웃 + uses: actions/checkout@v5 + with: + ref: ${{ steps.pr.outputs.ref }} + + # ================================================================= + # ⚠️ [영역 2] 환경변수 파일 생성 + # ================================================================= + - name: "[필수] .env 파일 생성" + run: | + cat << 'EOF' > .env + ${{ secrets.ENV_FILE }} + EOF + # ================================================================= + # ⚠️ [영역 2 끝] 환경변수 파일 생성 끝 + # ================================================================= + + - name: Docker 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: Docker 이미지 빌드 & Push + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ github.event.issue.number }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 진행 상황 - Docker 완료 + id: docker_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const dockerStart = ${{ steps.progress.outputs.docker_start }}; + const now = Date.now(); + const dockerElapsed = now - dockerStart; + const minutes = Math.floor(dockerElapsed / 60000); + const seconds = Math.floor((dockerElapsed % 60000) / 1000); + const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 PR Preview 빌드 중...', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`, + '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('docker_duration', dockerDuration); + core.setOutput('deploy_start', now); + + - name: 서버에 배포 + uses: appleboy/ssh-action@v1.0.3 + env: + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + envs: SSH_AUTH_METHOD + script: | + set -e + + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + # 변수 설정 + PR_NUMBER=${{ github.event.issue.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}" + DOMAIN="${PROJECT_NAME}-pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}" + INTERNAL_PORT="${{ env.INTERNAL_PORT }}" + TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 PR Preview 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🔢 PR 번호: #${PR_NUMBER}" + echo "📛 컨테이너: ${CONTAINER_NAME}" + echo "🌐 도메인: ${DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 이미지 Pull + echo "📥 Docker 이미지 Pull 중..." + SUDO docker pull "${IMAGE}" + + # 기존 컨테이너 삭제 + echo "🗑️ 기존 컨테이너 정리 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 볼륨 마운트 옵션 구성 + VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro" + if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then + VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}" + fi + + # 새 컨테이너 실행 + echo "🐳 새 컨테이너 실행 중..." + SUDO docker run -d \ + --name "${CONTAINER_NAME}" \ + --network "${TRAEFIK_NETWORK}" \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \ + --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \ + --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \ + -e TZ=Asia/Seoul \ + -e ENVIRONMENT=prod \ + $VOLUME_OPTS \ + "${IMAGE}" + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Health Check (최대 120초 대기) - 하이브리드 방식 + # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도 + # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + echo "" + echo "⏳ Health Check 시작 (최대 120초 대기)..." + HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}" + LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}" + MAX_RETRIES=24 + RETRY_COUNT=0 + HEALTH_CHECK_PASSED=false + HEALTH_CHECK_METHOD="" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + sleep 5 + RETRY_COUNT=$((RETRY_COUNT + 1)) + + # 1. 컨테이너 상태 확인 + STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found") + + if [ "$STATUS" = "exited" ]; then + echo "❌ 컨테이너 비정상 종료!" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + if [ "$STATUS" = "running" ]; then + # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만) + if [ -n "$HEALTH_PATH" ]; then + HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "") + + if [ -n "$HEALTH" ]; then + echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="HTTP" + break + fi + fi + + # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백 + if [ -n "$LOG_PATTERN" ]; then + STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi + fi + fi + + echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS" + done + + if [ "$HEALTH_CHECK_PASSED" = "false" ]; then + echo "" + echo "❌ Health Check 타임아웃 (120초)" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + - name: 배포 완료 코멘트 + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + const deployStart = ${{ steps.docker_progress.outputs.deploy_start }}; + const now = Date.now(); + const deployElapsed = now - deployStart; + const minutes = Math.floor(deployElapsed / 60000); + const seconds = Math.floor((deployElapsed % 60000) / 1000); + const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; + const prNumber = context.issue.number; + const domain = `${projectName}-pr-${prNumber}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const sha = '${{ steps.pr.outputs.sha }}'; + + // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가) + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`); + envRows.push(`| **커밋** | \`${sha}\` |`); + + const branchName = '${{ steps.pr.outputs.ref }}'; + + const body = [ + '## ✅ PR Preview 배포 완료!', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`, + `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`, + '', + '### 🌐 Preview 환경', + '| 항목 | 값 |', + '|------|-----|', + ...envRows, + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab server build` | 최신 커밋으로 재배포 |', + '| `@suh-lab server destroy` | Preview 환경 삭제 |', + '| `@suh-lab server status` | 현재 상태 확인 |', + '', + '
', + '🔧 고급 명령어 (다른 Issue/PR에서 제어)', + '', + '```', + `@suh-lab server build ${branchName}`, + `@suh-lab server destroy ${branchName}`, + `@suh-lab server status ${branchName}`, + '```', + '
', + '', + '---', + '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + - name: 빌드/배포 실패 시 에러 코멘트 + if: failure() + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id || 0 }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const prNumber = context.issue.number; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + // 진행 상황에 따라 어디서 실패했는지 표시 + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + + // 실패 지점 판단 - 초기 단계 실패도 구분 + let dockerStatus, deployStatus; + let dockerTime = '-'; + + if (!commentId) { + // 진행 상황 댓글 생성 전 실패 (초기화 단계) + dockerStatus = '⚠️ 초기화 실패'; + deployStatus = '-'; + } else if (!dockerDuration || dockerDuration === '') { + // Docker 빌드 단계에서 실패 + dockerStatus = '❌ 실패'; + deployStatus = '⏸️ 대기'; + } else { + // 배포 단계에서 실패 + dockerStatus = '✅ 완료'; + deployStatus = '❌ 실패'; + dockerTime = dockerDuration; + } + + const body = [ + '## ❌ PR Preview 배포 실패!', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`, + `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`, + '', + `**[📋 빌드/배포 로그 확인](${runUrl})**`, + '', + '### 🔍 가능한 원인', + '- Docker 이미지 빌드 실패 (Python 의존성 문제)', + '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)', + '- Health Check 타임아웃 (120초 내 기동 완료 안됨)', + '- 환경변수 누락 (.env 파일 설정 확인)', + '', + '### 💡 다음 단계', + '1. 위 링크에서 빌드/배포 로그를 확인하세요', + '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`', + '', + '---', + '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + // 진행 상황 댓글이 있으면 업데이트, 없으면 새로 생성 + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + } + + # ----------------------------------------------------------------- + # Job 2-2: Issue에서 브랜치명 추출 + # ----------------------------------------------------------------- + get-branch-from-issue: + name: Issue에서 브랜치 추출 + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.is_pr == 'false' && + needs.check-command.outputs.is_custom_branch != 'true' + runs-on: ubuntu-latest + outputs: + branch_name: ${{ steps.extract.outputs.branch }} + found: ${{ steps.extract.outputs.found }} + issue_number: ${{ steps.extract.outputs.issue_number }} + steps: + - name: Issue 댓글에서 브랜치명 추출 + id: extract + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue.number; + const marker = '${{ env.ISSUE_HELPER_MARKER }}'; + + console.log(`🔍 Issue #${issueNumber}에서 브랜치 검색 중...`); + console.log(`📝 마커: ${marker}`); + + // Issue의 모든 댓글 조회 + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + // 브랜치 추출 패턴 (마커와 무관하게 동일한 형식) + // "### 브랜치" 또는 "### 브랜치명" 둘 다 매칭 + const branchRegex = /###\s*브랜치(?:명)?\s*\n```\s*\n([^\n]+)\s*\n```/; + + for (const comment of comments.data) { + // 마커가 포함된 댓글 찾기 + if (comment.body.includes(marker)) { + const match = comment.body.match(branchRegex); + if (match) { + const branchName = match[1].trim(); + core.setOutput('branch', branchName); + core.setOutput('found', 'true'); + core.setOutput('issue_number', issueNumber); + console.log(`✅ 브랜치 발견: ${branchName}`); + return; + } + } + } + + // 브랜치를 찾지 못한 경우 - 에러가 아님, graceful하게 처리 + core.setOutput('found', 'false'); + core.setOutput('issue_number', issueNumber); + console.log('⚠️ Issue Helper 댓글에서 브랜치를 찾을 수 없습니다.'); + + - name: 브랜치 없음 알림 + if: steps.extract.outputs.found == 'false' + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue.number; + const marker = '${{ env.ISSUE_HELPER_MARKER }}'; + + const body = [ + '## ⚠️ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Issue** | #${issueNumber} |`, + `| **마커** | \`${marker}\` |`, + '', + '### 💡 확인 사항', + '1. Issue Helper 댓글이 존재하는지 확인하세요', + '2. 댓글에 `### 브랜치` 섹션이 있는지 확인하세요', + '3. `ISSUE_HELPER_MARKER` 환경변수가 올바른지 확인하세요', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + # ----------------------------------------------------------------- + # Job 2-3: 빌드 & 배포 (Issue 댓글에서 실행) + # ----------------------------------------------------------------- + build-preview-issue: + name: Preview 빌드 & 배포 (Issue) + needs: [check-command, get-branch-from-issue] + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_pr == 'false' && + needs.check-command.outputs.is_custom_branch != 'true' && + needs.get-branch-from-issue.outputs.found == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치 존재 확인 + id: check_branch + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + + console.log(`🔍 브랜치 존재 확인: ${branchName}`); + + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: branchName + }); + core.setOutput('exists', 'true'); + console.log(`✅ 브랜치 존재: ${branchName}`); + } catch (error) { + if (error.status === 404) { + core.setOutput('exists', 'false'); + console.log(`❌ 브랜치 없음: ${branchName}`); + + // 브랜치 없음 알림 댓글 + const body = [ + '## ❌ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Issue** | #${issueNumber} |`, + `| **브랜치** | \`${branchName}\` |`, + '', + '### 💡 확인 사항', + '1. 브랜치가 push되었는지 확인하세요', + '2. 브랜치명이 정확한지 확인하세요', + '3. 브랜치가 삭제되지 않았는지 확인하세요', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + } else { + throw error; + } + } + + - name: 브랜치 없으면 중단 + if: steps.check_branch.outputs.exists == 'false' + run: | + echo "⚠️ 브랜치가 존재하지 않아 빌드를 건너뜁니다." + exit 0 + + - name: Issue 정보 설정 + if: steps.check_branch.outputs.exists == 'true' + id: issue + run: | + echo "ref=${{ needs.get-branch-from-issue.outputs.branch_name }}" >> $GITHUB_OUTPUT + echo "issue_number=${{ needs.get-branch-from-issue.outputs.issue_number }}" >> $GITHUB_OUTPUT + # 브랜치의 최신 커밋 SHA 가져오기 + echo "sha=$(git ls-remote https://github.com/${{ github.repository }}.git refs/heads/${{ needs.get-branch-from-issue.outputs.branch_name }} | cut -c1-7)" >> $GITHUB_OUTPUT + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 진행 상황 댓글 시스템 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + - name: 진행 상황 댓글 생성 + if: steps.check_branch.outputs.exists == 'true' + id: progress + uses: actions/github-script@v7 + with: + script: | + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const startTime = Date.now(); + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + core.setOutput('comment_id', comment.id); + core.setOutput('start_time', startTime); + core.setOutput('docker_start', startTime); + + - name: 코드 체크아웃 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/checkout@v5 + with: + ref: ${{ needs.get-branch-from-issue.outputs.branch_name }} + + # ================================================================= + # ⚠️ [영역 2] 환경변수 파일 생성 + # ================================================================= + - name: "[필수] .env 파일 생성" + if: steps.check_branch.outputs.exists == 'true' + run: | + cat << 'EOF' > .env + ${{ secrets.ENV_FILE }} + EOF + # ================================================================= + # ⚠️ [영역 2 끝] 환경변수 파일 생성 끝 + # ================================================================= + + - name: Docker 로그인 + if: steps.check_branch.outputs.exists == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Buildx 설정 + if: steps.check_branch.outputs.exists == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Docker 이미지 빌드 & Push + if: steps.check_branch.outputs.exists == 'true' + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ needs.get-branch-from-issue.outputs.issue_number }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 진행 상황 - Docker 완료 + if: steps.check_branch.outputs.exists == 'true' + id: docker_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const dockerStart = ${{ steps.progress.outputs.docker_start }}; + const now = Date.now(); + const dockerElapsed = now - dockerStart; + const minutes = Math.floor(dockerElapsed / 60000); + const seconds = Math.floor((dockerElapsed % 60000) / 1000); + const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`, + '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('docker_duration', dockerDuration); + core.setOutput('deploy_start', now); + + - name: 서버에 배포 + if: steps.check_branch.outputs.exists == 'true' + uses: appleboy/ssh-action@v1.0.3 + env: + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + envs: SSH_AUTH_METHOD + script: | + set -e + + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + # 변수 설정 + ISSUE_NUMBER=${{ needs.get-branch-from-issue.outputs.issue_number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${ISSUE_NUMBER}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${ISSUE_NUMBER}" + DOMAIN="${PROJECT_NAME}-pr-${ISSUE_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}" + INTERNAL_PORT="${{ env.INTERNAL_PORT }}" + TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Issue Preview 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🔢 Issue 번호: #${ISSUE_NUMBER}" + echo "📛 컨테이너: ${CONTAINER_NAME}" + echo "🌐 도메인: ${DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 이미지 Pull + echo "📥 Docker 이미지 Pull 중..." + SUDO docker pull "${IMAGE}" + + # 기존 컨테이너 삭제 + echo "🗑️ 기존 컨테이너 정리 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 볼륨 마운트 옵션 구성 + VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro" + if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then + VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}" + fi + + # 새 컨테이너 실행 + echo "🐳 새 컨테이너 실행 중..." + SUDO docker run -d \ + --name "${CONTAINER_NAME}" \ + --network "${TRAEFIK_NETWORK}" \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \ + --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \ + --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \ + -e TZ=Asia/Seoul \ + -e ENVIRONMENT=prod \ + $VOLUME_OPTS \ + "${IMAGE}" + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Health Check (최대 120초 대기) - 하이브리드 방식 + # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도 + # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + echo "" + echo "⏳ Health Check 시작 (최대 120초 대기)..." + HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}" + LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}" + MAX_RETRIES=24 + RETRY_COUNT=0 + HEALTH_CHECK_PASSED=false + HEALTH_CHECK_METHOD="" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + sleep 5 + RETRY_COUNT=$((RETRY_COUNT + 1)) + + # 1. 컨테이너 상태 확인 + STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found") + + if [ "$STATUS" = "exited" ]; then + echo "❌ 컨테이너 비정상 종료!" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + if [ "$STATUS" = "running" ]; then + # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만) + if [ -n "$HEALTH_PATH" ]; then + HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "") + + if [ -n "$HEALTH" ]; then + echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="HTTP" + break + fi + fi + + # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백 + if [ -n "$LOG_PATTERN" ]; then + STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi + fi + fi + + echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS" + done + + if [ "$HEALTH_CHECK_PASSED" = "false" ]; then + echo "" + echo "❌ Health Check 타임아웃 (120초)" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + - name: 배포 완료 코멘트 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + const deployStart = ${{ steps.docker_progress.outputs.deploy_start }}; + const now = Date.now(); + const deployElapsed = now - deployStart; + const minutes = Math.floor(deployElapsed / 60000); + const seconds = Math.floor((deployElapsed % 60000) / 1000); + const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const domain = `${projectName}-pr-${issueNumber}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const sha = '${{ steps.issue.outputs.sha }}'; + + // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가) + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`); + envRows.push(`| **브랜치** | \`${branchName}\` |`); + envRows.push(`| **커밋** | \`${sha}\` |`); + + const body = [ + '## ✅ Issue Preview 배포 완료!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`, + `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`, + '', + '### 🌐 Preview 환경', + '| 항목 | 값 |', + '|------|-----|', + ...envRows, + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab server build` | 최신 커밋으로 재배포 |', + '| `@suh-lab server destroy` | Preview 환경 삭제 |', + '| `@suh-lab server status` | 현재 상태 확인 |', + '', + '
', + '🔧 고급 명령어 (다른 Issue/PR에서 제어)', + '', + '```', + `@suh-lab server build ${branchName}`, + `@suh-lab server destroy ${branchName}`, + `@suh-lab server status ${branchName}`, + '```', + '
', + '', + '---', + '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + - name: 빌드/배포 실패 시 에러 코멘트 + if: failure() && steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id || 0 }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + + let dockerStatus, deployStatus; + let dockerTime = '-'; + + if (!commentId) { + dockerStatus = '⚠️ 초기화 실패'; + deployStatus = '-'; + } else if (!dockerDuration || dockerDuration === '') { + dockerStatus = '❌ 실패'; + deployStatus = '⏸️ 대기'; + } else { + dockerStatus = '✅ 완료'; + deployStatus = '❌ 실패'; + dockerTime = dockerDuration; + } + + const body = [ + '## ❌ Issue Preview 배포 실패!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`, + `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`, + '', + `**[📋 빌드/배포 로그 확인](${runUrl})**`, + '', + '### 🔍 가능한 원인', + '- Docker 이미지 빌드 실패 (Python 의존성 문제)', + '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)', + '- Health Check 타임아웃 (120초 내 기동 완료 안됨)', + '- 환경변수 누락 (.env 파일 설정 확인)', + '', + '### 💡 다음 단계', + '1. 위 링크에서 빌드/배포 로그를 확인하세요', + '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`', + '', + '---', + '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + } + + # ----------------------------------------------------------------- + # Job 3: Preview 삭제 (PR/Issue 닫힘 또는 destroy 명령) + # ----------------------------------------------------------------- + destroy-preview: + name: Preview 삭제 + # needs 의존성 제거: PR/Issue 닫힘 이벤트에서도 독립적으로 실행되도록 함 + if: | + (github.event_name == 'pull_request' && github.event.action == 'closed') || + (github.event_name == 'issues' && github.event.action == 'closed') || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server destroy')) + runs-on: ubuntu-latest + steps: + - name: PR/Issue 번호 가져오기 + id: pr_number + run: | + if [[ "${{ github.event_name }}" == "issue_comment" ]]; then + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + echo "type=comment" >> $GITHUB_OUTPUT + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + echo "type=pr_closed" >> $GITHUB_OUTPUT + else + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + echo "type=issue_closed" >> $GITHUB_OUTPUT + fi + + - name: 컨테이너 & 이미지 삭제 + uses: appleboy/ssh-action@v1.0.3 + env: + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + envs: SSH_AUTH_METHOD + script: | + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + PR_NUMBER=${{ steps.pr_number.outputs.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🗑️ PR Preview 삭제" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🔢 PR 번호: #${PR_NUMBER}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 컨테이너 삭제 + echo "🐳 컨테이너 삭제 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 이미지 삭제 + echo "🖼️ 이미지 삭제 중..." + SUDO docker rmi "${IMAGE}" 2>/dev/null || true + + echo "✅ 삭제 완료!" + + - name: 삭제 완료 코멘트 + if: | + github.event_name == 'issue_comment' || + (github.event_name == 'pull_request' && github.event.action == 'closed') || + (github.event_name == 'issues' && github.event.action == 'closed') + uses: actions/github-script@v7 + with: + script: | + const number = ${{ steps.pr_number.outputs.number }}; + const projectName = '${{ env.PROJECT_NAME }}'; + + const body = [ + '## 🗑️ Preview 환경 삭제 완료!', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, + '| **상태** | 삭제됨 |', + '', + '다시 배포하려면: `@suh-lab server build`', + '', + '---', + '*🤖 이 댓글은 PR/Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: body + }); + + # ----------------------------------------------------------------- + # Job 4: 상태 확인 (PR/Issue 모두 지원) + # ----------------------------------------------------------------- + check-status: + name: Preview 상태 확인 + needs: check-command + if: needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'status' + runs-on: ubuntu-latest + steps: + - name: 컨테이너 상태 확인 + id: status + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + + NUMBER=${{ github.event.issue.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🔍 Preview 상태 확인" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then + echo "STATUS=running" + echo "✅ 컨테이너 실행 중" + docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}" + else + echo "STATUS=not_found" + echo "❌ 컨테이너 없음" + fi + + - name: 상태 코멘트 (실행 중) + uses: appleboy/ssh-action@v1.0.3 + id: check_running + continue-on-error: true + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + + NUMBER=${{ github.event.issue.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}" + docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}" + + - name: 상태 코멘트 작성 + uses: actions/github-script@v7 + with: + script: | + const number = context.issue.number; + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const domain = `${projectName}-pr-${number}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const isRunning = '${{ steps.check_running.outcome }}' === 'success'; + const isPr = '${{ needs.check-command.outputs.is_pr }}' === 'true'; + const contextType = isPr ? 'PR' : 'Issue'; + + let body; + if (isRunning) { + body = [ + '## ✅ Preview 환경 실행 중', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Preview URL** | ${previewUrl} |`, + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, + '| **상태** | 🟢 Running |', + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab server build` | 최신 커밋으로 재배포 |', + '| `@suh-lab server destroy` | Preview 환경 삭제 |', + '', + '---', + `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*` + ].join('\n'); + } else { + body = [ + '## ❌ Preview 환경 없음', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, + '| **상태** | 🔴 Not Found |', + '', + '배포하려면: `@suh-lab server build`', + '', + '---', + `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*` + ].join('\n'); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: body + }); + + # ----------------------------------------------------------------- + # Job 5: 커스텀 브랜치 빌드 & 배포 + # ----------------------------------------------------------------- + build-preview-custom-branch: + name: Preview 빌드 & 배포 (Custom Branch) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_custom_branch == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치 존재 확인 및 정보 추출 + id: branch_info + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const issueNumber = context.issue.number; + + 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 = [ + '## ❌ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + '', + '### 💡 확인 사항', + '1. 브랜치명이 정확한지 확인하세요', + '2. 브랜치가 push되었는지 확인하세요', + '3. 브랜치가 삭제되지 않았는지 확인하세요', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + core.setFailed('브랜치를 찾을 수 없습니다'); + return; + } + throw error; + } + + // 브랜치명에서 Issue 번호 추출 (#번호 패턴) + const issueMatch = branchName.match(/#(\d+)/); + + if (issueMatch) { + // 번호 추출 성공 + core.setOutput('container_number', issueMatch[1]); + core.setOutput('use_hash', 'false'); + console.log(`✅ Issue 번호 추출: #${issueMatch[1]}`); + } else { + // 해시 생성 + let hash = 0; + for (let i = 0; i < branchName.length; i++) { + const char = branchName.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0'); + core.setOutput('container_suffix', `custom-${hashStr}`); + core.setOutput('use_hash', 'true'); + console.log(`✅ 해시 생성: custom-${hashStr}`); + } + + core.setOutput('branch_name', branchName); + core.setOutput('branch_exists', 'true'); + + - name: 브랜치의 최신 커밋 SHA 가져오기 + if: steps.branch_info.outputs.branch_exists == 'true' + id: get_sha + run: | + SHA=$(git ls-remote https://github.com/${{ github.repository }}.git refs/heads/${{ needs.check-command.outputs.custom_branch }} | cut -c1-7) + echo "sha=$SHA" >> $GITHUB_OUTPUT + + # 진행 상황 댓글 생성 + - name: 진행 상황 댓글 생성 + if: steps.branch_info.outputs.branch_exists == 'true' + id: progress + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue.number; + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const startTime = Date.now(); + + const body = [ + '## 🚀 Custom Branch Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + core.setOutput('comment_id', comment.id); + core.setOutput('start_time', startTime); + core.setOutput('docker_start', startTime); + + - name: 코드 체크아웃 + if: steps.branch_info.outputs.branch_exists == 'true' + uses: actions/checkout@v5 + with: + ref: ${{ needs.check-command.outputs.custom_branch }} + + # 환경변수 파일 생성 + - name: "[필수] .env 파일 생성" + if: steps.branch_info.outputs.branch_exists == 'true' + run: | + cat << 'EOF' > .env + ${{ secrets.ENV_FILE }} + EOF + + - name: Docker 로그인 + if: steps.branch_info.outputs.branch_exists == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Buildx 설정 + if: steps.branch_info.outputs.branch_exists == 'true' + uses: docker/setup-buildx-action@v3 + + - name: 컨테이너 이름 결정 + if: steps.branch_info.outputs.branch_exists == 'true' + id: container + run: | + if [[ "${{ steps.branch_info.outputs.use_hash }}" == "true" ]]; then + SUFFIX="${{ steps.branch_info.outputs.container_suffix }}" + else + SUFFIX="pr-${{ steps.branch_info.outputs.container_number }}" + fi + echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT + echo "name=${{ env.PROJECT_NAME }}-${SUFFIX}" >> $GITHUB_OUTPUT + + - name: Docker 이미지 빌드 & Push + if: steps.branch_info.outputs.branch_exists == 'true' + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:${{ steps.container.outputs.suffix }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 진행 상황 - Docker 완료 + if: steps.branch_info.outputs.branch_exists == 'true' + id: docker_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const dockerStart = ${{ steps.progress.outputs.docker_start }}; + const now = Date.now(); + const dockerElapsed = now - dockerStart; + const minutes = Math.floor(dockerElapsed / 60000); + const seconds = Math.floor((dockerElapsed % 60000) / 1000); + const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 Custom Branch Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`, + '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('docker_duration', dockerDuration); + core.setOutput('deploy_start', now); + + - name: 서버에 배포 + if: steps.branch_info.outputs.branch_exists == 'true' + uses: appleboy/ssh-action@v1.0.3 + env: + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + envs: SSH_AUTH_METHOD + script: | + set -e + + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + # 변수 설정 + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${{ steps.container.outputs.name }}" + CONTAINER_SUFFIX="${{ steps.container.outputs.suffix }}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${CONTAINER_SUFFIX}" + DOMAIN="${CONTAINER_NAME}.${{ env.PREVIEW_DOMAIN_SUFFIX }}" + INTERNAL_PORT="${{ env.INTERNAL_PORT }}" + TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Custom Branch Preview 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "📛 컨테이너: ${CONTAINER_NAME}" + echo "🌐 도메인: ${DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 이미지 Pull + echo "📥 Docker 이미지 Pull 중..." + SUDO docker pull "${IMAGE}" + + # 기존 컨테이너 삭제 + echo "🗑️ 기존 컨테이너 정리 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 볼륨 마운트 옵션 구성 + VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro" + if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then + VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}" + fi + + # 새 컨테이너 실행 + echo "🐳 새 컨테이너 실행 중..." + SUDO docker run -d \ + --name "${CONTAINER_NAME}" \ + --network "${TRAEFIK_NETWORK}" \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \ + --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \ + --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \ + -e TZ=Asia/Seoul \ + -e ENVIRONMENT=prod \ + $VOLUME_OPTS \ + "${IMAGE}" + + # Health Check + echo "" + echo "⏳ Health Check 시작 (최대 120초 대기)..." + HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}" + LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}" + MAX_RETRIES=24 + RETRY_COUNT=0 + HEALTH_CHECK_PASSED=false + HEALTH_CHECK_METHOD="" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + sleep 5 + RETRY_COUNT=$((RETRY_COUNT + 1)) + + STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found") + + if [ "$STATUS" = "exited" ]; then + echo "❌ 컨테이너 비정상 종료!" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + if [ "$STATUS" = "running" ]; then + if [ -n "$HEALTH_PATH" ]; then + HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "") + + if [ -n "$HEALTH" ]; then + echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="HTTP" + break + fi + fi + + if [ -n "$LOG_PATTERN" ]; then + STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi + fi + fi + + echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS" + done + + if [ "$HEALTH_CHECK_PASSED" = "false" ]; then + echo "" + echo "❌ Health Check 타임아웃 (120초)" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + - name: 배포 완료 코멘트 + if: steps.branch_info.outputs.branch_exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + const deployStart = ${{ steps.docker_progress.outputs.deploy_start }}; + const now = Date.now(); + const deployElapsed = now - deployStart; + const minutes = Math.floor(deployElapsed / 60000); + const seconds = Math.floor((deployElapsed % 60000) / 1000); + const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; + const containerName = '${{ steps.container.outputs.name }}'; + const domain = `${containerName}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const sha = '${{ steps.get_sha.outputs.sha }}'; + + // Preview 환경 테이블 구성 + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${containerName}\` |`); + envRows.push(`| **브랜치** | \`${branchName}\` |`); + envRows.push(`| **커밋** | \`${sha}\` |`); + + const body = [ + '## ✅ Custom Branch Preview 배포 완료!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`, + `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`, + '', + '### 🌐 Preview 환경', + '| 항목 | 값 |', + '|------|-----|', + ...envRows, + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab server build` | 최신 커밋으로 재배포 |', + '| `@suh-lab server destroy` | Preview 환경 삭제 |', + '| `@suh-lab server status` | 현재 상태 확인 |', + '', + '
', + '🔧 고급 명령어 (다른 Issue/PR에서 제어)', + '', + '```', + `@suh-lab server build ${branchName}`, + `@suh-lab server destroy ${branchName}`, + `@suh-lab server status ${branchName}`, + '```', + '
', + '', + '---', + '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + - name: 빌드/배포 실패 시 에러 코멘트 + if: failure() && steps.branch_info.outputs.branch_exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id || 0 }}; + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + + let dockerStatus, deployStatus; + let dockerTime = '-'; + + if (!commentId) { + dockerStatus = '⚠️ 초기화 실패'; + deployStatus = '-'; + } else if (!dockerDuration || dockerDuration === '') { + dockerStatus = '❌ 실패'; + deployStatus = '⏸️ 대기'; + } else { + dockerStatus = '✅ 완료'; + deployStatus = '❌ 실패'; + dockerTime = dockerDuration; + } + + const body = [ + '## ❌ Custom Branch Preview 배포 실패!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`, + `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`, + '', + `**[📋 빌드/배포 로그 확인](${runUrl})**`, + '', + '### 🔍 가능한 원인', + '- Docker 이미지 빌드 실패 (Python 의존성 문제)', + '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)', + '- Health Check 타임아웃 (120초 내 기동 완료 안됨)', + '- 환경변수 누락 (.env 파일 설정 확인)', + '', + '### 💡 다음 단계', + '1. 위 링크에서 빌드/배포 로그를 확인하세요', + `2. 문제를 수정한 후 다시 시도하세요: \`@suh-lab server build ${branchName}\``, + '', + '---', + '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } + + # ----------------------------------------------------------------- + # Job 6: 커스텀 브랜치 Preview 삭제 + # ----------------------------------------------------------------- + destroy-preview-custom-branch: + name: Preview 삭제 (Custom Branch) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'destroy' && + needs.check-command.outputs.is_custom_branch == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치명에서 컨테이너 정보 추출 + id: container_info + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const projectName = '${{ env.PROJECT_NAME }}'; + + // 브랜치명에서 Issue 번호 추출 (#번호 패턴) + const issueMatch = branchName.match(/#(\d+)/); + + let containerName; + if (issueMatch) { + containerName = `${projectName}-pr-${issueMatch[1]}`; + console.log(`✅ Issue 번호 추출: #${issueMatch[1]}`); + } else { + // 해시 생성 + let hash = 0; + for (let i = 0; i < branchName.length; i++) { + const char = branchName.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0'); + containerName = `${projectName}-custom-${hashStr}`; + console.log(`✅ 해시 생성: custom-${hashStr}`); + } + + core.setOutput('container_name', containerName); + core.setOutput('branch_name', branchName); + + - name: 컨테이너 & 이미지 삭제 + uses: appleboy/ssh-action@v1.0.3 + env: + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + envs: SSH_AUTH_METHOD + script: | + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + CONTAINER_NAME="${{ steps.container_info.outputs.container_name }}" + PROJECT_NAME="${{ env.PROJECT_NAME }}" + + # 컨테이너명에서 suffix 추출 + SUFFIX="${CONTAINER_NAME#${PROJECT_NAME}-}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${SUFFIX}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🗑️ Custom Branch Preview 삭제" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "📛 컨테이너: ${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 컨테이너 삭제 + echo "🐳 컨테이너 삭제 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 이미지 삭제 + echo "🖼️ 이미지 삭제 중..." + SUDO docker rmi "${IMAGE}" 2>/dev/null || true + + echo "✅ 삭제 완료!" + + - name: 삭제 완료 코멘트 + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const containerName = '${{ steps.container_info.outputs.container_name }}'; + + const body = [ + '## 🗑️ Custom Branch Preview 환경 삭제 완료!', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **컨테이너** | \`${containerName}\` |`, + '| **상태** | 삭제됨 |', + '', + `다시 배포하려면: \`@suh-lab server build ${branchName}\``, + '', + '---', + '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + + # ----------------------------------------------------------------- + # Job 7: 커스텀 브랜치 Preview 상태 확인 + # ----------------------------------------------------------------- + check-status-custom-branch: + name: Preview 상태 확인 (Custom Branch) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'status' && + needs.check-command.outputs.is_custom_branch == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치명에서 컨테이너 정보 추출 + id: container_info + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const projectName = '${{ env.PROJECT_NAME }}'; + + // 브랜치명에서 Issue 번호 추출 (#번호 패턴) + const issueMatch = branchName.match(/#(\d+)/); + + let containerName; + if (issueMatch) { + containerName = `${projectName}-pr-${issueMatch[1]}`; + } else { + // 해시 생성 + let hash = 0; + for (let i = 0; i < branchName.length; i++) { + const char = branchName.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0'); + containerName = `${projectName}-custom-${hashStr}`; + } + + core.setOutput('container_name', containerName); + core.setOutput('branch_name', branchName); + + - name: 컨테이너 상태 확인 + uses: appleboy/ssh-action@v1.0.3 + id: check_running + continue-on-error: true + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + export PATH=$PATH:/usr/local/bin + + CONTAINER_NAME="${{ steps.container_info.outputs.container_name }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🔍 Custom Branch Preview 상태 확인" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then + echo "STATUS=running" + echo "✅ 컨테이너 실행 중" + docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}" + else + echo "STATUS=not_found" + echo "❌ 컨테이너 없음" + exit 1 + fi + + - name: 상태 코멘트 작성 + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const containerName = '${{ steps.container_info.outputs.container_name }}'; + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const domain = `${containerName}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const isRunning = '${{ steps.check_running.outcome }}' === 'success'; + + let body; + if (isRunning) { + body = [ + '## ✅ Custom Branch Preview 환경 실행 중', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **Preview URL** | ${previewUrl} |`, + `| **컨테이너** | \`${containerName}\` |`, + '| **상태** | 🟢 Running |', + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + `| \`@suh-lab server build ${branchName}\` | 최신 커밋으로 재배포 |`, + `| \`@suh-lab server destroy ${branchName}\` | Preview 환경 삭제 |`, + '', + '---', + '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + } else { + body = [ + '## ❌ Custom Branch Preview 환경 없음', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **컨테이너** | \`${containerName}\` |`, + '| **상태** | 🔴 Not Found |', + '', + `배포하려면: \`@suh-lab server build ${branchName}\``, + '', + '---', + '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); diff --git a/.github/workflows/PROJECT-PYTHON-SIMPLE-CICD.yaml b/.github/workflows/PROJECT-PYTHON-SIMPLE-CICD.yaml new file mode 100644 index 00000000..b89006c7 --- /dev/null +++ b/.github/workflows/PROJECT-PYTHON-SIMPLE-CICD.yaml @@ -0,0 +1,389 @@ +# =================================================================== +# Python FastAPI CI/CD 배포 (SSH + Docker) +# =================================================================== +# +# deploy 브랜치 푸시 시 Docker 이미지를 빌드하고 +# SSH로 접속 가능한 서버(Synology·AWS EC2·VPS 등)에 자동 배포합니다. +# +# =================================================================== +# 🔑 필수 GitHub Secrets +# =================================================================== +# ENV_FILE: .env 파일 내용 +# DOCKERHUB_USERNAME: DockerHub 사용자명 +# DOCKERHUB_TOKEN: DockerHub 액세스 토큰 +# SERVER_HOST: 배포 서버 주소 +# SERVER_USER: SSH 사용자명 +# SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때) +# SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등) +# =================================================================== +# +# 🔧 환경변수 설정 (env 섹션에서 설정) +# =================================================================== +# +# 🌐 포트 및 컨테이너: +# CONTAINER_INTERNAL_PORT: FastAPI 내부 포트 (기본: 8000) +# DEPLOY_PORT: 배포 포트 +# CONTAINER_NAME: 컨테이너 이름 (기본: PROJECT_NAME) +# +# 📦 볼륨 마운트: +# ENABLE_VOLUME_MOUNT: 볼륨 마운트 활성화 (true/false) +# VOLUME_HOST_PATH: 호스트 볼륨 경로 +# VOLUME_CONTAINER_PATH: 컨테이너 내부 마운트 경로 +# +# 🔌 SSH 연결: +# SSH_PORT: SSH 포트 (기본: 2022) +# SSH_COMMAND_TIMEOUT: SSH 명령 타임아웃 (기본: 600s) +# SSH_CONNECTION_TIMEOUT: SSH 연결 타임아웃 (기본: 60s) +# +# ⏱️ 헬스체크: +# HEALTHCHECK_WAIT_SECONDS: 초기화 대기 시간 (기본: 10초) +# HEALTHCHECK_PATH: HTTP 엔드포인트 경로 (비어있으면 로그만 사용) +# HEALTHCHECK_MAX_RETRIES: HTTP 재시도 횟수 (기본: 5회) +# HEALTHCHECK_RETRY_INTERVAL: 재시도 간격 (기본: 3초) +# HEALTHCHECK_LOG_PATTERN: Fallback 로그 검색 패턴 +# +# 💡 HEALTHCHECK_PATH 예시: +# FastAPI: "/docs" +# 커스텀: "/api/v1/health" +# =================================================================== + +name: PROJECT-PYTHON-SIMPLE-CICD + +# =================================================================== +# 트리거 설정 +# =================================================================== +on: + # ⚠️ passQL AI 실배포는 PROJECT-PYTHON-AI-CICD가 담당 — + # 이 템플릿의 env(PROJECT_NAME/DEPLOY_PORT)를 passql-ai에 맞게 설정하기 전까지 + # push 트리거를 활성화하지 말 것 (이중 배포 방지) + # push: + # branches: + # - deploy # 프로덕션 배포 + # paths: ['ai/**'] + workflow_dispatch: # 수동 실행 허용 + +# =================================================================== +# 환경 변수 설정 +# =================================================================== +env: + # 프로젝트 고유 식별자 + PROJECT_NAME: "passQL" + + # 🌐 포트 설정 + DEPLOY_PORT: "8000" + + # 🐍 Python 설정 + PYTHON_VERSION: "3.13" + + # 🐳 컨테이너 이름 설정 (비워두면 PROJECT_NAME 사용) + CONTAINER_NAME: "" + + # 🗂️ 볼륨 마운트 설정 (빈값이면 마운트 안함) + ENABLE_VOLUME_MOUNT: "false" + VOLUME_HOST_PATH: "/volume1/projects/passQL" + VOLUME_CONTAINER_PATH: "/mnt/passQL" + + # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem) + # password → SERVER_PASSWORD secret 사용 + # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로) + SSH_AUTH_METHOD: "password" + + # 🔌 SSH 연결 설정 + SSH_PORT: "2022" # SSH 포트 (Synology 기본: 2022) + SSH_COMMAND_TIMEOUT: "600s" # SSH 명령 실행 타임아웃 + SSH_CONNECTION_TIMEOUT: "60s" # SSH 연결 타임아웃 + + # 🌐 컨테이너 포트 설정 + CONTAINER_INTERNAL_PORT: "8000" # FastAPI 내부 포트 + + # ⏱️ 헬스체크 설정 + HEALTHCHECK_WAIT_SECONDS: "10" # 컨테이너 초기화 대기 시간 (초) + HEALTHCHECK_PATH: "/docs" # HTTP 엔드포인트 경로 (비어있으면 로그 패턴만 사용) + HEALTHCHECK_MAX_RETRIES: "5" # HTTP 재시도 횟수 + HEALTHCHECK_RETRY_INTERVAL: "3" # 재시도 간격 (초) + HEALTHCHECK_LOG_PATTERN: "Uvicorn running on" # Fallback 로그 검색 패턴 + +jobs: + # =================================================================== + # 빌드 작업 + # =================================================================== + build: + name: Python FastAPI 애플리케이션 빌드 + runs-on: ubuntu-latest + + steps: + # 1. 소스코드 체크아웃 + - name: 코드 체크아웃 + uses: actions/checkout@v5 + + # 2. 디스크 공간 확보 + - name: 디스크 공간 확보 + run: | + echo "📊 빌드 전 디스크 사용량:" + df -h + + echo "🧹 불필요한 파일 정리 중..." + sudo rm -rf /usr/share/dotnet || true + sudo rm -rf /opt/ghc || true + sudo rm -rf /usr/local/share/boost || true + sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true + + echo "🐳 Docker 시스템 정리 중..." + docker system prune -af --volumes || true + + echo "📊 정리 후 디스크 사용량:" + df -h + + # 3. .env 파일 생성 + - name: .env 파일 생성 + run: | + cat > .env << 'EOF' + ${{ secrets.ENV_FILE }} + EOF + echo "✅ .env 파일이 생성되었습니다" + + # 4. Docker 빌드 환경 설정 + - name: Docker 빌드환경 설정 + uses: docker/setup-buildx-action@v3 + + # 5. DockerHub 로그인 + - name: DockerHub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # 6. Docker 이미지 빌드 및 푸시 + - name: Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:${{ github.ref_name }} + # GitHub Actions 캐시 사용 (로컬 디스크 공간 절약) + cache-from: type=gha + cache-to: type=gha,mode=max + + # 7. 빌드 후 정리 + - name: 빌드 후 정리 + if: always() + run: | + echo "🧹 빌드 후 정리 중..." + docker system prune -f || true + echo "📊 최종 디스크 사용량:" + df -h + + # =================================================================== + # 배포 작업 + # =================================================================== + deploy: + name: 원격 서버 배포 + needs: build + runs-on: ubuntu-latest + + steps: + # SSH 통한 원격 서버 배포 실행 + - name: Deploy + uses: appleboy/ssh-action@v1.0.3 + env: + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + command_timeout: ${{ env.SSH_COMMAND_TIMEOUT }} + timeout: ${{ env.SSH_CONNECTION_TIMEOUT }} + envs: SSH_AUTH_METHOD + script: | + set -e + + # ============================================================ + # 배포 환경 변수 설정 + # ============================================================ + echo "🔧 환경변수 설정.." + export PATH=$PATH:/usr/local/bin + export PW=${{ secrets.SERVER_PASSWORD }} + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + # password 모드: echo $PW | sudo -S (Synology 등 sudo 비밀번호 필요 서버) + # key 모드: sudo 직접 (AWS EC2 등 passwordless sudo 서버) + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + # GitHub에서 전달받은 브랜치명 + BRANCH=${{ github.ref_name }} + + # 프로젝트 설정 + PROJECT_NAME="${{ env.PROJECT_NAME }}" + + # ============================================================ + # 포트 및 컨테이너명 설정 + # ============================================================ + PORT=${{ env.DEPLOY_PORT }} + + # 컨테이너 이름 설정 + if [ -z "${{ env.CONTAINER_NAME }}" ]; then + CONTAINER_NAME="${PROJECT_NAME}" + echo "ℹ️ CONTAINER_NAME이 설정되지 않아 PROJECT_NAME을 사용합니다: $CONTAINER_NAME" + else + CONTAINER_NAME="${{ env.CONTAINER_NAME }}" + echo "ℹ️ 사용자 지정 CONTAINER_NAME 사용: $CONTAINER_NAME" + fi + + echo "🚀 배포 환경으로 배포합니다 (브랜치: $BRANCH)" + + # 설정 정보 출력 + echo "📋 배포 설정 정보:" + echo " - 브랜치: $BRANCH" + echo " - 프로젝트: $PROJECT_NAME" + echo " - 컨테이너 이름: $CONTAINER_NAME" + echo " - 포트: $PORT" + echo " - Docker 이미지: ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH}" + + # ============================================================ + # Docker 이미지 풀 (Pull) + # ============================================================ + echo "⬇️ Docker 이미지 풀: ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH}" + SUDO docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH} + + # ============================================================ + # 기존 컨테이너 정리 + # ============================================================ + echo "🧹 컨테이너 $CONTAINER_NAME 존재 여부 확인 중..." + + # 동일한 이름의 컨테이너가 존재하는지 확인 + if SUDO docker ps -a --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}\$"; then + echo "⚠️ 컨테이너 $CONTAINER_NAME 이(가) 존재합니다. 중지 및 삭제 중..." + SUDO docker rm -f $CONTAINER_NAME + echo "✅ 컨테이너 $CONTAINER_NAME 이(가) 삭제되었습니다." + else + echo "ℹ️ 존재하는 컨테이너 $CONTAINER_NAME 이(가) 없습니다." + fi + + # ============================================================ + # 새 컨테이너 실행 + # ============================================================ + echo "🚀 새로운 컨테이너 $CONTAINER_NAME 실행 중..." + + # Docker 컨테이너 실행 + # - 포트 매핑: ${PORT}:${{ env.CONTAINER_INTERNAL_PORT }} (외부:내부) + # - 시간대: Asia/Seoul + # - 볼륨 마운트: 로컬 시간 동기화 및 프로젝트 데이터 + # ⚠️ .env 파일은 이미 Docker 이미지에 포함되어 있습니다 + + # 볼륨 마운트 옵션 구성 + VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro" + if [ "${{ env.ENABLE_VOLUME_MOUNT }}" == "true" ]; then + # 디렉토리가 없으면 생성 + if [ ! -d "${{ env.VOLUME_HOST_PATH }}" ]; then + echo "📁 볼륨 마운트 디렉토리 생성: ${{ env.VOLUME_HOST_PATH }}" + SUDO mkdir -p "${{ env.VOLUME_HOST_PATH }}" + fi + VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.VOLUME_HOST_PATH }}:${{ env.VOLUME_CONTAINER_PATH }}" + echo "📦 볼륨 마운트 활성화: $VOLUME_OPTS" + else + echo "ℹ️ 볼륨 마운트 비활성화됨" + fi + + SUDO docker run -d \ + -p ${PORT}:${{ env.CONTAINER_INTERNAL_PORT }} \ + --name $CONTAINER_NAME \ + -e TZ=Asia/Seoul \ + $VOLUME_OPTS \ + ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH} + + # ============================================================ + # 컨테이너 헬스체크 + # ============================================================ + echo "⏳ 컨테이너 초기화 대기 중... (${{ env.HEALTHCHECK_WAIT_SECONDS }}초)" + sleep ${{ env.HEALTHCHECK_WAIT_SECONDS }} + + # 1단계: 컨테이너 프로세스 확인 + echo "🏥 컨테이너 헬스체크 중..." + if ! SUDO docker ps --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}\$"; then + echo "❌ 컨테이너 $CONTAINER_NAME 프로세스가 실행되지 않았습니다" + SUDO docker logs --tail 50 $CONTAINER_NAME || true + exit 1 + fi + echo "✅ 컨테이너 프로세스 실행 확인 완료" + + # 2단계: HTTP 엔드포인트 체크 (설정된 경우) + HEALTHCHECK_SUCCESS=false + if [ -n "${{ env.HEALTHCHECK_PATH }}" ]; then + echo "🌐 HTTP 헬스체크 시작: http://localhost:${PORT}${{ env.HEALTHCHECK_PATH }}" + + for i in $(seq 1 ${{ env.HEALTHCHECK_MAX_RETRIES }}); do + echo " 시도 $i/${{ env.HEALTHCHECK_MAX_RETRIES }}..." + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:${PORT}${{ env.HEALTHCHECK_PATH }} || echo "000") + + if [ "$HTTP_CODE" = "200" ]; then + echo "✅ HTTP 헬스체크 성공 (응답 코드: $HTTP_CODE)" + HEALTHCHECK_SUCCESS=true + break + else + echo "⚠️ HTTP 헬스체크 실패 (응답 코드: $HTTP_CODE)" + if [ $i -lt ${{ env.HEALTHCHECK_MAX_RETRIES }} ]; then + echo " ${{ env.HEALTHCHECK_RETRY_INTERVAL }}초 후 재시도..." + sleep ${{ env.HEALTHCHECK_RETRY_INTERVAL }} + fi + fi + done + + # HTTP 체크 실패 시 fallback으로 로그 패턴 매칭 + if [ "$HEALTHCHECK_SUCCESS" = "false" ]; then + echo "⚠️ HTTP 헬스체크 최종 실패, 로그 패턴 매칭으로 fallback..." + fi + else + echo "ℹ️ HEALTHCHECK_PATH가 설정되지 않아 로그 패턴 매칭만 사용합니다" + fi + + # 3단계: 로그 패턴 매칭 (HTTP 실패 시 또는 HTTP 체크 비활성화 시) + if [ "$HEALTHCHECK_SUCCESS" = "false" ]; then + echo "📋 로그 패턴 매칭 시작: '${{ env.HEALTHCHECK_LOG_PATTERN }}'" + + if SUDO docker logs $CONTAINER_NAME 2>&1 | grep -q "${{ env.HEALTHCHECK_LOG_PATTERN }}"; then + echo "✅ 로그 패턴 매칭 성공: 애플리케이션이 정상적으로 시작되었습니다" + HEALTHCHECK_SUCCESS=true + else + echo "❌ 로그 패턴 매칭 실패: 애플리케이션 시작 로그를 찾을 수 없습니다" + echo "" + echo "📋 디버깅 정보:" + echo " - 검색 패턴: ${{ env.HEALTHCHECK_LOG_PATTERN }}" + echo " - 컨테이너: $CONTAINER_NAME" + echo " - 이미지: ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH}" + echo " - 포트: ${PORT}:${{ env.CONTAINER_INTERNAL_PORT }}" + echo " - 볼륨: $VOLUME_OPTS" + echo "" + echo "📋 컨테이너 로그 (최근 50줄):" + SUDO docker logs --tail 50 $CONTAINER_NAME || true + exit 1 + fi + fi + + # 최종 성공 메시지 + if [ "$HEALTHCHECK_SUCCESS" = "true" ]; then + echo "✅ 헬스체크 완료: 컨테이너 $CONTAINER_NAME 이(가) 정상적으로 실행 중입니다" + fi + + # ============================================================ + # 배포 완료 확인 + # ============================================================ + echo "✅ 배포가 성공적으로 완료되었습니다!" + echo "" + echo "📋 배포 결과 요약:" + echo " 🎯 프로젝트: $PROJECT_NAME" + echo " 🌿 브랜치: $BRANCH" + echo " 🐳 컨테이너: $CONTAINER_NAME" + echo " 🌐 포트: $PORT" + echo " ⏰ 배포 시간: $(date '+%Y-%m-%d %H:%M:%S')" + echo "" + echo "🔗 접속 URL: http://${{ secrets.SERVER_HOST }}:${PORT}/docs" diff --git a/.github/workflows/PROJECT-REACT-CI.yaml b/.github/workflows/PROJECT-REACT-CI.yaml index 37dadcd7..60d146fc 100644 --- a/.github/workflows/PROJECT-REACT-CI.yaml +++ b/.github/workflows/PROJECT-REACT-CI.yaml @@ -32,7 +32,7 @@ on: workflow_dispatch: env: - PROJECT_NAME: "project" + PROJECT_NAME: "passQL" NODE_VERSION: "20.15.0" ENV_FILE: ".env" diff --git a/.github/workflows/PROJECT-REACT-CICD.yaml b/.github/workflows/PROJECT-REACT-CICD.yaml index 22764e79..71e3ce41 100644 --- a/.github/workflows/PROJECT-REACT-CICD.yaml +++ b/.github/workflows/PROJECT-REACT-CICD.yaml @@ -22,10 +22,11 @@ on: push: branches: - deploy + paths: ['client/**'] workflow_dispatch: env: - PROJECT_NAME: "project" + PROJECT_NAME: "passQL" DOCKER_IMAGE_PREFIX: "front-container" NODE_VERSION: "20.15.0" ENV_FILE_NAME: ".env" diff --git a/.github/workflows/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml b/.github/workflows/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml new file mode 100644 index 00000000..a83b1408 --- /dev/null +++ b/.github/workflows/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml @@ -0,0 +1,535 @@ +# =================================================================== +# Spring Boot 무중단 배포 (Nginx Blue-Green, SSH + Docker) +# =================================================================== +# +# deploy 브랜치 푸시 시 Docker 이미지를 빌드하고 +# nginx config 의 proxy_pass 포트 토글로 Blue-Green 무중단 배포합니다. +# +# 본 워크플로우는 무중단 배포 옵션입니다. +# 기본 배포는 PROJECT-SPRING-SIMPLE-CICD.yaml 입니다. +# 전환하려면 아래 push.branches 주석 해제 + SIMPLE-CICD 의 push 트리거 주석 처리. +# +# =================================================================== +# 🔑 필수 GitHub Secrets +# =================================================================== +# APPLICATION_PROD_YML (선택): application-prod.yml 내용 +# DOCKERHUB_USERNAME: DockerHub 사용자명 +# DOCKERHUB_TOKEN: DockerHub 액세스 토큰 +# SERVER_HOST: 배포 서버 주소 +# SERVER_USER: SSH 사용자명 +# SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때) +# SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등) +# =================================================================== +# +# 🔧 환경변수 설정 (env 섹션에서 설정) +# =================================================================== +# +# 🌐 포트 및 컨테이너: +# PROJECT_NAME: 프로젝트명 (컨테이너/이미지 이름) +# SERVICE_DOMAIN: 외부 도메인 (nginx server_name 과 일치) +# CONTAINER_INTERNAL_PORT: Spring Boot 내부 포트 (기본: 8080) +# BLUE_PORT: Blue 호스트 노출 포트 (기본: 8080) +# GREEN_PORT: Green 호스트 노출 포트 (기본: 8081) +# +# 🔧 Nginx: +# NGINX_RP_CONF: nginx 역방향 프록시 config 파일 경로 +# NGINX_BACKUP_DIR: nginx config 백업 디렉토리 +# NGINX_BACKUP_KEEP: 백업 보존 개수 (기본: 10) +# +# 📦 볼륨/스토리지: +# ENABLE_VOLUME_MOUNT: 볼륨 마운트 여부 (true/false) +# VOLUME_HOST_PATH: 호스트 볼륨 경로 +# VOLUME_CONTAINER_PATH: 컨테이너 내부 마운트 경로 +# +# 🔌 SSH 연결: +# SSH_PORT: SSH 포트 (기본: 2022) +# SSH_COMMAND_TIMEOUT: 명령 타임아웃 (기본: 600s) +# SSH_CONNECTION_TIMEOUT: 연결 타임아웃 (기본: 60s) +# +# ⏱️ 헬스체크: +# HEALTHCHECK_PATH: HTTP 경로 (기본: /actuator/health, fallback: /) +# HEALTHCHECK_MAX_RETRIES: 컨테이너 헬스체크 최대 횟수 (기본: 120) +# HEALTHCHECK_RETRY_INTERVAL: 재시도 간격 초 (기본: 1) +# DOMAIN_CHECK_RETRIES: nginx reload 후 도메인 검증 횟수 (기본: 10) +# DOMAIN_CHECK_INTERVAL: 도메인 검증 간격 초 (기본: 2) +# IN_FLIGHT_WAIT: old 컨테이너 제거 전 대기 초 (기본: 5) +# +# 💡 사전 조건: +# - 서버에 nginx 설치 + sites-enabled config 존재 +# - 해당 config 의 server 블록에 다음 라인 존재: +# server_name ${SERVICE_DOMAIN}; +# proxy_pass http://127.0.0.1:${BLUE_PORT}; # 또는 ${GREEN_PORT} +# - SSH 사용자가 sudo 로 nginx -t / reload, docker 명령 실행 가능 +# =================================================================== + +name: PROJECT-SPRING-NONSTOP-NGINX-CICD + +# =================================================================== +# 트리거 설정 +# =================================================================== +# deploy 브랜치 push 자동 트리거는 주석 처리되어 있습니다. +# 본 워크플로우로 전환하려면 아래 push.branches 주석을 해제하세요. +on: + # push: + # branches: + # - deploy + workflow_dispatch: + +# =================================================================== +# 환경 변수 설정 +# =================================================================== +env: + # 🔧 프로젝트 설정 - 실제 프로젝트명으로 변경하세요 + PROJECT_NAME: "passQL" + SERVICE_DOMAIN: "example.com" + + # ☁️ Spring Boot 설정 + SPRING_PROFILE: "prod" + JAVA_VERSION: "21" + GRADLE_OPTS: "-Dspring.profiles.active=prod" + + # 📁 경로 설정 + APPLICATION_YML_DIR: "server/PQL-Web/src/main/resources" + DOCKERFILE_PATH: "./Dockerfile" + + # 🌐 포트 및 컨테이너 설정 + CONTAINER_INTERNAL_PORT: "8080" + BLUE_PORT: "8080" + GREEN_PORT: "8081" + + # 🔧 Nginx 설정 + NGINX_RP_CONF: "/etc/nginx/sites-enabled/example.conf" + NGINX_BACKUP_DIR: "/volume1/project/nginx/backups" + NGINX_BACKUP_KEEP: "10" + + # 📦 볼륨 마운트 설정 + ENABLE_VOLUME_MOUNT: "false" + VOLUME_HOST_PATH: "/volume1/projects/passQL" + VOLUME_CONTAINER_PATH: "/app" + + # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem) + # password → SERVER_PASSWORD secret 사용 + # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로) + SSH_AUTH_METHOD: "password" + + # 🔌 SSH 연결 설정 + SSH_PORT: "2022" + SSH_COMMAND_TIMEOUT: "600s" + SSH_CONNECTION_TIMEOUT: "60s" + + # ⏱️ 헬스체크 설정 + HEALTHCHECK_PATH: "/actuator/health" + HEALTHCHECK_MAX_RETRIES: "120" + HEALTHCHECK_RETRY_INTERVAL: "1" + DOMAIN_CHECK_RETRIES: "10" + DOMAIN_CHECK_INTERVAL: "2" + IN_FLIGHT_WAIT: "5" + +jobs: + # =================================================================== + # 빌드 작업 + # =================================================================== + build: + name: Spring Boot 애플리케이션 빌드 + runs-on: ubuntu-latest + + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v5 + + - name: Java 설정 + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: 'gradle' + + - name: Gradle Wrapper 실행권한 부여 + run: chmod +x gradlew + + - name: application-prod.yml 생성 + run: | + mkdir -p ${{ env.APPLICATION_YML_DIR }} + cat << 'EOF' > ./${{ env.APPLICATION_YML_DIR }}/application-prod.yml + ${{ secrets.APPLICATION_PROD_YML }} + EOF + echo "✅ application-prod.yml 파일 생성 완료 (${{ env.APPLICATION_YML_DIR }})" + + - name: Build with Gradle + run: ./gradlew clean build -x test ${{ env.GRADLE_OPTS }} + + - name: Docker 빌드환경 설정 + uses: docker/setup-buildx-action@v3 + + - name: DockerHub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile') }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:${{ github.ref_name }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + - name: Move Docker cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + # =================================================================== + # 배포 작업 (Blue-Green 무중단) + # =================================================================== + deploy: + name: Blue-Green 무중단 배포 + needs: build + runs-on: ubuntu-latest + + steps: + - name: Deploy + uses: appleboy/ssh-action@v1.0.3 + env: + PROJECT_NAME: ${{ env.PROJECT_NAME }} + SERVICE_DOMAIN: ${{ env.SERVICE_DOMAIN }} + SPRING_PROFILE: ${{ env.SPRING_PROFILE }} + CONTAINER_INTERNAL_PORT: ${{ env.CONTAINER_INTERNAL_PORT }} + BLUE_PORT: ${{ env.BLUE_PORT }} + GREEN_PORT: ${{ env.GREEN_PORT }} + ENABLE_VOLUME_MOUNT: ${{ env.ENABLE_VOLUME_MOUNT }} + VOLUME_HOST_PATH: ${{ env.VOLUME_HOST_PATH }} + VOLUME_CONTAINER_PATH: ${{ env.VOLUME_CONTAINER_PATH }} + NGINX_RP_CONF: ${{ env.NGINX_RP_CONF }} + NGINX_BACKUP_DIR: ${{ env.NGINX_BACKUP_DIR }} + NGINX_BACKUP_KEEP: ${{ env.NGINX_BACKUP_KEEP }} + HEALTHCHECK_MAX_RETRIES: ${{ env.HEALTHCHECK_MAX_RETRIES }} + HEALTHCHECK_RETRY_INTERVAL: ${{ env.HEALTHCHECK_RETRY_INTERVAL }} + HEALTHCHECK_PATH: ${{ env.HEALTHCHECK_PATH }} + DOMAIN_CHECK_RETRIES: ${{ env.DOMAIN_CHECK_RETRIES }} + DOMAIN_CHECK_INTERVAL: ${{ env.DOMAIN_CHECK_INTERVAL }} + IN_FLIGHT_WAIT: ${{ env.IN_FLIGHT_WAIT }} + BRANCH_REF: ${{ github.ref_name }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }} + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + command_timeout: ${{ env.SSH_COMMAND_TIMEOUT }} + timeout: ${{ env.SSH_CONNECTION_TIMEOUT }} + envs: PROJECT_NAME,SERVICE_DOMAIN,SPRING_PROFILE,CONTAINER_INTERNAL_PORT,BLUE_PORT,GREEN_PORT,ENABLE_VOLUME_MOUNT,VOLUME_HOST_PATH,VOLUME_CONTAINER_PATH,NGINX_RP_CONF,NGINX_BACKUP_DIR,NGINX_BACKUP_KEEP,HEALTHCHECK_MAX_RETRIES,HEALTHCHECK_RETRY_INTERVAL,HEALTHCHECK_PATH,DOMAIN_CHECK_RETRIES,DOMAIN_CHECK_INTERVAL,IN_FLIGHT_WAIT,BRANCH_REF,DOCKERHUB_USERNAME,SERVER_PASSWORD,SSH_AUTH_METHOD + script: | + set -e + export PATH=$PATH:/usr/local/bin + export PW="${SERVER_PASSWORD}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + IMAGE="${DOCKERHUB_USERNAME}/${PROJECT_NAME}:${BRANCH_REF}" + BLUE_CONTAINER_NAME="${PROJECT_NAME}-blue" + GREEN_CONTAINER_NAME="${PROJECT_NAME}-green" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Nginx Blue-Green 무중단 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🌐 도메인: ${SERVICE_DOMAIN}" + echo "🖼️ 이미지: ${IMAGE}" + echo "🎨 Blue=${BLUE_PORT} | Green=${GREEN_PORT}" + echo "🔧 Nginx config: ${NGINX_RP_CONF}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # ============================================================ + # 1. 최신 이미지 Pull + # ============================================================ + echo "📥 이미지 Pull: ${IMAGE}" + SUDO docker pull "${IMAGE}" + + # ============================================================ + # 2. nginx 설정 백업 + # ============================================================ + echo "📁 nginx 백업 디렉토리 생성: ${NGINX_BACKUP_DIR}" + SUDO mkdir -p "${NGINX_BACKUP_DIR}" + + BACKUP_FILE="${NGINX_BACKUP_DIR}/server.ReverseProxy.conf.$(date +%Y%m%d_%H%M%S).bak" + echo "💾 nginx config 백업: ${BACKUP_FILE}" + SUDO cp "${NGINX_RP_CONF}" "${BACKUP_FILE}" + + # ============================================================ + # 3. 현재 활성 포트 판별 (nginx config proxy_pass 파싱) + # ============================================================ + echo "🔍 nginx config 의 현재 활성 포트 탐색" + ACTIVE_PORT=$( + SUDO awk -v dom="${SERVICE_DOMAIN}" ' + BEGIN { + inserver=0; depth=0; entry_depth=0; + dom_esc = dom; + gsub(/[.[\]()+*?^$\\|]/, "\\\\&", dom_esc); + } + { + raw = $0; + line = raw; + sub(/#.*/, "", line); + depth += gsub(/\{/, "{", line); + depth -= gsub(/\}/, "}", line); + + if (!inserver && line ~ /^[[:space:]]*server_name[[:space:]]+/) { + tmp = line; + sub(/^[[:space:]]*server_name[[:space:]]+/, "", tmp); + sub(/[[:space:]]*;.*/, "", tmp); + if (match(tmp, "(^|[[:space:]])" dom_esc "([[:space:]]|$)")) { + inserver=1; entry_depth=depth; + } + } + + if (inserver && line ~ /proxy_pass[[:space:]]+https?:\/\/(localhost|127\.0\.0\.1):[0-9]{2,5}/) { + if (match(line, /https?:\/\/(localhost|127\.0\.0\.1):([0-9]{2,5})/, a)) { print a[2]; exit } + } + + if (inserver && (depth < entry_depth || (depth==0 && entry_depth==0))) inserver=0; + } + ' "${NGINX_RP_CONF}" || true + ) + + if [ -z "${ACTIVE_PORT}" ]; then + echo "⚠️ 활성 포트를 찾지 못함 → 기본값(BLUE=${BLUE_PORT})을 활성으로 간주" + ACTIVE_PORT="${BLUE_PORT}" + fi + echo "🎨 현재 활성 포트: ${ACTIVE_PORT}" + + if [ "${ACTIVE_PORT}" = "${BLUE_PORT}" ]; then + NEW_PORT="${GREEN_PORT}" + NEW_CONTAINER_NAME="${GREEN_CONTAINER_NAME}" + OLD_CONTAINER_NAME="${BLUE_CONTAINER_NAME}" + else + NEW_PORT="${BLUE_PORT}" + NEW_CONTAINER_NAME="${BLUE_CONTAINER_NAME}" + OLD_CONTAINER_NAME="${GREEN_CONTAINER_NAME}" + fi + + echo "🐳 신규 배포 → 포트=${NEW_PORT} 컨테이너=${NEW_CONTAINER_NAME}" + echo "🗑️ 이전 컨테이너 (제거 예정) = ${OLD_CONTAINER_NAME}" + + # ============================================================ + # 4. 동일 슬롯(idle 쪽) 잔존 컨테이너 정리 + # ============================================================ + if SUDO docker ps -a --format '{{.Names}}' | grep -Eq "^${NEW_CONTAINER_NAME}\$"; then + echo "🗑️ idle 슬롯 정리: ${NEW_CONTAINER_NAME}" + SUDO docker rm -f "${NEW_CONTAINER_NAME}" + fi + + # ============================================================ + # 5. 볼륨 마운트 옵션 구성 + # ============================================================ + VOLUME_MOUNT_OPTION="-v /etc/localtime:/etc/localtime:ro" + if [ "${ENABLE_VOLUME_MOUNT}" = "true" ]; then + if [ ! -d "${VOLUME_HOST_PATH}" ]; then + echo "📁 볼륨 마운트 디렉토리 생성: ${VOLUME_HOST_PATH}" + SUDO mkdir -p "${VOLUME_HOST_PATH}" + fi + VOLUME_MOUNT_OPTION="${VOLUME_MOUNT_OPTION} -v ${VOLUME_HOST_PATH}:${VOLUME_CONTAINER_PATH}" + echo "📦 볼륨 마운트 활성화: ${VOLUME_HOST_PATH} → ${VOLUME_CONTAINER_PATH}" + else + echo "ℹ️ 볼륨 마운트 비활성화됨" + fi + + # ============================================================ + # 6. 새 컨테이너 기동 + # ============================================================ + echo "🐳 새 컨테이너 기동: ${NEW_CONTAINER_NAME} (포트 ${NEW_PORT})" + SUDO docker run -d \ + -p ${NEW_PORT}:${CONTAINER_INTERNAL_PORT} \ + --name "${NEW_CONTAINER_NAME}" \ + -e TZ=Asia/Seoul \ + -e "SPRING_PROFILES_ACTIVE=${SPRING_PROFILE}" \ + ${VOLUME_MOUNT_OPTION} \ + "${IMAGE}" + + # ============================================================ + # 7. 컨테이너 헬스체크 (로컬 포트 직접 호출) + # ============================================================ + echo "⏳ 헬스체크 시작 (최대 ${HEALTHCHECK_MAX_RETRIES}회 × ${HEALTHCHECK_RETRY_INTERVAL}초)" + HEALTHCHECK_SUCCESS=false + for i in $(seq 1 ${HEALTHCHECK_MAX_RETRIES}); do + if curl -fsS "http://127.0.0.1:${NEW_PORT}${HEALTHCHECK_PATH}" >/dev/null 2>&1 || \ + curl -fsS "http://127.0.0.1:${NEW_PORT}/" >/dev/null 2>&1 ; then + echo "✅ 헬스체크 성공 (시도 ${i})" + HEALTHCHECK_SUCCESS=true + break + fi + echo "⏳ 헬스체크 진행중... (${i}/${HEALTHCHECK_MAX_RETRIES})" + sleep ${HEALTHCHECK_RETRY_INTERVAL} + done + + if [ "${HEALTHCHECK_SUCCESS}" != "true" ]; then + echo "❌ 헬스체크 실패 → 배포 중단" + SUDO docker logs --tail 200 "${NEW_CONTAINER_NAME}" || true + echo "🗑️ 신규 컨테이너 정리: ${NEW_CONTAINER_NAME}" + SUDO docker rm -f "${NEW_CONTAINER_NAME}" || true + exit 1 + fi + + # ============================================================ + # 8. nginx config 포트 토글 (proxy_pass 포트 치환) + # ============================================================ + echo "🔧 nginx config 포트 토글: ${ACTIVE_PORT} → ${NEW_PORT}" + TMP_FILE="$(mktemp)" + SUDO awk -v dom="${SERVICE_DOMAIN}" -v new_port="${NEW_PORT}" ' + BEGIN { + inserver=0; depth=0; entry_depth=0; + dom_esc = dom; + gsub(/[.[\]()+*?^$\\|]/, "\\\\&", dom_esc); + } + { + if ($0 ~ "server_name[[:space:]]+" dom_esc "[[:space:]]*;") { + inserver=1; + entry_depth=depth; + } + + line = $0; + sub(/#.*/, "", line); + depth += gsub(/\{/, "{", line); + depth -= gsub(/\}/, "}", line); + + if (inserver && \ + $0 !~ /^[[:space:]]*#/ && \ + $0 ~ /proxy_pass[[:space:]]+https?:\/\/(localhost|127\.0\.0\.1):[0-9]{2,5}/) { + sub(/:[0-9]{2,5}/, ":" new_port); + } + + print; + + if (inserver && (depth < entry_depth || (depth == 0 && entry_depth == 0))) { + inserver=0; + } + } + ' "${NGINX_RP_CONF}" > "${TMP_FILE}" + + # ============================================================ + # 9. nginx 설정 검증 + reload (실패 시 백업 복구) + # ============================================================ + echo "🔧 nginx 설정 적용 및 검증" + SUDO cp "${TMP_FILE}" "${NGINX_RP_CONF}" + if ! (SUDO nginx -t); then + echo "❌ nginx 설정 검증 실패 → 백업 파일로 복구" + SUDO cp "${BACKUP_FILE}" "${NGINX_RP_CONF}" + SUDO rm -f "${BACKUP_FILE}" + rm -f "${TMP_FILE}" + echo "🗑️ 신규 컨테이너 정리: ${NEW_CONTAINER_NAME}" + SUDO docker rm -f "${NEW_CONTAINER_NAME}" || true + exit 1 + fi + echo "✅ nginx 설정 검증 성공" + rm -f "${TMP_FILE}" + + echo "🔄 nginx reload" + if ! (SUDO systemctl reload nginx); then + echo "⚠️ reload 실패 → restart 시도" + if ! (SUDO systemctl restart nginx); then + echo "❌ nginx reload/restart 실패 → 백업 복구" + SUDO cp "${BACKUP_FILE}" "${NGINX_RP_CONF}" + SUDO systemctl restart nginx + SUDO rm -f "${BACKUP_FILE}" + echo "🗑️ 신규 컨테이너 정리: ${NEW_CONTAINER_NAME}" + SUDO docker rm -f "${NEW_CONTAINER_NAME}" || true + exit 1 + fi + fi + + # ============================================================ + # 10. 도메인 접근 검증 + # ============================================================ + echo "🌐 도메인 접근 검증 (${SERVICE_DOMAIN})" + DOMAIN_OK=false + for i in $(seq 1 ${DOMAIN_CHECK_RETRIES}); do + if curl -fsS "https://${SERVICE_DOMAIN}${HEALTHCHECK_PATH}" >/dev/null 2>&1 || \ + curl -fsS "https://${SERVICE_DOMAIN}/" >/dev/null 2>&1 ; then + echo "✅ 도메인 접근 검증 성공 (시도 ${i})" + DOMAIN_OK=true + break + fi + echo "⏳ 도메인 접근 검증 중... (${i}/${DOMAIN_CHECK_RETRIES})" + sleep ${DOMAIN_CHECK_INTERVAL} + done + + if [ "${DOMAIN_OK}" != "true" ]; then + echo "⚠️ 도메인 접근 검증 실패: 배포 진행 (DNS 전파 지연 가능성)" + fi + + # ============================================================ + # 11. 이전 컨테이너 제거 + # ============================================================ + echo "⏱️ in-flight 요청 처리 대기 (${IN_FLIGHT_WAIT}초)" + sleep ${IN_FLIGHT_WAIT} + + if SUDO docker ps -a --format '{{.Names}}' | grep -Eq "^${OLD_CONTAINER_NAME}\$"; then + echo "🗑️ old=${OLD_CONTAINER_NAME} 제거" + SUDO docker rm -f "${OLD_CONTAINER_NAME}" || true + else + echo "ℹ️ old=${OLD_CONTAINER_NAME} 없음 — 정리 생략" + fi + + # ============================================================ + # 12. dangling 이미지 정리 + 백업 보존 + # ============================================================ + echo "🧹 dangling() 이미지 정리..." + SUDO docker image prune -af + + echo "🧹 백업 파일 최신 ${NGINX_BACKUP_KEEP}개 보존" + KEEP_PLUS_ONE=$((NGINX_BACKUP_KEEP + 1)) + SUDO /bin/bash -c "ls -1t '${NGINX_BACKUP_DIR}'/server.ReverseProxy.conf.*.bak 2>/dev/null | tail -n +${KEEP_PLUS_ONE} | xargs -r rm -f" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 무중단 배포 완료. active=${NEW_CONTAINER_NAME} (포트 ${NEW_PORT})" + echo "🌐 도메인: https://${SERVICE_DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# =================================================================== +# 📝 사용 방법 +# =================================================================== +# +# 1) GitHub Secrets 등록: +# APPLICATION_PROD_YML, DOCKERHUB_USERNAME, DOCKERHUB_TOKEN, +# SERVER_HOST, SERVER_USER, SERVER_PASSWORD +# +# 2) 본 워크플로우의 env 블록에서 프로젝트별 설정 수정: +# - PROJECT_NAME, SERVICE_DOMAIN +# - APPLICATION_YML_DIR, CONTAINER_INTERNAL_PORT +# - BLUE_PORT, GREEN_PORT (서로 다른 호스트 포트) +# - NGINX_RP_CONF, NGINX_BACKUP_DIR +# - ENABLE_VOLUME_MOUNT, VOLUME_HOST_PATH, VOLUME_CONTAINER_PATH +# +# 3) 서버 사전 준비: +# - nginx 설치 + sites-enabled config 존재 +# - 해당 config server 블록에 다음 라인 존재: +# server_name ${SERVICE_DOMAIN}; +# proxy_pass http://127.0.0.1:${BLUE_PORT}; # 또는 ${GREEN_PORT} +# - SSH 사용자가 sudo 로 nginx -t / reload, docker 명령 실행 가능 +# +# 4) 본 워크플로우로 전환: +# - 본 파일의 push.branches 주석 해제 +# - PROJECT-SPRING-SIMPLE-CICD.yaml 의 push 트리거 주석 처리 +# +# =================================================================== diff --git a/.github/workflows/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml b/.github/workflows/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml new file mode 100644 index 00000000..f0118eb7 --- /dev/null +++ b/.github/workflows/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml @@ -0,0 +1,437 @@ +# =================================================================== +# Spring Boot 무중단 배포 (Traefik Blue-Green, SSH + Docker) +# =================================================================== +# +# deploy 브랜치 푸시 시 Docker 이미지를 빌드하고 +# Traefik 라벨 기반 Blue-Green 토글로 서버(SSH 접속 가능한 호스트)에 무중단 배포합니다. +# +# 본 워크플로우는 무중단 배포 옵션입니다. +# 기본 배포는 PROJECT-SPRING-SIMPLE-CICD.yaml 입니다. +# 전환하려면 아래 push.branches 주석 해제 + SIMPLE-CICD 의 push 트리거 주석 처리. +# +# =================================================================== +# 🔑 필수 GitHub Secrets +# =================================================================== +# APPLICATION_PROD_YML (선택): application-prod.yml 내용 +# DOCKERHUB_USERNAME: DockerHub 사용자명 +# DOCKERHUB_TOKEN: DockerHub 액세스 토큰 +# SERVER_HOST: 배포 서버 주소 +# SERVER_USER: SSH 사용자명 +# SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때) +# SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등) +# =================================================================== +# +# 🔧 환경변수 설정 (env 섹션에서 설정) +# =================================================================== +# +# 🌐 포트 및 컨테이너: +# PROJECT_NAME: 프로젝트명 (컨테이너/이미지/Traefik router 이름) +# SERVICE_DOMAIN: 외부 진입 도메인 +# CONTAINER_INTERNAL_PORT: Spring Boot 내부 포트 (기본: 8080) +# +# 🚦 Traefik: +# TRAEFIK_NETWORK: Traefik docker 네트워크 (기본: traefik-network) +# TRAEFIK_ENTRYPOINT: Traefik entrypoint 이름 (기본: web) +# TRAEFIK_INTERNAL_PORT: NAS 호스트의 Traefik 진입 포트 (기본: 8079) +# EXTRA_NETWORKS: 추가 docker 네트워크 (공백 구분, 예: selenium-chrome-network) +# +# 📦 볼륨/스토리지: +# ENABLE_VOLUME_MOUNT: 볼륨 마운트 여부 (true/false) +# VOLUME_HOST_PATH: 호스트 볼륨 경로 +# VOLUME_CONTAINER_PATH: 컨테이너 내부 마운트 경로 +# +# 🔌 SSH 연결: +# SSH_PORT: SSH 포트 (기본: 2022) +# SSH_COMMAND_TIMEOUT: 명령 타임아웃 (기본: 600s) +# SSH_CONNECTION_TIMEOUT: 연결 타임아웃 (기본: 60s) +# +# ⏱️ 헬스체크: +# HEALTHCHECK_PATH: Traefik 통한 GET 호출 path (기본: /) +# HEALTHCHECK_ACCEPT_CODES: 통과 인정 HTTP 코드 (| 구분, 기본: 200|301|302|308) +# HEALTHCHECK_MAX_RETRIES: 최대 시도 횟수 (기본: 36) +# HEALTHCHECK_RETRY_INTERVAL: 재시도 간격 초 (기본: 5) +# IN_FLIGHT_WAIT: old 컨테이너 제거 전 대기 초 (기본: 10) +# +# 💡 사전 조건: +# - 배포 서버에 Traefik 컨테이너가 traefik-network 에 띄워져 있어야 함 +# - 역방향 프록시: ${SERVICE_DOMAIN}:443 → localhost:${TRAEFIK_INTERNAL_PORT} +# (예: Synology DSM 역방향 프록시, Nginx, Caddy 등) +# =================================================================== + +name: PROJECT-SPRING-NONSTOP-TRAEFIK-CICD + +# =================================================================== +# 트리거 설정 +# =================================================================== +# deploy 브랜치 push 자동 트리거는 주석 처리되어 있습니다. +# 본 워크플로우로 전환하려면 아래 push.branches 주석을 해제하세요. +on: + # push: + # branches: + # - deploy + workflow_dispatch: + +# =================================================================== +# 환경 변수 설정 +# =================================================================== +env: + # 🔧 프로젝트 설정 - 실제 프로젝트명으로 변경하세요 + PROJECT_NAME: "passQL" + SERVICE_DOMAIN: "example.com" + + # ☁️ Spring Boot 설정 + SPRING_PROFILE: "prod" + JAVA_VERSION: "21" + GRADLE_OPTS: "-Dspring.profiles.active=prod" + + # 📁 경로 설정 + APPLICATION_YML_DIR: "server/PQL-Web/src/main/resources" + DOCKERFILE_PATH: "./Dockerfile" + + # 🌐 포트 및 컨테이너 설정 + CONTAINER_INTERNAL_PORT: "8080" + + # 🚦 Traefik 설정 (서버의 Traefik 환경 구축 후 수정 금지) + TRAEFIK_NETWORK: "traefik-network" + TRAEFIK_ENTRYPOINT: "web" + TRAEFIK_INTERNAL_PORT: "8079" + EXTRA_NETWORKS: "" + + # 📦 볼륨 마운트 설정 + ENABLE_VOLUME_MOUNT: "false" + VOLUME_HOST_PATH: "/volume1/projects/passQL" + VOLUME_CONTAINER_PATH: "/mnt/passQL" + + # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem) + # password → SERVER_PASSWORD secret 사용 + # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로) + SSH_AUTH_METHOD: "password" + + # 🔌 SSH 연결 설정 + SSH_PORT: "2022" + SSH_COMMAND_TIMEOUT: "600s" + SSH_CONNECTION_TIMEOUT: "60s" + + # ⏱️ 헬스체크 설정 + HEALTHCHECK_PATH: "/" + HEALTHCHECK_ACCEPT_CODES: "200|301|302|308" + HEALTHCHECK_MAX_RETRIES: "36" + HEALTHCHECK_RETRY_INTERVAL: "5" + IN_FLIGHT_WAIT: "10" + +jobs: + # =================================================================== + # 빌드 작업 + # =================================================================== + build: + name: Spring Boot 애플리케이션 빌드 + runs-on: ubuntu-latest + + steps: + - name: 코드 체크아웃 + uses: actions/checkout@v5 + + - name: Java 설정 + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: 'gradle' + + - name: Gradle Wrapper 실행권한 부여 + run: chmod +x gradlew + + - name: application-prod.yml 생성 + run: | + mkdir -p ${{ env.APPLICATION_YML_DIR }} + cat << 'EOF' > ./${{ env.APPLICATION_YML_DIR }}/application-prod.yml + ${{ secrets.APPLICATION_PROD_YML }} + EOF + echo "✅ application-prod.yml 파일 생성 완료 (${{ env.APPLICATION_YML_DIR }})" + + - name: Build with Gradle + run: ./gradlew clean build -x test ${{ env.GRADLE_OPTS }} + + - name: Docker 빌드환경 설정 + uses: docker/setup-buildx-action@v3 + + - name: DockerHub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile') }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:${{ github.ref_name }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + - name: Move Docker cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + # =================================================================== + # 배포 작업 (Blue-Green 무중단) + # =================================================================== + deploy: + name: Blue-Green 무중단 배포 + needs: build + runs-on: ubuntu-latest + + steps: + - name: Deploy + uses: appleboy/ssh-action@v1.0.3 + env: + PROJECT_NAME: ${{ env.PROJECT_NAME }} + SERVICE_DOMAIN: ${{ env.SERVICE_DOMAIN }} + CONTAINER_INTERNAL_PORT: ${{ env.CONTAINER_INTERNAL_PORT }} + SPRING_PROFILE: ${{ env.SPRING_PROFILE }} + ENABLE_VOLUME_MOUNT: ${{ env.ENABLE_VOLUME_MOUNT }} + VOLUME_HOST_PATH: ${{ env.VOLUME_HOST_PATH }} + VOLUME_CONTAINER_PATH: ${{ env.VOLUME_CONTAINER_PATH }} + EXTRA_NETWORKS: ${{ env.EXTRA_NETWORKS }} + TRAEFIK_NETWORK: ${{ env.TRAEFIK_NETWORK }} + TRAEFIK_ENTRYPOINT: ${{ env.TRAEFIK_ENTRYPOINT }} + TRAEFIK_INTERNAL_PORT: ${{ env.TRAEFIK_INTERNAL_PORT }} + HEALTHCHECK_PATH: ${{ env.HEALTHCHECK_PATH }} + HEALTHCHECK_ACCEPT_CODES: ${{ env.HEALTHCHECK_ACCEPT_CODES }} + HEALTHCHECK_MAX_RETRIES: ${{ env.HEALTHCHECK_MAX_RETRIES }} + HEALTHCHECK_RETRY_INTERVAL: ${{ env.HEALTHCHECK_RETRY_INTERVAL }} + IN_FLIGHT_WAIT: ${{ env.IN_FLIGHT_WAIT }} + BRANCH_REF: ${{ github.ref_name }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }} + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + command_timeout: ${{ env.SSH_COMMAND_TIMEOUT }} + timeout: ${{ env.SSH_CONNECTION_TIMEOUT }} + envs: PROJECT_NAME,SERVICE_DOMAIN,CONTAINER_INTERNAL_PORT,SPRING_PROFILE,ENABLE_VOLUME_MOUNT,VOLUME_HOST_PATH,VOLUME_CONTAINER_PATH,EXTRA_NETWORKS,TRAEFIK_NETWORK,TRAEFIK_ENTRYPOINT,TRAEFIK_INTERNAL_PORT,HEALTHCHECK_PATH,HEALTHCHECK_ACCEPT_CODES,HEALTHCHECK_MAX_RETRIES,HEALTHCHECK_RETRY_INTERVAL,IN_FLIGHT_WAIT,BRANCH_REF,DOCKERHUB_USERNAME,SERVER_PASSWORD,SSH_AUTH_METHOD + script: | + set -e + export PATH=$PATH:/usr/local/bin + export PW="${SERVER_PASSWORD}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + IMAGE="${DOCKERHUB_USERNAME}/${PROJECT_NAME}:${BRANCH_REF}" + ROUTER_NAME="${PROJECT_NAME}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Blue-Green 무중단 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🌐 도메인: ${SERVICE_DOMAIN}" + echo "🖼️ 이미지: ${IMAGE}" + echo "🔧 Traefik 진입: localhost:${TRAEFIK_INTERNAL_PORT} (entrypoint=${TRAEFIK_ENTRYPOINT})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # ============================================================ + # 1. 최신 이미지 Pull + # ============================================================ + echo "📥 이미지 Pull: ${IMAGE}" + SUDO docker pull "${IMAGE}" + + # ============================================================ + # 2. 현재 active 컨테이너 색 판별 (Traefik 라벨 부착 여부 기준) + # ============================================================ + ACTIVE=$(SUDO docker ps \ + --filter "label=traefik.http.routers.${ROUTER_NAME}.rule" \ + --filter "name=${PROJECT_NAME}-" \ + --format "{{.Names}}" | head -1 || echo "") + + if [ "$ACTIVE" = "${PROJECT_NAME}-blue" ]; then + NEW_COLOR="green" + OLD_COLOR="blue" + elif [ "$ACTIVE" = "${PROJECT_NAME}-green" ]; then + NEW_COLOR="blue" + OLD_COLOR="green" + else + # 최초 배포 또는 active 없음 → blue 부터 시작 + NEW_COLOR="blue" + OLD_COLOR="green" + fi + + NEW_NAME="${PROJECT_NAME}-${NEW_COLOR}" + OLD_NAME="${PROJECT_NAME}-${OLD_COLOR}" + + echo "🎨 active=${ACTIVE:-none} → new=${NEW_NAME}, old=${OLD_NAME}" + + # ============================================================ + # 3. 동일 슬롯(idle 쪽) 잔존 컨테이너 정리 + # ============================================================ + echo "🗑️ idle 슬롯 정리: ${NEW_NAME}" + SUDO docker rm -f "${NEW_NAME}" 2>/dev/null || true + + # ============================================================ + # 4. 볼륨 마운트 옵션 구성 + # ============================================================ + VOLUME_MOUNT_OPTION="-v /etc/localtime:/etc/localtime:ro" + if [ "${ENABLE_VOLUME_MOUNT}" = "true" ]; then + if [ ! -d "${VOLUME_HOST_PATH}" ]; then + echo "📁 볼륨 마운트 디렉토리 생성: ${VOLUME_HOST_PATH}" + SUDO mkdir -p "${VOLUME_HOST_PATH}" + fi + VOLUME_MOUNT_OPTION="${VOLUME_MOUNT_OPTION} -v ${VOLUME_HOST_PATH}:${VOLUME_CONTAINER_PATH}" + echo "📦 볼륨 마운트 활성화: ${VOLUME_HOST_PATH} → ${VOLUME_CONTAINER_PATH}" + else + echo "ℹ️ 볼륨 마운트 비활성화됨" + fi + + # ============================================================ + # 5. 새 컨테이너 기동 (Traefik 라벨 포함) + # ============================================================ + echo "🐳 새 컨테이너 기동: ${NEW_NAME}" + SUDO docker run -d \ + --name "${NEW_NAME}" \ + --network "${TRAEFIK_NETWORK}" \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${ROUTER_NAME}.rule=Host(\`${SERVICE_DOMAIN}\`)" \ + --label "traefik.http.routers.${ROUTER_NAME}.entrypoints=${TRAEFIK_ENTRYPOINT}" \ + --label "traefik.http.services.${ROUTER_NAME}.loadbalancer.server.port=${CONTAINER_INTERNAL_PORT}" \ + -e TZ=Asia/Seoul \ + -e "SPRING_PROFILES_ACTIVE=${SPRING_PROFILE}" \ + ${VOLUME_MOUNT_OPTION} \ + "${IMAGE}" + + # ============================================================ + # 6. 추가 네트워크 연결 (EXTRA_NETWORKS, 공백 구분) + # ============================================================ + if [ -n "${EXTRA_NETWORKS}" ]; then + for net in ${EXTRA_NETWORKS}; do + echo "🔗 추가 네트워크 연결: ${net}" + SUDO docker network connect "${net}" "${NEW_NAME}" 2>/dev/null || true + done + fi + + # ============================================================ + # 7. Traefik 라벨 디버그 출력 (escape 검증용) + # ============================================================ + echo "🔍 Traefik 라벨 확인:" + SUDO docker inspect "${NEW_NAME}" --format '{{range $k, $v := .Config.Labels}} {{$k}} = {{$v}}{{"\n"}}{{end}}' | grep traefik || true + + # ============================================================ + # 8. 헬스체크 — Traefik 통한 라우팅 확인 + # ============================================================ + MAX_WAIT=$((HEALTHCHECK_MAX_RETRIES * HEALTHCHECK_RETRY_INTERVAL)) + echo "⏳ 헬스체크 시작 (최대 ${MAX_WAIT}초, ${HEALTHCHECK_MAX_RETRIES}회 × ${HEALTHCHECK_RETRY_INTERVAL}초)" + + RETRY_COUNT=0 + HEALTHCHECK_SUCCESS=false + + while [ $RETRY_COUNT -lt $HEALTHCHECK_MAX_RETRIES ]; do + sleep ${HEALTHCHECK_RETRY_INTERVAL} + RETRY_COUNT=$((RETRY_COUNT + 1)) + + STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${NEW_NAME}" 2>/dev/null || echo "not_found") + + if [ "$STATUS" = "exited" ]; then + echo "❌ 컨테이너 비정상 종료 (exited). 로그 200줄:" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 200 "${NEW_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "ℹ️ 컨테이너 ${NEW_NAME} 유지 (자동 삭제 안 함) — 수동 검증/디버깅" + echo "ℹ️ old=${OLD_NAME} 존재 시 그대로 유지 → 사실상 자동 롤백" + exit 1 + fi + + if [ "$STATUS" = "running" ]; then + HTTP_CODE=$(curl -sm 5 -o /dev/null -w "%{http_code}" \ + -H "Host: ${SERVICE_DOMAIN}" \ + "http://localhost:${TRAEFIK_INTERNAL_PORT}${HEALTHCHECK_PATH}" 2>/dev/null || echo "000") + + if echo "$HTTP_CODE" | grep -Eq "^(${HEALTHCHECK_ACCEPT_CODES})$"; then + echo "✅ 헬스체크 통과 (${RETRY_COUNT}/${HEALTHCHECK_MAX_RETRIES}) HTTP=${HTTP_CODE}" + HEALTHCHECK_SUCCESS=true + break + fi + fi + + echo "⏳ 대기 (${RETRY_COUNT}/${HEALTHCHECK_MAX_RETRIES}) status=${STATUS} http=${HTTP_CODE:-?}" + done + + if [ "$HEALTHCHECK_SUCCESS" = "false" ]; then + echo "❌ 헬스체크 타임아웃 (${MAX_WAIT}초)" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🔍 ${NEW_NAME} 최근 200줄 로그:" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 200 "${NEW_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "ℹ️ 컨테이너 ${NEW_NAME} 유지 (자동 삭제 안 함) — 수동 검증/디버깅" + echo "ℹ️ old=${OLD_NAME} 존재 시 그대로 유지 → 사실상 자동 롤백" + exit 1 + fi + + # ============================================================ + # 9. in-flight 처리 + old 컨테이너 자동 제거 + # ============================================================ + if SUDO docker ps -a --format '{{.Names}}' | grep -Eq "^${OLD_NAME}$"; then + echo "⏱️ in-flight 요청 처리 대기 (${IN_FLIGHT_WAIT}초)" + sleep ${IN_FLIGHT_WAIT} + echo "🗑️ old=${OLD_NAME} 제거" + SUDO docker rm -f "${OLD_NAME}" + else + echo "ℹ️ old=${OLD_NAME} 없음 — 정리 생략" + fi + + # ============================================================ + # 10. dangling 이미지 정리 + # ============================================================ + echo "🧹 dangling() 이미지 정리..." + SUDO docker image prune -af + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 무중단 배포 완료. active=${NEW_NAME}" + echo "🌐 도메인: https://${SERVICE_DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# =================================================================== +# 📝 사용 방법 +# =================================================================== +# +# 1) GitHub Secrets 등록: +# APPLICATION_PROD_YML, DOCKERHUB_USERNAME, DOCKERHUB_TOKEN, +# SERVER_HOST, SERVER_USER, SERVER_PASSWORD +# +# 2) 본 워크플로우의 env 블록에서 프로젝트별 설정 수정: +# - PROJECT_NAME, SERVICE_DOMAIN +# - APPLICATION_YML_DIR, CONTAINER_INTERNAL_PORT +# - ENABLE_VOLUME_MOUNT, VOLUME_HOST_PATH, VOLUME_CONTAINER_PATH +# - EXTRA_NETWORKS (예: selenium-chrome-network) +# - HEALTHCHECK_PATH, HEALTHCHECK_ACCEPT_CODES +# +# 3) 서버 사전 준비: +# - Traefik 컨테이너가 traefik-network 에 띄워져 있어야 함 +# - 역방향 프록시: ${SERVICE_DOMAIN}:443 → localhost:${TRAEFIK_INTERNAL_PORT} +# (예: Synology DSM 역방향 프록시, Nginx, Caddy 등) +# +# 4) 본 워크플로우로 전환: +# - 본 파일의 push.branches 주석 해제 +# - PROJECT-SPRING-SIMPLE-CICD.yaml 의 push 트리거 주석 처리 +# +# =================================================================== diff --git a/.github/workflows/PROJECT-SPRING-PR-PREVIEW.yaml b/.github/workflows/PROJECT-SPRING-PR-PREVIEW.yaml new file mode 100644 index 00000000..6a3c4531 --- /dev/null +++ b/.github/workflows/PROJECT-SPRING-PR-PREVIEW.yaml @@ -0,0 +1,2277 @@ +# =================================================================== +# Spring Boot PR/Issue Preview 시스템 (SSH + Docker) +# =================================================================== +# +# 🔑 필수 GitHub Secrets +# =================================================================== +# APPLICATION_PROD_YML (선택): application-prod.yml 내용 +# DOCKERHUB_USERNAME: DockerHub 사용자명 +# DOCKERHUB_TOKEN: DockerHub 액세스 토큰 +# SERVER_HOST: 배포 서버 주소 +# SERVER_USER: SSH 사용자명 +# SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때) +# SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등) +# =================================================================== +# +# 🚀 사용법: +# @suh-lab server build - PR/Issue 빌드 및 배포 +# @suh-lab server destroy - Preview 환경 삭제 +# @suh-lab server status - 현재 상태 확인 +# +# 📊 리소스 네이밍 규칙: +# - 컨테이너: {PROJECT_NAME}-pr-{PR번호} +# - 이미지: {DOCKERHUB_USERNAME}/{PROJECT_NAME}:pr-{PR번호} +# - 도메인: {PROJECT_NAME}-pr-{PR번호}.pr.suhsaechan.kr +# - Preview URL: http://{도메인}:8079 +# +# =================================================================== + +name: PROJECT-SPRING-PR-PREVIEW + +# =================================================================== +# ⚠️ [영역 1] 프로젝트별 설정 - 다른 프로젝트에서 사용 시 이 섹션만 수정하세요 +# =================================================================== +env: + # 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용) (프로젝트별 수정 필요) + PROJECT_NAME: "passQL" + + # Spring Boot 빌드 설정 (프로젝트별 수정 필요) + JAVA_VERSION: "21" + GRADLE_BUILD_CMD: './gradlew clean build -x test -Dspring.profiles.active=prod' + JAR_PATH: 'Suh-Web/build/libs/*.jar' # 멀티모듈이면 빌드 산출물 경로를 직접 수정하세요 + APPLICATION_YML_PATH: "server/PQL-Web/src/main/resources/application-prod.yml" + + # Docker 설정 (프로젝트별 수정 필요) + DOCKERFILE_PATH: './Dockerfile' + INTERNAL_PORT: '8080' + + # 🗂️ 볼륨 마운트 설정 (프로젝트별 수정 필요, 빈값이면 마운트 안함) + PROJECT_TARGET_DIR: "" # 서버의 데이터 디렉토리 (예: /volume1/projects/myapp/data) + PROJECT_MNT_DIR: "" # 컨테이너 내부 마운트 경로 (예: /mnt/myapp) + + # Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지) + TRAEFIK_NETWORK: traefik-network + PREVIEW_DOMAIN_SUFFIX: pr.suhsaechan.kr + PREVIEW_PORT: '8079' + + # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem) + # password → SERVER_PASSWORD secret 사용 + # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로) + SSH_AUTH_METHOD: "password" + + # SSH 포트 (서버 포트 환경 구축 후 수정 금지) + SSH_PORT: '2022' + + # Issue Helper 댓글 마커 (프로젝트별 수정 필요) + # Issue 댓글에서 브랜치명을 추출할 때 사용 + ISSUE_HELPER_MARKER: 'Guide by SUH-LAB' + + # Health Check 설정 (프로젝트별 수정 필요) + # - HEALTH_CHECK_PATH: HTTP Health Check 경로 (빈값이면 HTTP 체크 건너뜀) + # - HEALTH_CHECK_LOG_PATTERN: 로그 패턴 매칭 (HTTP 실패 시 폴백) + # - API_DOCS_PATH: API 문서 URL (빈값이면 배포 코멘트에 미표시) + # + # Spring Boot 기본값: + # HEALTH_CHECK_PATH: '/actuator/health' + # HEALTH_CHECK_LOG_PATTERN: 'Started .* in [0-9.]+ seconds' + # API_DOCS_PATH: '/swagger-ui.html' 또는 '/swagger-ui/index.html' + HEALTH_CHECK_PATH: '/actuator/health' + HEALTH_CHECK_LOG_PATTERN: 'Started .* in [0-9.]+ seconds' + API_DOCS_PATH: '/docs/swagger' + +# =================================================================== +# 트리거 설정 +# =================================================================== +on: + issue_comment: + types: [created] # PR 댓글 + Issue 댓글 + issues: + types: [closed] # Issue 닫힘 시 destroy + pull_request: + types: [closed] # PR 닫힘 시 destroy + +permissions: + contents: read + pull-requests: write + issues: write + +# =================================================================== +# Jobs +# =================================================================== +jobs: + # ----------------------------------------------------------------- + # Job 1: 명령어 파싱 (PR 댓글 + Issue 댓글 모두 지원) + # ----------------------------------------------------------------- + check-command: + name: 명령어 확인 + if: github.event_name == 'issue_comment' + runs-on: ubuntu-latest + outputs: + command: ${{ steps.parse.outputs.command }} + is_valid: ${{ steps.parse.outputs.is_valid }} + is_pr: ${{ steps.parse.outputs.is_pr }} + custom_branch: ${{ steps.parse.outputs.custom_branch }} + is_custom_branch: ${{ steps.parse.outputs.is_custom_branch }} + steps: + - name: 댓글에 👀 리액션 추가 + if: contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server') + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes' + }); + + - name: 커맨드 파싱 + id: parse + env: + COMMENT: ${{ github.event.comment.body }} + IS_PR: ${{ github.event.issue.pull_request != null }} + run: | + # PR인지 Issue인지 확인 + if [[ "$IS_PR" == "true" ]]; then + echo "is_pr=true" >> $GITHUB_OUTPUT + echo "ℹ️ PR 댓글에서 명령어 감지" + else + echo "is_pr=false" >> $GITHUB_OUTPUT + echo "ℹ️ Issue 댓글에서 명령어 감지" + fi + + # 명령어 파싱 (브랜치 파라미터 지원) + # 형식: @suh-lab server [브랜치명] + if [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+build([[:space:]]+([^[:space:]]+))? ]]; then + echo "command=build" >> $GITHUB_OUTPUT + echo "is_valid=true" >> $GITHUB_OUTPUT + if [[ -n "${BASH_REMATCH[2]}" ]]; then + echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "is_custom_branch=true" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: build (브랜치: ${BASH_REMATCH[2]})" + else + echo "is_custom_branch=false" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: build" + fi + elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+destroy([[:space:]]+([^[:space:]]+))? ]]; then + echo "command=destroy" >> $GITHUB_OUTPUT + echo "is_valid=true" >> $GITHUB_OUTPUT + if [[ -n "${BASH_REMATCH[2]}" ]]; then + echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "is_custom_branch=true" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: destroy (브랜치: ${BASH_REMATCH[2]})" + else + echo "is_custom_branch=false" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: destroy" + fi + elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+status([[:space:]]+([^[:space:]]+))? ]]; then + echo "command=status" >> $GITHUB_OUTPUT + echo "is_valid=true" >> $GITHUB_OUTPUT + if [[ -n "${BASH_REMATCH[2]}" ]]; then + echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "is_custom_branch=true" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: status (브랜치: ${BASH_REMATCH[2]})" + else + echo "is_custom_branch=false" >> $GITHUB_OUTPUT + echo "✅ 명령어 감지: status" + fi + else + echo "is_valid=false" >> $GITHUB_OUTPUT + echo "ℹ️ @suh-lab server 명령어가 아님" + fi + + # ----------------------------------------------------------------- + # Job 2-1: 빌드 & 배포 (PR 댓글에서 실행) + # ----------------------------------------------------------------- + build-preview-pr: + name: Preview 빌드 & 배포 (PR) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_pr == 'true' && + needs.check-command.outputs.is_custom_branch != 'true' + runs-on: ubuntu-latest + steps: + - name: PR 정보 가져오기 + id: pr + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('ref', pr.data.head.ref); + core.setOutput('sha', pr.data.head.sha.substring(0, 7)); + return pr.data.number; + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 진행 상황 댓글 시스템 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + - name: 진행 상황 댓글 생성 + id: progress + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.issue.number; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const startTime = Date.now(); + + const body = [ + '## 🚀 PR Preview 빌드 중...', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + '| 🔨 프로젝트 빌드 | ⏳ 진행 중... | - |', + '| 🐳 Docker 이미지 생성 | ⏸️ 대기 | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + + core.setOutput('comment_id', comment.id); + core.setOutput('start_time', startTime); + + - name: 코드 체크아웃 + uses: actions/checkout@v5 + with: + ref: ${{ steps.pr.outputs.ref }} + + - name: JDK 설정 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + cache: 'gradle' + + - name: Gradle 권한 설정 + run: chmod +x gradlew + + # ================================================================= + # ⚠️ [영역 2] 프로젝트별 Secret 파일 생성 + # ================================================================= + # + # 📋 필수 (모든 Spring Boot 프로젝트): + # - application-prod.yml + # + # 📋 선택 (프로젝트에 따라 추가/제거): + # - Firebase: firebase-admin-sdk.json, firebase-messaging-sw.js + # - Vertex AI: gen-lang-client-xxx.json + # - 기타 설정 파일: admin.yml, prompt.yml 등 + # + # ⚠️ 다른 프로젝트에서 사용 시: + # 1. 필요 없는 step은 삭제하세요 + # 2. 필요한 step을 추가하세요 + # 3. 파일 경로를 프로젝트에 맞게 수정하세요 + # ================================================================= + + # [필수] Spring Boot 설정 파일 + - name: "[필수] application-prod.yml 생성" + run: | + mkdir -p $(dirname ${{ env.APPLICATION_YML_PATH }}) + cat << 'EOF' > ${{ env.APPLICATION_YML_PATH }} + ${{ secrets.APPLICATION_PROD_YML }} + EOF + + # [선택] 아래는 예시입니다. 필요 없으면 삭제하세요. + # - name: "[선택] Vertex AI Service Account Key 생성" + # env: + # VERTEX_SA_KEY: ${{ secrets.VERTEX_SA_KEY }} + # run: | + # echo "$VERTEX_SA_KEY" | sed 's/\\n/\n/g' > ./Suh-Web/src/main/resources/vertex-ai-key.json + + # - name: "[선택] Firebase Admin SDK 생성" + # env: + # FIREBASE_KEY_JSON: ${{ secrets.FIREBASE_KEY_JSON }} + # run: | + # echo "$FIREBASE_KEY_JSON" | sed 's/\\n/\n/g' > ./Suh-Web/src/main/resources/firebase-admin-sdk.json + + # ================================================================= + # ⚠️ [영역 2 끝] 프로젝트별 Secret 파일 생성 끝 + # ================================================================= + + - name: Gradle 빌드 + run: ${{ env.GRADLE_BUILD_CMD }} + + - name: 진행 상황 - 빌드 완료 + id: build_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const startTime = ${{ steps.progress.outputs.start_time }}; + const now = Date.now(); + const elapsed = now - startTime; + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + const buildDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 PR Preview 빌드 중...', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + '| 🐳 Docker 이미지 생성 | ⏳ 진행 중... | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('build_duration', buildDuration); + core.setOutput('docker_start', now); + + - name: Docker 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: Docker 이미지 빌드 & Push + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ github.event.issue.number }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 진행 상황 - Docker 완료 + id: docker_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerStart = ${{ steps.build_progress.outputs.docker_start }}; + const now = Date.now(); + const dockerElapsed = now - dockerStart; + const minutes = Math.floor(dockerElapsed / 60000); + const seconds = Math.floor((dockerElapsed % 60000) / 1000); + const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 PR Preview 빌드 중...', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('build_duration', buildDuration); + core.setOutput('docker_duration', dockerDuration); + core.setOutput('deploy_start', now); + + - name: 서버에 배포 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + set -e + + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${{ env.SSH_AUTH_METHOD }}" + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + # 변수 설정 + PR_NUMBER=${{ github.event.issue.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}" + DOMAIN="${PROJECT_NAME}-pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}" + INTERNAL_PORT="${{ env.INTERNAL_PORT }}" + TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 PR Preview 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🔢 PR 번호: #${PR_NUMBER}" + echo "📛 컨테이너: ${CONTAINER_NAME}" + echo "🌐 도메인: ${DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 이미지 Pull + echo "📥 Docker 이미지 Pull 중..." + SUDO docker pull "${IMAGE}" + + # 기존 컨테이너 삭제 + echo "🗑️ 기존 컨테이너 정리 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 볼륨 마운트 옵션 구성 + VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro" + if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then + VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}" + fi + + # 새 컨테이너 실행 + echo "🐳 새 컨테이너 실행 중..." + SUDO docker run -d \ + --name "${CONTAINER_NAME}" \ + --network "${TRAEFIK_NETWORK}" \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \ + --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \ + --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \ + -e TZ=Asia/Seoul \ + -e SPRING_PROFILES_ACTIVE=prod \ + $VOLUME_OPTS \ + "${IMAGE}" + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Health Check (최대 120초 대기) - 하이브리드 방식 + # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도 + # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + echo "" + echo "⏳ Health Check 시작 (최대 120초 대기)..." + HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}" + LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}" + MAX_RETRIES=24 + RETRY_COUNT=0 + HEALTH_CHECK_PASSED=false + HEALTH_CHECK_METHOD="" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + sleep 5 + RETRY_COUNT=$((RETRY_COUNT + 1)) + + # 1. 컨테이너 상태 확인 + STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found") + + if [ "$STATUS" = "exited" ]; then + echo "❌ 컨테이너 비정상 종료!" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + if [ "$STATUS" = "running" ]; then + # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만) + if [ -n "$HEALTH_PATH" ]; then + HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" wget -qO- "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "") + + # Spring Actuator 응답 확인 ({"status":"UP"}) 또는 일반 HTTP 응답 + if echo "$HEALTH" | grep -q '"status":"UP"' || [ -n "$HEALTH" ]; then + echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="HTTP" + break + fi + fi + + # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백 + if [ -n "$LOG_PATTERN" ]; then + STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi + fi + fi + + echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS" + done + + if [ "$HEALTH_CHECK_PASSED" = "false" ]; then + echo "" + echo "❌ Health Check 타임아웃 (120초)" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + - name: 배포 완료 코멘트 + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const buildDuration = '${{ steps.docker_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + const deployStart = ${{ steps.docker_progress.outputs.deploy_start }}; + const now = Date.now(); + const deployElapsed = now - deployStart; + const minutes = Math.floor(deployElapsed / 60000); + const seconds = Math.floor((deployElapsed % 60000) / 1000); + const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; + const prNumber = context.issue.number; + const domain = `${projectName}-pr-${prNumber}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const sha = '${{ steps.pr.outputs.sha }}'; + + // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가) + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`); + envRows.push(`| **커밋** | \`${sha}\` |`); + + const branchName = '${{ steps.pr.outputs.ref }}'; + + const body = [ + '## ✅ PR Preview 배포 완료!', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`, + '', + '### 🌐 Preview 환경', + '| 항목 | 값 |', + '|------|-----|', + ...envRows, + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab server build` | 최신 커밋으로 재배포 |', + '| `@suh-lab server destroy` | Preview 환경 삭제 |', + '| `@suh-lab server status` | 현재 상태 확인 |', + '', + '
', + '🔧 고급 명령어 (다른 Issue/PR에서 제어)', + '', + '```', + `@suh-lab server build ${branchName}`, + `@suh-lab server destroy ${branchName}`, + `@suh-lab server status ${branchName}`, + '```', + '
', + '', + '---', + '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + - name: 빌드/배포 실패 시 에러 코멘트 + if: failure() + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id || 0 }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const prNumber = context.issue.number; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + // 진행 상황에 따라 어디서 실패했는지 표시 + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + + // 실패 지점 판단 - 초기 단계 실패도 구분 + let buildStatus, dockerStatus, deployStatus; + let buildTime = '-', dockerTime = '-'; + + if (!commentId) { + // 진행 상황 댓글 생성 전 실패 (초기화 단계) + buildStatus = '⚠️ 초기화 실패'; + dockerStatus = '-'; + deployStatus = '-'; + } else if (!buildDuration || buildDuration === '') { + // 빌드 단계에서 실패 + buildStatus = '❌ 실패'; + dockerStatus = '⏸️ 대기'; + deployStatus = '⏸️ 대기'; + } else if (!dockerDuration || dockerDuration === '') { + // Docker 단계에서 실패 + buildStatus = '✅ 완료'; + dockerStatus = '❌ 실패'; + deployStatus = '⏸️ 대기'; + buildTime = buildDuration; + } else { + // 배포 단계에서 실패 + buildStatus = '✅ 완료'; + dockerStatus = '✅ 완료'; + deployStatus = '❌ 실패'; + buildTime = buildDuration; + dockerTime = dockerDuration; + } + + const body = [ + '## ❌ PR Preview 배포 실패!', + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ${buildStatus} | ${buildTime} |`, + `| 🐳 Docker 이미지 생성 | ${dockerStatus} | ${dockerTime} |`, + `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`, + '', + `**[📋 빌드/배포 로그 확인](${runUrl})**`, + '', + '### 🔍 가능한 원인', + '- Gradle 빌드 실패', + '- Docker 이미지 빌드 실패', + '- 컨테이너 시작 실패 (Spring Boot 기동 오류)', + '- Health Check 타임아웃 (120초 내 기동 완료 안됨)', + '', + '### 💡 다음 단계', + '1. 위 링크에서 빌드/배포 로그를 확인하세요', + '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`', + '', + '---', + '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + // 진행 상황 댓글이 있으면 업데이트, 없으면 새로 생성 + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + } + + # ----------------------------------------------------------------- + # Job 2-2: Issue에서 브랜치명 추출 + # ----------------------------------------------------------------- + get-branch-from-issue: + name: Issue에서 브랜치 추출 + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.is_pr == 'false' && + needs.check-command.outputs.is_custom_branch != 'true' + runs-on: ubuntu-latest + outputs: + branch_name: ${{ steps.extract.outputs.branch }} + found: ${{ steps.extract.outputs.found }} + issue_number: ${{ steps.extract.outputs.issue_number }} + steps: + - name: Issue 댓글에서 브랜치명 추출 + id: extract + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue.number; + const marker = '${{ env.ISSUE_HELPER_MARKER }}'; + + console.log(`🔍 Issue #${issueNumber}에서 브랜치 검색 중...`); + console.log(`📝 마커: ${marker}`); + + // Issue의 모든 댓글 조회 + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + // 브랜치 추출 패턴 (마커와 무관하게 동일한 형식) + // "### 브랜치" 또는 "### 브랜치명" 둘 다 매칭 + const branchRegex = /###\s*브랜치(?:명)?\s*\n```\s*\n([^\n]+)\s*\n```/; + + for (const comment of comments.data) { + // 마커가 포함된 댓글 찾기 + if (comment.body.includes(marker)) { + const match = comment.body.match(branchRegex); + if (match) { + const branchName = match[1].trim(); + core.setOutput('branch', branchName); + core.setOutput('found', 'true'); + core.setOutput('issue_number', issueNumber); + console.log(`✅ 브랜치 발견: ${branchName}`); + return; + } + } + } + + // 브랜치를 찾지 못한 경우 - 에러가 아님, graceful하게 처리 + core.setOutput('found', 'false'); + core.setOutput('issue_number', issueNumber); + console.log('⚠️ Issue Helper 댓글에서 브랜치를 찾을 수 없습니다.'); + + - name: 브랜치 없음 알림 + if: steps.extract.outputs.found == 'false' + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue.number; + const marker = '${{ env.ISSUE_HELPER_MARKER }}'; + + const body = [ + '## ⚠️ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Issue** | #${issueNumber} |`, + `| **마커** | \`${marker}\` |`, + '', + '### 💡 확인 사항', + '1. Issue Helper 댓글이 존재하는지 확인하세요', + '2. 댓글에 `### 브랜치` 섹션이 있는지 확인하세요', + '3. `ISSUE_HELPER_MARKER` 환경변수가 올바른지 확인하세요', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + # ----------------------------------------------------------------- + # Job 2-3: 빌드 & 배포 (Issue 댓글에서 실행) + # ----------------------------------------------------------------- + build-preview-issue: + name: Preview 빌드 & 배포 (Issue) + needs: [check-command, get-branch-from-issue] + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_pr == 'false' && + needs.check-command.outputs.is_custom_branch != 'true' && + needs.get-branch-from-issue.outputs.found == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치 존재 확인 + id: check_branch + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + + console.log(`🔍 브랜치 존재 확인: ${branchName}`); + + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: branchName + }); + core.setOutput('exists', 'true'); + console.log(`✅ 브랜치 존재: ${branchName}`); + } catch (error) { + if (error.status === 404) { + core.setOutput('exists', 'false'); + console.log(`❌ 브랜치 없음: ${branchName}`); + + // 브랜치 없음 알림 댓글 + const body = [ + '## ❌ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Issue** | #${issueNumber} |`, + `| **브랜치** | \`${branchName}\` |`, + '', + '### 💡 확인 사항', + '1. 브랜치가 push되었는지 확인하세요', + '2. 브랜치명이 정확한지 확인하세요', + '3. 브랜치가 삭제되지 않았는지 확인하세요', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + } else { + throw error; + } + } + + - name: 브랜치 없으면 중단 + if: steps.check_branch.outputs.exists == 'false' + run: | + echo "⚠️ 브랜치가 존재하지 않아 빌드를 건너뜁니다." + exit 0 + + - name: Issue 정보 설정 + if: steps.check_branch.outputs.exists == 'true' + id: issue + run: | + echo "ref=${{ needs.get-branch-from-issue.outputs.branch_name }}" >> $GITHUB_OUTPUT + echo "issue_number=${{ needs.get-branch-from-issue.outputs.issue_number }}" >> $GITHUB_OUTPUT + # 브랜치의 최신 커밋 SHA 가져오기 + echo "sha=$(git ls-remote https://github.com/${{ github.repository }}.git refs/heads/${{ needs.get-branch-from-issue.outputs.branch_name }} | cut -c1-7)" >> $GITHUB_OUTPUT + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 진행 상황 댓글 시스템 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + - name: 진행 상황 댓글 생성 + if: steps.check_branch.outputs.exists == 'true' + id: progress + uses: actions/github-script@v7 + with: + script: | + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const startTime = Date.now(); + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + '| 🔨 프로젝트 빌드 | ⏳ 진행 중... | - |', + '| 🐳 Docker 이미지 생성 | ⏸️ 대기 | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + core.setOutput('comment_id', comment.id); + core.setOutput('start_time', startTime); + + - name: 코드 체크아웃 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/checkout@v5 + with: + ref: ${{ needs.get-branch-from-issue.outputs.branch_name }} + + - name: JDK 설정 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + cache: 'gradle' + + - name: Gradle 권한 설정 + if: steps.check_branch.outputs.exists == 'true' + run: chmod +x gradlew + + # ================================================================= + # ⚠️ [영역 2] 프로젝트별 Secret 파일 생성 + # ================================================================= + - name: "[필수] application-prod.yml 생성" + if: steps.check_branch.outputs.exists == 'true' + run: | + mkdir -p $(dirname ${{ env.APPLICATION_YML_PATH }}) + cat << 'EOF' > ${{ env.APPLICATION_YML_PATH }} + ${{ secrets.APPLICATION_PROD_YML }} + EOF + # ================================================================= + # ⚠️ [영역 2 끝] 프로젝트별 Secret 파일 생성 끝 + # ================================================================= + + - name: Gradle 빌드 + if: steps.check_branch.outputs.exists == 'true' + run: ${{ env.GRADLE_BUILD_CMD }} + + - name: 진행 상황 - 빌드 완료 + if: steps.check_branch.outputs.exists == 'true' + id: build_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const startTime = ${{ steps.progress.outputs.start_time }}; + const now = Date.now(); + const elapsed = now - startTime; + const minutes = Math.floor(elapsed / 60000); + const seconds = Math.floor((elapsed % 60000) / 1000); + const buildDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + '| 🐳 Docker 이미지 생성 | ⏳ 진행 중... | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('build_duration', buildDuration); + core.setOutput('docker_start', now); + + - name: Docker 로그인 + if: steps.check_branch.outputs.exists == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Buildx 설정 + if: steps.check_branch.outputs.exists == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Docker 이미지 빌드 & Push + if: steps.check_branch.outputs.exists == 'true' + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ needs.get-branch-from-issue.outputs.issue_number }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 진행 상황 - Docker 완료 + if: steps.check_branch.outputs.exists == 'true' + id: docker_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerStart = ${{ steps.build_progress.outputs.docker_start }}; + const now = Date.now(); + const dockerElapsed = now - dockerStart; + const minutes = Math.floor(dockerElapsed / 60000); + const seconds = Math.floor((dockerElapsed % 60000) / 1000); + const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = [ + '## 🚀 Issue Preview 빌드 중...', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |', + '', + `**[📋 실시간 로그 보기](${runUrl})**`, + '', + '---', + '*🤖 이 댓글은 자동으로 업데이트됩니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('build_duration', buildDuration); + core.setOutput('docker_duration', dockerDuration); + core.setOutput('deploy_start', now); + + - name: 서버에 배포 + if: steps.check_branch.outputs.exists == 'true' + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + set -e + + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${{ env.SSH_AUTH_METHOD }}" + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + # 변수 설정 + ISSUE_NUMBER=${{ needs.get-branch-from-issue.outputs.issue_number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${ISSUE_NUMBER}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${ISSUE_NUMBER}" + DOMAIN="${PROJECT_NAME}-pr-${ISSUE_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}" + INTERNAL_PORT="${{ env.INTERNAL_PORT }}" + TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Issue Preview 배포 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🔢 Issue 번호: #${ISSUE_NUMBER}" + echo "📛 컨테이너: ${CONTAINER_NAME}" + echo "🌐 도메인: ${DOMAIN}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 이미지 Pull + echo "📥 Docker 이미지 Pull 중..." + SUDO docker pull "${IMAGE}" + + # 기존 컨테이너 삭제 + echo "🗑️ 기존 컨테이너 정리 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 볼륨 마운트 옵션 구성 + VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro" + if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then + VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}" + fi + + # 새 컨테이너 실행 + echo "🐳 새 컨테이너 실행 중..." + SUDO docker run -d \ + --name "${CONTAINER_NAME}" \ + --network "${TRAEFIK_NETWORK}" \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \ + --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \ + --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \ + -e TZ=Asia/Seoul \ + -e SPRING_PROFILES_ACTIVE=prod \ + $VOLUME_OPTS \ + "${IMAGE}" + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Health Check (최대 120초 대기) - 하이브리드 방식 + # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도 + # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + echo "" + echo "⏳ Health Check 시작 (최대 120초 대기)..." + HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}" + LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}" + MAX_RETRIES=24 + RETRY_COUNT=0 + HEALTH_CHECK_PASSED=false + HEALTH_CHECK_METHOD="" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + sleep 5 + RETRY_COUNT=$((RETRY_COUNT + 1)) + + # 1. 컨테이너 상태 확인 + STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found") + + if [ "$STATUS" = "exited" ]; then + echo "❌ 컨테이너 비정상 종료!" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + if [ "$STATUS" = "running" ]; then + # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만) + if [ -n "$HEALTH_PATH" ]; then + HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" wget -qO- "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "") + + # Spring Actuator 응답 확인 ({"status":"UP"}) 또는 일반 HTTP 응답 + if echo "$HEALTH" | grep -q '"status":"UP"' || [ -n "$HEALTH" ]; then + echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="HTTP" + break + fi + fi + + # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백 + if [ -n "$LOG_PATTERN" ]; then + STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "") + + if [ -n "$STARTED" ]; then + echo "✅ 정상 기동 확인! (로그 패턴)" + echo " $STARTED" + HEALTH_CHECK_PASSED=true + HEALTH_CHECK_METHOD="Log" + break + fi + fi + fi + + echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS" + done + + if [ "$HEALTH_CHECK_PASSED" = "false" ]; then + echo "" + echo "❌ Health Check 타임아웃 (120초)" + echo "" + echo "📋 컨테이너 로그 (최근 100줄):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + SUDO docker logs --tail 100 "${CONTAINER_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + - name: 배포 완료 코멘트 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const buildDuration = '${{ steps.docker_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + const deployStart = ${{ steps.docker_progress.outputs.deploy_start }}; + const now = Date.now(); + const deployElapsed = now - deployStart; + const minutes = Math.floor(deployElapsed / 60000); + const seconds = Math.floor((deployElapsed % 60000) / 1000); + const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`; + + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const domain = `${projectName}-pr-${issueNumber}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const sha = '${{ steps.issue.outputs.sha }}'; + + // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가) + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`); + envRows.push(`| **브랜치** | \`${branchName}\` |`); + envRows.push(`| **커밋** | \`${sha}\` |`); + + const body = [ + '## ✅ Issue Preview 배포 완료!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`, + '', + '### 🌐 Preview 환경', + '| 항목 | 값 |', + '|------|-----|', + ...envRows, + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab server build` | 최신 커밋으로 재배포 |', + '| `@suh-lab server destroy` | Preview 환경 삭제 |', + '| `@suh-lab server status` | 현재 상태 확인 |', + '', + '
', + '🔧 고급 명령어 (다른 Issue/PR에서 제어)', + '', + '```', + `@suh-lab server build ${branchName}`, + `@suh-lab server destroy ${branchName}`, + `@suh-lab server status ${branchName}`, + '```', + '
', + '', + '---', + '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + - name: 빌드/배포 실패 시 에러 코멘트 + if: failure() && steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id || 0 }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }}; + const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + + let buildStatus, dockerStatus, deployStatus; + let buildTime = '-', dockerTime = '-'; + + if (!commentId) { + buildStatus = '⚠️ 초기화 실패'; + dockerStatus = '-'; + deployStatus = '-'; + } else if (!buildDuration || buildDuration === '') { + buildStatus = '❌ 실패'; + dockerStatus = '⏸️ 대기'; + deployStatus = '⏸️ 대기'; + } else if (!dockerDuration || dockerDuration === '') { + buildStatus = '✅ 완료'; + dockerStatus = '❌ 실패'; + deployStatus = '⏸️ 대기'; + buildTime = buildDuration; + } else { + buildStatus = '✅ 완료'; + dockerStatus = '✅ 완료'; + deployStatus = '❌ 실패'; + buildTime = buildDuration; + dockerTime = dockerDuration; + } + + const body = [ + '## ❌ Issue Preview 배포 실패!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ${buildStatus} | ${buildTime} |`, + `| 🐳 Docker 이미지 생성 | ${dockerStatus} | ${dockerTime} |`, + `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`, + '', + `**[📋 빌드/배포 로그 확인](${runUrl})**`, + '', + '### 🔍 가능한 원인', + '- Gradle 빌드 실패', + '- Docker 이미지 빌드 실패', + '- 컨테이너 시작 실패 (Spring Boot 기동 오류)', + '- Health Check 타임아웃 (120초 내 기동 완료 안됨)', + '', + '### 💡 다음 단계', + '1. 위 링크에서 빌드/배포 로그를 확인하세요', + '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`', + '', + '---', + '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + } + + # ----------------------------------------------------------------- + # Job 3: Preview 삭제 (PR/Issue 닫힘 또는 destroy 명령) + # ----------------------------------------------------------------- + destroy-preview: + name: Preview 삭제 + # needs 의존성 제거: PR/Issue 닫힘 이벤트에서도 독립적으로 실행되도록 함 + if: | + (github.event_name == 'pull_request' && github.event.action == 'closed') || + (github.event_name == 'issues' && github.event.action == 'closed') || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server destroy')) + runs-on: ubuntu-latest + steps: + - name: PR/Issue 번호 가져오기 + id: pr_number + run: | + if [[ "${{ github.event_name }}" == "issue_comment" ]]; then + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + echo "type=comment" >> $GITHUB_OUTPUT + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + echo "type=pr_closed" >> $GITHUB_OUTPUT + else + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + echo "type=issue_closed" >> $GITHUB_OUTPUT + fi + + - name: 컨테이너 & 이미지 삭제 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + export PW="${{ secrets.SERVER_PASSWORD }}" + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + SSH_AUTH_METHOD="${{ env.SSH_AUTH_METHOD }}" + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + PR_NUMBER=${{ steps.pr_number.outputs.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}" + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🗑️ Preview 삭제" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📦 프로젝트: ${PROJECT_NAME}" + echo "🔢 번호: #${PR_NUMBER}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # 컨테이너 삭제 + echo "🐳 컨테이너 삭제 중..." + SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + + # 이미지 삭제 + echo "🖼️ 이미지 삭제 중..." + SUDO docker rmi "${IMAGE}" 2>/dev/null || true + + echo "✅ 삭제 완료!" + + - name: 삭제 완료 코멘트 + if: | + github.event_name == 'issue_comment' || + (github.event_name == 'pull_request' && github.event.action == 'closed') || + (github.event_name == 'issues' && github.event.action == 'closed') + uses: actions/github-script@v7 + with: + script: | + const number = ${{ steps.pr_number.outputs.number }}; + const projectName = '${{ env.PROJECT_NAME }}'; + + const body = [ + '## 🗑️ Preview 환경 삭제 완료!', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, + '| **상태** | 삭제됨 |', + '', + '다시 배포하려면: `@suh-lab server build`', + '', + '---', + '*🤖 이 댓글은 PR/Issue Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: body + }); + + # ----------------------------------------------------------------- + # Job 4: 상태 확인 (PR/Issue 모두 지원) + # ----------------------------------------------------------------- + check-status: + name: Preview 상태 확인 + needs: check-command + if: needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'status' + runs-on: ubuntu-latest + steps: + - name: 컨테이너 상태 확인 + id: status + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + + NUMBER=${{ github.event.issue.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🔍 Preview 상태 확인" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then + echo "STATUS=running" + echo "✅ 컨테이너 실행 중" + docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}" + else + echo "STATUS=not_found" + echo "❌ 컨테이너 없음" + fi + + - name: 상태 코멘트 (실행 중) + uses: appleboy/ssh-action@v1.0.3 + id: check_running + continue-on-error: true + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + # 환경 변수 설정 (배포 서버용) + export PATH=$PATH:/usr/local/bin + + NUMBER=${{ github.event.issue.number }} + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}" + docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}" + + - name: 상태 코멘트 작성 + uses: actions/github-script@v7 + with: + script: | + const number = context.issue.number; + const projectName = '${{ env.PROJECT_NAME }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const domain = `${projectName}-pr-${number}.${domainSuffix}`; + const previewUrl = `http://${domain}:${previewPort}`; + const isRunning = '${{ steps.check_running.outcome }}' === 'success'; + const isPr = '${{ needs.check-command.outputs.is_pr }}' === 'true'; + const contextType = isPr ? 'PR' : 'Issue'; + + let body; + if (isRunning) { + body = [ + '## ✅ Preview 환경 실행 중', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **Preview URL** | ${previewUrl} |`, + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, + '| **상태** | 🟢 Running |', + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + '| `@suh-lab server build` | 최신 커밋으로 재배포 |', + '| `@suh-lab server destroy` | Preview 환경 삭제 |', + '', + '---', + `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*` + ].join('\n'); + } else { + body = [ + '## ❌ Preview 환경 없음', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **컨테이너** | \`${projectName}-pr-${number}\` |`, + '| **상태** | 🔴 Not Found |', + '', + '배포하려면: `@suh-lab server build`', + '', + '---', + `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*` + ].join('\n'); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: body + }); + + # ============================================================ + # Custom Branch Preview Jobs + # 특정 브랜치를 지정하여 빌드/삭제/상태 확인하는 기능 + # 명령어: @suh-lab server build <브랜치명> + # ============================================================ + + build-preview-custom-branch: + name: Preview 빌드 & 배포 (Custom Branch) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'build' && + needs.check-command.outputs.is_custom_branch == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치명에서 Issue 번호 추출 + id: extract + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const issueMatch = branchName.match(/#(\d+)/); + + if (issueMatch) { + core.setOutput('issue_number', issueMatch[1]); + core.setOutput('use_hash', 'false'); + core.setOutput('container_suffix', `pr-${issueMatch[1]}`); + } else { + // 해시 생성 + let hash = 0; + for (let i = 0; i < branchName.length; i++) { + const char = branchName.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0'); + core.setOutput('container_suffix', `custom-${hashStr}`); + core.setOutput('use_hash', 'true'); + } + + core.setOutput('branch_name', branchName); + + - name: 브랜치 존재 여부 확인 + id: check_branch + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ steps.extract.outputs.branch_name }}'; + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: branchName + }); + core.setOutput('exists', 'true'); + } catch (error) { + if (error.status === 404) { + core.setOutput('exists', 'false'); + } else { + throw error; + } + } + + - name: 브랜치 없음 에러 코멘트 + if: steps.check_branch.outputs.exists == 'false' + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ steps.extract.outputs.branch_name }}'; + + const body = [ + '## ❌ 브랜치를 찾을 수 없습니다', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + '', + '### 💡 확인 사항', + '1. 브랜치명이 정확한지 확인하세요', + '2. 브랜치가 push되었는지 확인하세요', + '3. 브랜치가 삭제되지 않았는지 확인하세요', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + + core.setFailed('브랜치를 찾을 수 없습니다: ' + branchName); + + - name: 체크아웃 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/checkout@v5 + with: + ref: ${{ steps.extract.outputs.branch_name }} + + - name: 최신 커밋 정보 가져오기 + if: steps.check_branch.outputs.exists == 'true' + id: commit + run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: 진행 상황 코멘트 작성 + if: steps.check_branch.outputs.exists == 'true' + id: progress + uses: actions/github-script@v7 + with: + script: | + const projectName = '${{ env.PROJECT_NAME }}'; + const containerSuffix = '${{ steps.extract.outputs.container_suffix }}'; + const branchName = '${{ steps.extract.outputs.branch_name }}'; + const sha = '${{ steps.commit.outputs.sha }}'; + + const body = [ + '## 🚀 Custom Branch Preview 배포 시작', + '', + `**브랜치**: \`${branchName}\``, + `**커밋**: \`${sha}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + '| 🔨 프로젝트 빌드 | ⏳ 진행 중... | - |', + '| 🐳 Docker 이미지 생성 | ⏸️ 대기 | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + const comment = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + + core.setOutput('comment_id', comment.data.id); + + - name: JDK 설정 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + + - name: Gradle 캐시 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Gradle 빌드 + if: steps.check_branch.outputs.exists == 'true' + id: build + run: | + BUILD_START=$(date +%s) + chmod +x ./gradlew + ./gradlew clean bootJar -x test --no-daemon + BUILD_END=$(date +%s) + BUILD_DURATION=$((BUILD_END - BUILD_START)) + echo "duration=${BUILD_DURATION}s" >> $GITHUB_OUTPUT + + - name: 빌드 완료 진행 상황 업데이트 + if: steps.check_branch.outputs.exists == 'true' + id: build_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const branchName = '${{ steps.extract.outputs.branch_name }}'; + const sha = '${{ steps.commit.outputs.sha }}'; + const buildDuration = '${{ steps.build.outputs.duration }}'; + + const body = [ + '## 🚀 Custom Branch Preview 배포 시작', + '', + `**브랜치**: \`${branchName}\``, + `**커밋**: \`${sha}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + '| 🐳 Docker 이미지 생성 | ⏳ 진행 중... | - |', + '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('build_duration', buildDuration); + + - name: Docker Hub 로그인 + if: steps.check_branch.outputs.exists == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Docker 이미지 빌드 & 푸시 + if: steps.check_branch.outputs.exists == 'true' + id: docker + run: | + DOCKER_START=$(date +%s) + CONTAINER_SUFFIX="${{ steps.extract.outputs.container_suffix }}" + IMAGE_TAG="${{ secrets.DOCKER_USERNAME }}/${{ env.PROJECT_NAME }}:${CONTAINER_SUFFIX}" + docker build -t $IMAGE_TAG . + docker push $IMAGE_TAG + DOCKER_END=$(date +%s) + DOCKER_DURATION=$((DOCKER_END - DOCKER_START)) + echo "duration=${DOCKER_DURATION}s" >> $GITHUB_OUTPUT + echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT + + - name: Docker 완료 진행 상황 업데이트 + if: steps.check_branch.outputs.exists == 'true' + id: docker_progress + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const branchName = '${{ steps.extract.outputs.branch_name }}'; + const sha = '${{ steps.commit.outputs.sha }}'; + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker.outputs.duration }}'; + + const body = [ + '## 🚀 Custom Branch Preview 배포 시작', + '', + `**브랜치**: \`${branchName}\``, + `**커밋**: \`${sha}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + core.setOutput('docker_duration', dockerDuration); + + - name: 서버 배포 + if: steps.check_branch.outputs.exists == 'true' + id: deploy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + export PATH=$PATH:/usr/local/bin + + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_SUFFIX="${{ steps.extract.outputs.container_suffix }}" + CONTAINER_NAME="${PROJECT_NAME}-${CONTAINER_SUFFIX}" + IMAGE_TAG="${{ steps.docker.outputs.image_tag }}" + PREVIEW_PORT="${{ env.PREVIEW_PORT }}" + DOMAIN_SUFFIX="${{ env.PREVIEW_DOMAIN_SUFFIX }}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🚀 Custom Branch Preview 배포" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + docker pull $IMAGE_TAG + + if docker ps -a --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then + echo "🗑️ 기존 컨테이너 삭제: ${CONTAINER_NAME}" + docker stop ${CONTAINER_NAME} || true + docker rm ${CONTAINER_NAME} || true + fi + + echo "🐳 새 컨테이너 시작: ${CONTAINER_NAME}" + docker run -d \ + --name ${CONTAINER_NAME} \ + --network traefik-network \ + -l "traefik.enable=true" \ + -l "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${CONTAINER_NAME}.${DOMAIN_SUFFIX}\`)" \ + -l "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \ + -l "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${PREVIEW_PORT}" \ + ${IMAGE_TAG} + + echo "⏳ Health Check 대기 (최대 60초)..." + for i in {1..12}; do + if docker ps --filter "name=${CONTAINER_NAME}" --filter "status=running" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then + echo "✅ 컨테이너 실행 확인됨" + break + fi + echo "대기 중... ($i/12)" + sleep 5 + done + + if ! docker ps --filter "name=${CONTAINER_NAME}" --filter "status=running" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then + echo "❌ 배포 실패: 컨테이너가 실행되지 않음" + exit 1 + fi + + echo "✅ 배포 완료!" + echo "🌐 Preview URL: http://${CONTAINER_NAME}.${DOMAIN_SUFFIX}:${PREVIEW_PORT}" + + - name: 배포 완료 시간 기록 + if: steps.check_branch.outputs.exists == 'true' + id: deploy_time + run: echo "duration=완료" >> $GITHUB_OUTPUT + + - name: 배포 완료 코멘트 + if: steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const containerSuffix = '${{ steps.extract.outputs.container_suffix }}'; + const branchName = '${{ steps.extract.outputs.branch_name }}'; + const sha = '${{ steps.commit.outputs.sha }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + const apiDocsPath = '${{ env.API_DOCS_PATH }}'; + + const containerName = `${projectName}-${containerSuffix}`; + const previewUrl = `http://${containerName}.${domainSuffix}:${previewPort}`; + + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + const deployDuration = '${{ steps.deploy_time.outputs.duration }}'; + + const envRows = [ + `| **Preview URL** | ${previewUrl} |`, + ]; + if (apiDocsPath) { + envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`); + } + envRows.push(`| **컨테이너** | \`${containerName}\` |`); + envRows.push(`| **브랜치** | \`${branchName}\` |`); + envRows.push(`| **커밋** | \`${sha}\` |`); + + const body = [ + '## ✅ Custom Branch Preview 배포 완료!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ✅ 완료 | ${buildDuration} |`, + `| 🐳 Docker 이미지 생성 | ✅ 완료 | ${dockerDuration} |`, + `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`, + '', + '### 🌐 Preview 환경', + '| 항목 | 값 |', + '|------|-----|', + ...envRows, + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + `| \`@suh-lab server build ${branchName}\` | 최신 커밋으로 재배포 |`, + `| \`@suh-lab server destroy ${branchName}\` | Preview 환경 삭제 |`, + `| \`@suh-lab server status ${branchName}\` | 현재 상태 확인 |`, + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + + - name: 빌드/배포 실패 시 에러 코멘트 + if: failure() && steps.check_branch.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const commentId = ${{ steps.progress.outputs.comment_id || 0 }}; + const projectName = '${{ env.PROJECT_NAME }}'; + const branchName = '${{ steps.extract.outputs.branch_name }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const buildDuration = '${{ steps.build_progress.outputs.build_duration }}'; + const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}'; + + let buildStatus, dockerStatus, deployStatus; + let buildTime = '-', dockerTime = '-'; + + if (!commentId) { + buildStatus = '⚠️ 초기화 실패'; + dockerStatus = '-'; + deployStatus = '-'; + } else if (!buildDuration || buildDuration === '') { + buildStatus = '❌ 실패'; + dockerStatus = '⏸️ 대기'; + deployStatus = '⏸️ 대기'; + } else if (!dockerDuration || dockerDuration === '') { + buildStatus = '✅ 완료'; + dockerStatus = '❌ 실패'; + deployStatus = '⏸️ 대기'; + buildTime = buildDuration; + } else { + buildStatus = '✅ 완료'; + dockerStatus = '✅ 완료'; + deployStatus = '❌ 실패'; + buildTime = buildDuration; + dockerTime = dockerDuration; + } + + const body = [ + '## ❌ Custom Branch Preview 배포 실패!', + '', + `**브랜치**: \`${branchName}\``, + '', + '| 단계 | 상태 | 소요 시간 |', + '|------|------|----------|', + `| 🔨 프로젝트 빌드 | ${buildStatus} | ${buildTime} |`, + `| 🐳 Docker 이미지 생성 | ${dockerStatus} | ${dockerTime} |`, + `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`, + '', + `**[📋 빌드/배포 로그 확인](${runUrl})**`, + '', + '### 🔍 가능한 원인', + '- Gradle 빌드 실패', + '- Docker 이미지 빌드 실패', + '- 서버 연결 문제', + '- 컨테이너 시작 실패', + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + if (commentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } + + destroy-preview-custom-branch: + name: Preview 삭제 (Custom Branch) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'destroy' && + needs.check-command.outputs.is_custom_branch == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치명에서 컨테이너 suffix 추출 + id: extract + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const issueMatch = branchName.match(/#(\d+)/); + + if (issueMatch) { + core.setOutput('container_suffix', `pr-${issueMatch[1]}`); + } else { + let hash = 0; + for (let i = 0; i < branchName.length; i++) { + const char = branchName.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0'); + core.setOutput('container_suffix', `custom-${hashStr}`); + } + + core.setOutput('branch_name', branchName); + + - name: 컨테이너 삭제 + id: destroy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + export PATH=$PATH:/usr/local/bin + + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_SUFFIX="${{ steps.extract.outputs.container_suffix }}" + CONTAINER_NAME="${PROJECT_NAME}-${CONTAINER_SUFFIX}" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "🗑️ Custom Branch Preview 삭제" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if docker ps -a --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then + echo "🗑️ 컨테이너 삭제 중: ${CONTAINER_NAME}" + docker stop ${CONTAINER_NAME} || true + docker rm ${CONTAINER_NAME} || true + echo "DESTROYED=true" + else + echo "ℹ️ 컨테이너가 존재하지 않음: ${CONTAINER_NAME}" + echo "DESTROYED=false" + fi + + - name: 삭제 확인 코멘트 + uses: actions/github-script@v7 + with: + script: | + const projectName = '${{ env.PROJECT_NAME }}'; + const containerSuffix = '${{ steps.extract.outputs.container_suffix }}'; + const branchName = '${{ steps.extract.outputs.branch_name }}'; + const containerName = `${projectName}-${containerSuffix}`; + + const body = [ + '## 🗑️ Custom Branch Preview 삭제 완료', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **컨테이너** | \`${containerName}\` |`, + '', + `다시 배포하려면: \`@suh-lab server build ${branchName}\``, + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + + check-status-custom-branch: + name: Preview 상태 확인 (Custom Branch) + needs: check-command + if: | + needs.check-command.outputs.is_valid == 'true' && + needs.check-command.outputs.command == 'status' && + needs.check-command.outputs.is_custom_branch == 'true' + runs-on: ubuntu-latest + steps: + - name: 브랜치명에서 컨테이너 suffix 추출 + id: extract + uses: actions/github-script@v7 + with: + script: | + const branchName = '${{ needs.check-command.outputs.custom_branch }}'; + const issueMatch = branchName.match(/#(\d+)/); + + if (issueMatch) { + core.setOutput('container_suffix', `pr-${issueMatch[1]}`); + } else { + let hash = 0; + for (let i = 0; i < branchName.length; i++) { + const char = branchName.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0'); + core.setOutput('container_suffix', `custom-${hashStr}`); + } + + core.setOutput('branch_name', branchName); + + - name: 컨테이너 상태 확인 + id: check_running + uses: appleboy/ssh-action@v1.0.3 + continue-on-error: true + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + script: | + export PATH=$PATH:/usr/local/bin + + PROJECT_NAME="${{ env.PROJECT_NAME }}" + CONTAINER_SUFFIX="${{ steps.extract.outputs.container_suffix }}" + CONTAINER_NAME="${PROJECT_NAME}-${CONTAINER_SUFFIX}" + + docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}" + + - name: 상태 코멘트 작성 + uses: actions/github-script@v7 + with: + script: | + const projectName = '${{ env.PROJECT_NAME }}'; + const containerSuffix = '${{ steps.extract.outputs.container_suffix }}'; + const branchName = '${{ steps.extract.outputs.branch_name }}'; + const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}'; + const previewPort = '${{ env.PREVIEW_PORT }}'; + + const containerName = `${projectName}-${containerSuffix}`; + const previewUrl = `http://${containerName}.${domainSuffix}:${previewPort}`; + const isRunning = '${{ steps.check_running.outcome }}' === 'success'; + + let body; + if (isRunning) { + body = [ + '## ✅ Custom Branch Preview 환경 실행 중', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **Preview URL** | ${previewUrl} |`, + `| **컨테이너** | \`${containerName}\` |`, + '| **상태** | 🟢 Running |', + '', + '### 📋 명령어', + '| 명령어 | 설명 |', + '|--------|------|', + `| \`@suh-lab server build ${branchName}\` | 최신 커밋으로 재배포 |`, + `| \`@suh-lab server destroy ${branchName}\` | Preview 환경 삭제 |`, + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + } else { + body = [ + '## ❌ Custom Branch Preview 환경 없음', + '', + '| 항목 | 값 |', + '|------|-----|', + `| **브랜치** | \`${branchName}\` |`, + `| **컨테이너** | \`${containerName}\` |`, + '| **상태** | 🔴 Not Found |', + '', + `배포하려면: \`@suh-lab server build ${branchName}\``, + '', + '---', + '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*' + ].join('\n'); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); diff --git a/.github/workflows/PROJECT-SPRING-SIMPLE-CICD.yaml b/.github/workflows/PROJECT-SPRING-SIMPLE-CICD.yaml new file mode 100644 index 00000000..8f31571c --- /dev/null +++ b/.github/workflows/PROJECT-SPRING-SIMPLE-CICD.yaml @@ -0,0 +1,428 @@ +# =================================================================== +# Spring Boot CI/CD 배포 (SSH + Docker) +# =================================================================== +# +# deploy 브랜치 푸시 시 Docker 이미지를 빌드하고 +# SSH로 접속 가능한 서버(Synology·AWS EC2·VPS 등)에 자동 배포합니다. +# +# =================================================================== +# 🔑 필수 GitHub Secrets +# =================================================================== +# APPLICATION_PROD_YML (선택): application-prod.yml 내용 +# DOCKERHUB_USERNAME: DockerHub 사용자명 +# DOCKERHUB_TOKEN: DockerHub 액세스 토큰 +# SERVER_HOST: 배포 서버 주소 +# SERVER_USER: SSH 사용자명 +# SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때) +# SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등) +# =================================================================== +# +# 🔧 환경변수 설정 (env 섹션에서 설정) +# =================================================================== +# +# 🌐 포트 및 컨테이너: +# CONTAINER_INTERNAL_PORT: Spring Boot 내부 포트 (기본: 8080) +# DEPLOY_PORT: 배포 포트 +# CONTAINER_NAME: 컨테이너 이름 (기본: PROJECT_NAME) +# +# 📦 볼륨 마운트: +# ENABLE_VOLUME_MOUNT: 볼륨 마운트 활성화 (true/false) +# VOLUME_HOST_PATH: 호스트 볼륨 경로 +# VOLUME_CONTAINER_PATH: 컨테이너 내부 마운트 경로 +# +# 🔌 SSH 연결: +# SSH_PORT: SSH 포트 (기본: 2022) +# SSH_COMMAND_TIMEOUT: SSH 명령 타임아웃 (기본: 600s) +# SSH_CONNECTION_TIMEOUT: SSH 연결 타임아웃 (기본: 60s) +# +# ⏱️ 헬스체크: +# HEALTHCHECK_WAIT_SECONDS: 초기화 대기 시간 (기본: 10초) +# HEALTHCHECK_PATH: HTTP 엔드포인트 경로 (비어있으면 로그만 사용) +# HEALTHCHECK_MAX_RETRIES: HTTP 재시도 횟수 (기본: 5회) +# HEALTHCHECK_RETRY_INTERVAL: 재시도 간격 (기본: 3초) +# HEALTHCHECK_LOG_PATTERN: Fallback 로그 검색 패턴 +# +# 💡 HEALTHCHECK_PATH 예시: +# Spring Boot (Actuator): "/actuator/health" +# 커스텀: "/api/v1/health" +# +# 💡 HEALTHCHECK_LOG_PATTERN 예시: +# Spring Boot: "Tomcat started on port" +# =================================================================== +# +# 🌿 브랜치별 배포: +# - deploy 브랜치: 자동 배포 (DEPLOY_PORT 사용) +# - main 브랜치: 수동 실행만 가능 +# =================================================================== + +name: PROJECT-SPRING-SIMPLE-CICD + +# =================================================================== +# 트리거 설정 +# =================================================================== +# deploy 브랜치에 push할 때 자동으로 CI/CD가 실행됩니다. +# main 브랜치는 workflow_dispatch로 수동 실행만 가능합니다. +on: + # ⚠️ passQL 실배포는 PROJECT-SPRING-SYNOLOGY-PASSQL-CICD가 담당 — + # 이 템플릿의 env(PROJECT_NAME/DEPLOY_PORT)를 passQL에 맞게 설정하기 전까지 + # push 트리거를 활성화하지 말 것 (이중 배포 방지) + # push: + # branches: + # - deploy # 배포 환경 (DEPLOY_PORT 사용) + # paths: ['server/**'] + workflow_dispatch: # 수동 실행 허용 + +# =================================================================== +# 환경 변수 설정 +# =================================================================== +env: + # 🔧 프로젝트 설정 - 실제 프로젝트명으로 변경하세요 + PROJECT_NAME: "passQL" + + # ☁️ Spring Boot 설정 + SPRING_PROFILE: "prod" + JAVA_VERSION: "21" + GRADLE_OPTS: "-Dspring.profiles.active=prod" + + # 📁 경로 설정 + APPLICATION_YML_DIR: "server/PQL-Web/src/main/resources" + DOCKERFILE_PATH: "./Dockerfile" + + # 🌐 포트 설정 + CONTAINER_INTERNAL_PORT: "8080" # Spring Boot 내부 포트 (고정값) + DEPLOY_PORT: "8080" + + # 🐳 컨테이너 이름 설정 + # 비워두면 PROJECT_NAME을 기본값으로 사용 (예: mapsy-back) + # 커스텀 이름을 원하면 직접 입력 (예: "my-custom-container") + CONTAINER_NAME: "" + + # 📦 볼륨 마운트 설정 + # true: 볼륨 마운트 활성화 (디렉토리 없으면 자동 생성) + # false: 볼륨 마운트 비활성화 (기본값) + ENABLE_VOLUME_MOUNT: "false" + VOLUME_HOST_PATH: "/volume1/projects/passQL" + VOLUME_CONTAINER_PATH: "/mnt/passQL" + + # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem) + # password → SERVER_PASSWORD secret 사용 + # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로) + SSH_AUTH_METHOD: "password" + + # 🔌 SSH 연결 설정 + SSH_PORT: "2022" # SSH 포트 (Synology 기본: 2022) + SSH_COMMAND_TIMEOUT: "600s" # SSH 명령 실행 타임아웃 + SSH_CONNECTION_TIMEOUT: "60s" # SSH 연결 타임아웃 + + # ⏱️ 헬스체크 설정 + HEALTHCHECK_WAIT_SECONDS: "10" # 컨테이너 초기화 대기 시간 (초) + HEALTHCHECK_PATH: "/actuator/health" # HTTP 엔드포인트 경로 (비어있으면 로그 패턴만 사용) + HEALTHCHECK_MAX_RETRIES: "5" # HTTP 재시도 횟수 + HEALTHCHECK_RETRY_INTERVAL: "3" # 재시도 간격 (초) + HEALTHCHECK_LOG_PATTERN: "Tomcat started on port" # Fallback 로그 검색 패턴 + +jobs: + # =================================================================== + # 빌드 작업 + # =================================================================== + build: + name: Spring Boot 애플리케이션 빌드 + runs-on: ubuntu-latest + + steps: + # 1. 소스코드 체크아웃 + - name: 코드 체크아웃 + uses: actions/checkout@v5 + + # 2. Java 개발 환경 설정 + - name: Java 설정 + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: 'gradle' + + # 3. Gradle 실행 권한 부여 + - name: Gradle Wrapper 실행권한 부여 + run: chmod +x gradlew + + # 4. Spring Boot 운영 환경 설정 파일 생성 + # GitHub Secrets에서 APPLICATION_PROD_YML 값을 읽어와서 + # 리소스 디렉토리에 파일로 생성 + - name: application-prod.yml 생성 + run: | + # 리소스 디렉토리 생성 (환경변수 사용) + mkdir -p ${{ env.APPLICATION_YML_DIR }} + + # GitHub Secrets의 APPLICATION_PROD_YML 내용을 파일로 저장 + cat << 'EOF' > ./${{ env.APPLICATION_YML_DIR }}/application-prod.yml + ${{ secrets.APPLICATION_PROD_YML }} + EOF + + echo "✅ application-prod.yml 파일 생성 완료 (${{ env.APPLICATION_YML_DIR }})" + + # 5. Gradle 빌드 실행 + # 테스트는 제외하고 운영 프로파일로 빌드 + - name: Build with Gradle + run: ./gradlew clean build -x test ${{ env.GRADLE_OPTS }} + + # 6. Docker 빌드 환경 설정 + - name: Docker 빌드환경 설정 + uses: docker/setup-buildx-action@v3 + + # 7. DockerHub 로그인 + - name: DockerHub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # 8. Docker 레이어 캐싱 설정 + # 빌드 속도 향상을 위한 캐시 설정 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile') }} + restore-keys: | + ${{ runner.os }}-buildx- + + # 9. Docker 이미지 빌드 및 푸시 + # 브랜치명을 태그로 사용하여 환경별로 이미지 관리 + - name: Docker 이미지 빌드 및 푸시 + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:${{ github.ref_name }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + # 10. Docker 캐시 정리 + - name: Move Docker cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + # =================================================================== + # 배포 작업 + # =================================================================== + deploy: + name: 원격 서버 배포 + needs: build + runs-on: ubuntu-latest + + steps: + # SSH를 통한 원격 서버 배포 실행 + - name: Deploy + uses: appleboy/ssh-action@v1.0.3 + env: + SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} + port: ${{ env.SSH_PORT }} + envs: SSH_AUTH_METHOD + script: | + set -e + + # ============================================================ + # 배포 환경 변수 설정 + # ============================================================ + echo "🔧 환경변수 설정.." + export PATH=$PATH:/usr/local/bin + export PW=${{ secrets.SERVER_PASSWORD }} + + # 🔐 SSH 인증 방식에 따른 sudo 추상화 + # password 모드: echo $PW | sudo -S (Synology 등 sudo 비밀번호 필요 서버) + # key 모드: sudo 직접 (AWS EC2 등 passwordless sudo 서버) + SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}" + if [ "${SSH_AUTH_METHOD}" = "key" ]; then + SUDO() { sudo "$@"; } + else + SUDO() { echo "$PW" | sudo -S "$@"; } + fi + echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}" + + # GitHub에서 전달받은 브랜치명 + BRANCH=${{ github.ref_name }} + + # 프로젝트 설정 + PROJECT_NAME="${{ env.PROJECT_NAME }}" + + # ============================================================ + # 포트 및 컨테이너명 설정 + # ============================================================ + PORT=${{ env.DEPLOY_PORT }} + + # 컨테이너 이름 설정 (환경변수가 비어있으면 PROJECT_NAME을 기본값으로 사용) + if [ -z "${{ env.CONTAINER_NAME }}" ]; then + CONTAINER_NAME="${PROJECT_NAME}" + echo "ℹ️ CONTAINER_NAME이 설정되지 않아 PROJECT_NAME을 사용합니다: $CONTAINER_NAME" + else + CONTAINER_NAME="${{ env.CONTAINER_NAME }}" + echo "ℹ️ 사용자 지정 CONTAINER_NAME 사용: $CONTAINER_NAME" + fi + + echo "🚀 배포 환경으로 배포합니다 (브랜치: $BRANCH)" + + # 설정 정보 출력 + echo "📋 배포 설정 정보:" + echo " - 브랜치: $BRANCH" + echo " - 프로젝트: $PROJECT_NAME" + echo " - 컨테이너 이름: $CONTAINER_NAME" + echo " - 포트: $PORT" + echo " - Docker 이미지: ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH}" + + # ============================================================ + # Docker 이미지 풀 (Pull) + # ============================================================ + echo "⬇️ Docker 이미지 풀: ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH}" + SUDO docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH} + + # ============================================================ + # 기존 컨테이너 정리 + # ============================================================ + echo "🧹 컨테이너 $CONTAINER_NAME 존재 여부 확인 중..." + + # 동일한 이름의 컨테이너가 존재하는지 확인 + if SUDO docker ps -a --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}\$"; then + echo "⚠️ 컨테이너 $CONTAINER_NAME 이(가) 존재합니다. 중지 및 삭제 중..." + SUDO docker rm -f $CONTAINER_NAME + echo "✅ 컨테이너 $CONTAINER_NAME 이(가) 삭제되었습니다." + else + echo "ℹ️ 존재하는 컨테이너 $CONTAINER_NAME 이(가) 없습니다." + fi + + # ============================================================ + # 새 컨테이너 실행 + # ============================================================ + echo "🚀 새로운 컨테이너 $CONTAINER_NAME 실행 중..." + + # 볼륨 마운트 옵션 설정 + VOLUME_MOUNT_OPTION="" + if [ "${{ env.ENABLE_VOLUME_MOUNT }}" == "true" ]; then + # 디렉토리가 없으면 생성 + if [ ! -d "${{ env.VOLUME_HOST_PATH }}" ]; then + echo "📁 볼륨 마운트 디렉토리 생성: ${{ env.VOLUME_HOST_PATH }}" + SUDO mkdir -p "${{ env.VOLUME_HOST_PATH }}" + fi + VOLUME_MOUNT_OPTION="-v ${{ env.VOLUME_HOST_PATH }}:${{ env.VOLUME_CONTAINER_PATH }}" + echo "📦 볼륨 마운트 활성화: $VOLUME_MOUNT_OPTION" + else + echo "ℹ️ 볼륨 마운트 비활성화됨" + fi + + # Docker 컨테이너 실행 + # - 포트 매핑: ${PORT}:${{ env.CONTAINER_INTERNAL_PORT }} (외부:내부) + # - 시간대: Asia/Seoul + # - Spring 프로파일: ${{ env.SPRING_PROFILE }} + # - 볼륨 마운트: ENABLE_VOLUME_MOUNT=true일 때만 활성화 + SUDO docker run -d \ + -p ${PORT}:${{ env.CONTAINER_INTERNAL_PORT }} \ + --name $CONTAINER_NAME \ + -e TZ=Asia/Seoul \ + -e "SPRING_PROFILES_ACTIVE=${{ env.SPRING_PROFILE }}" \ + -v /etc/localtime:/etc/localtime:ro \ + $VOLUME_MOUNT_OPTION \ + ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH} + + # ============================================================ + # 컨테이너 헬스체크 + # ============================================================ + echo "⏳ 컨테이너 초기화 대기 중... (${{ env.HEALTHCHECK_WAIT_SECONDS }}초)" + sleep ${{ env.HEALTHCHECK_WAIT_SECONDS }} + + # 1단계: 컨테이너 프로세스 확인 + echo "🏥 컨테이너 헬스체크 중..." + if ! SUDO docker ps --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}\$"; then + echo "❌ 컨테이너 $CONTAINER_NAME 프로세스가 실행되지 않았습니다" + SUDO docker logs --tail 50 $CONTAINER_NAME || true + exit 1 + fi + echo "✅ 컨테이너 프로세스 실행 확인 완료" + + # 2단계: HTTP 엔드포인트 체크 (설정된 경우) + HEALTHCHECK_SUCCESS=false + if [ -n "${{ env.HEALTHCHECK_PATH }}" ]; then + echo "🌐 HTTP 헬스체크 시작: http://localhost:${PORT}${{ env.HEALTHCHECK_PATH }}" + + for i in $(seq 1 ${{ env.HEALTHCHECK_MAX_RETRIES }}); do + echo " 시도 $i/${{ env.HEALTHCHECK_MAX_RETRIES }}..." + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:${PORT}${{ env.HEALTHCHECK_PATH }} || echo "000") + + if [ "$HTTP_CODE" = "200" ]; then + echo "✅ HTTP 헬스체크 성공 (응답 코드: $HTTP_CODE)" + HEALTHCHECK_SUCCESS=true + break + else + echo "⚠️ HTTP 헬스체크 실패 (응답 코드: $HTTP_CODE)" + if [ $i -lt ${{ env.HEALTHCHECK_MAX_RETRIES }} ]; then + echo " ${{ env.HEALTHCHECK_RETRY_INTERVAL }}초 후 재시도..." + sleep ${{ env.HEALTHCHECK_RETRY_INTERVAL }} + fi + fi + done + + # HTTP 체크 실패 시 fallback으로 로그 패턴 매칭 + if [ "$HEALTHCHECK_SUCCESS" = "false" ]; then + echo "⚠️ HTTP 헬스체크 최종 실패, 로그 패턴 매칭으로 fallback..." + fi + else + echo "ℹ️ HEALTHCHECK_PATH가 설정되지 않아 로그 패턴 매칭만 사용합니다" + fi + + # 3단계: 로그 패턴 매칭 (HTTP 실패 시 또는 HTTP 체크 비활성화 시) + if [ "$HEALTHCHECK_SUCCESS" = "false" ]; then + echo "📋 로그 패턴 매칭 시작: '${{ env.HEALTHCHECK_LOG_PATTERN }}'" + + if SUDO docker logs $CONTAINER_NAME 2>&1 | grep -q "${{ env.HEALTHCHECK_LOG_PATTERN }}"; then + echo "✅ 로그 패턴 매칭 성공: 애플리케이션이 정상적으로 시작되었습니다" + HEALTHCHECK_SUCCESS=true + else + echo "❌ 로그 패턴 매칭 실패: 애플리케이션 시작 로그를 찾을 수 없습니다" + echo "" + echo "📋 디버깅 정보:" + echo " - 검색 패턴: ${{ env.HEALTHCHECK_LOG_PATTERN }}" + echo " - 컨테이너: $CONTAINER_NAME" + echo " - 이미지: ${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${BRANCH}" + echo " - 포트: ${PORT}:${{ env.CONTAINER_INTERNAL_PORT }}" + echo " - 볼륨: $VOLUME_MOUNT_OPTION" + echo " - 프로파일: ${{ env.SPRING_PROFILE }}" + echo "" + echo "📋 컨테이너 로그 (최근 50줄):" + SUDO docker logs --tail 50 $CONTAINER_NAME || true + exit 1 + fi + fi + + # 최종 성공 메시지 + if [ "$HEALTHCHECK_SUCCESS" = "true" ]; then + echo "✅ 헬스체크 완료: 컨테이너 $CONTAINER_NAME 이(가) 정상적으로 실행 중입니다" + fi + + # ============================================================ + # 배포 완료 확인 + # ============================================================ + echo "✅ 배포가 성공적으로 완료되었습니다!" + echo "" + echo "📋 배포 결과 요약:" + echo " 🎯 프로젝트: $PROJECT_NAME" + echo " 🌿 브랜치: $BRANCH" + echo " 🐳 컨테이너: $CONTAINER_NAME" + echo " 🌐 포트: $PORT" + echo " ⏰ 배포 시간: $(date '+%Y-%m-%d %H:%M:%S')" + echo "" + echo "🔗 접속 URL: http://${{ secrets.SERVER_HOST }}:${PORT}" + +# =================================================================== +# 📝 사용 방법 +# =================================================================== +# 1. GitHub Secrets 설정 (DOCKERHUB_*, SERVER_*) +# 2. env 섹션에서 PROJECT_NAME, DEPLOY_PORT 설정 +# 3. deploy 브랜치에 push → 자동 CI/CD 실행 +# =================================================================== diff --git a/.github/workflows/PROJECT-SPRING-SYNOLOGY-SIMPLE-CICD.yaml b/.github/workflows/PROJECT-SPRING-SYNOLOGY-SIMPLE-CICD.yaml index 68dcfbbc..4452f086 100644 --- a/.github/workflows/PROJECT-SPRING-SYNOLOGY-SIMPLE-CICD.yaml +++ b/.github/workflows/PROJECT-SPRING-SYNOLOGY-SIMPLE-CICD.yaml @@ -62,9 +62,11 @@ name: PROJECT-SPRING-SYNOLOGY-CICD # deploy 브랜치에 push할 때 자동으로 CI/CD가 실행됩니다. # main 브랜치는 workflow_dispatch로 수동 실행만 가능합니다. on: - push: - branches: - - deploy # 배포 환경 (DEPLOY_PORT 사용) + # ⚠️ 이 파일은 타 프로젝트(mapsy-back/MS-Web) 설정이 남아있는 템플릿 잔재 — + # deploy push마다 잘못된 빌드가 돌지 않도록 push 트리거 비활성화 + # push: + # branches: + # - deploy # 배포 환경 (DEPLOY_PORT 사용) workflow_dispatch: # 수동 실행 허용 # =================================================================== diff --git a/CHANGELOG.json b/CHANGELOG.json index dcdad330..5b673a5f 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,9 +1,9 @@ { "metadata": { - "lastUpdated": "2026-06-16T14:44:45Z", - "currentVersion": "0.0.189", + "lastUpdated": "2026-07-04T09:01:25Z", + "currentVersion": "0.0.190", "projectType": "spring", - "totalReleases": 93, + "totalReleases": 94, "projectTypes": [ "spring", "flutter", @@ -12,6 +12,35 @@ ] }, "releases": [ + { + "version": "0.0.190", + "project_type": "spring", + "project_types": [ + "spring", + "flutter", + "react", + "python" + ], + "date": "2026-07-04", + "pr_number": 326, + "raw_summary": "## Summary by CodeRabbit\n\n## 릴리스 노트\n\n* **버그 수정**\n * 로그인이 되지 않던 문제 해결\n\n* **개선**\n * 서버 업데이트 반영 과정의 안정성 개선\n * 배포 자동화 정리로 중복 배포 방지", + "parsed_changes": { + "버그_수정": { + "title": "버그 수정", + "items": [ + "로그인이 되지 않던 문제 해결" + ] + }, + "개선": { + "title": "개선", + "items": [ + "서버 업데이트 반영 과정의 안정성 개선", + "배포 자동화 정리로 중복 배포 방지" + ] + } + }, + "parse_method": "markdown" + }, { "version": "0.0.189", "project_type": "spring", diff --git a/CHANGELOG.md b/CHANGELOG.md index af2868e6..34c42957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,20 @@ # Changelog -**현재 버전:** 0.0.189 -**마지막 업데이트:** 2026-06-16T14:44:45Z +**현재 버전:** 0.0.190 +**마지막 업데이트:** 2026-07-04T09:01:25Z + +--- + +## [0.0.190] - 2026-07-04 + +**PR:** #326 + +**버그 수정** +- 로그인이 되지 않던 문제 해결 + +**개선** +- 서버 업데이트 반영 과정의 안정성 개선 +- 배포 자동화 정리로 중복 배포 방지 --- diff --git a/README.md b/README.md index 2ced9fcd..c554c993 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > 당신은 SQLD 시험과 동일한 Oracle SQL 환경에서 직접 실행하며 검증합니다. -## 최신 버전 : v0.0.188 (2026-06-13) +## 최신 버전 : v0.0.189 (2026-06-16) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md b/SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md new file mode 100644 index 00000000..f447f92a --- /dev/null +++ b/SUH-DEVOPS-TEMPLATE-SETUP-GUIDE.md @@ -0,0 +1,336 @@ +# 🚀 SUH-DEVOPS-TEMPLATE 빠른 시작 가이드 + +> **3가지만 하면 끝!** 나머지는 모두 자동화됩니다. + +이 템플릿은 GitHub 템플릿 또는 원격 스크립트로 설치하면 **자동으로 초기화**됩니다. +사용자는 단 **3가지 설정**만 하면 모든 자동화가 작동합니다. + +--- + +## 🎯 3가지 필수 작업 + +### 1️⃣ GitHub Personal Access Token 설정 + +#### 토큰 생성 +1. **GitHub** → **Settings** → **Developer settings** → **Personal access tokens (Classic)** +2. **Generate new token (classic)** 클릭 +3. 토큰 설정: + - **Name**: `_GITHUB_PAT_TOKEN` + - **Expiration**: 90 days (또는 조직 정책에 따라) + - **Scopes**: ✅ `repo` (Full control), ✅ `workflow` (Update workflows) +4. **Generate token** 클릭 후 토큰 복사 + +#### Secret 등록 +1. **프로젝트 저장소** → **Settings** → **Secrets and variables** → **Actions** +2. **New repository secret** 클릭 +3. **Name**: `_GITHUB_PAT_TOKEN` +4. **Secret**: [위에서 복사한 토큰 값 붙여넣기] +5. **Add secret** 클릭 + +--- + +### 2️⃣ deploy 브랜치 생성 + +```bash +# deploy 브랜치 생성 및 푸시 +git checkout -b deploy +git push -u origin deploy + +# main 브랜치로 돌아가기 +git checkout main +``` + +**설명**: `deploy` 브랜치는 체인지로그 자동 생성 및 배포 워크플로우가 실행되는 브랜치입니다. + +--- + +### 3️⃣ CodeRabbit 활성화 + +1. [CodeRabbit 웹사이트](https://coderabbit.ai) 접속 +2. GitHub 계정으로 로그인 +3. 저장소 목록에서 프로젝트 선택하여 활성화 +4. `.coderabbit.yaml` 파일이 프로젝트에 있으면 자동으로 설정 적용됨 + +**설명**: CodeRabbit은 AI 기반 코드 리뷰 및 체인지로그 자동 생성을 담당합니다. + +--- + +## 🛠️ IDE 도구 (Skills) 설치 (선택) + +template_integrator로 통합 시 자동 안내되지만, 수동으로도 설치할 수 있습니다. + +| IDE | 설치 방법 | 사용 예시 | +|-----|----------|----------| +| **Claude Code** | 플러그인 마켓플레이스 (CLI) | `/cassiiopeia:suh-analyze`, `/cassiiopeia:suh-review` | +| **Cursor** | `.cursor/skills/` 폴더 복사 | Skills 패널에서 선택 | +| **Gemini CLI** | `gemini extensions install` | extension 명령 | +| **Codex CLI** | `codex plugin marketplace add` | `/plugins`에서 확인 | + +### Claude Code + +```bash +# 마켓플레이스 등록 + 플러그인 설치 +claude plugin marketplace add Cassiiopeia/SUH-DEVOPS-TEMPLATE +claude plugin install cassiiopeia@cassiiopeia-marketplace --scope user +``` + +### Cursor + +template_integrator 실행 시 Cursor 설치를 선택하면 자동으로 `skills/` → `.cursor/skills/`로 복사됩니다. + +--- + +## 🎉 완료! + +**이제 코드를 푸시하면 모든 자동화가 작동합니다.** + +- ✅ 버전 자동 증가 (1.0.0 → 1.0.1) +- ✅ README 버전 자동 업데이트 +- ✅ Git 태그 자동 생성 +- ✅ 체인지로그 자동 생성 + +--- + +## ✨ 자동으로 처리되는 것들 + +### 템플릿 사용 시 (GitHub "Use this template") + +**프로젝트 생성 즉시 자동 실행**: +- ✅ `version.yml` 자동 생성 (v0.0.0, basic 타입) +- ✅ 공통 워크플로우 자동 설치 (주요 항목): + - `PROJECT-COMMON-VERSION-CONTROL.yaml` (버전 자동 관리) + - `PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml` (체인지로그 생성) + - `PROJECT-COMMON-README-VERSION-UPDATE.yaml` (README 업데이트) + - `PROJECT-COMMON-SUH-ISSUE-HELPER-MODULE.yml` (이슈 브랜치/커밋 제안) + - `PROJECT-COMMON-QA-ISSUE-CREATION-BOT.yaml` (QA 이슈 자동 생성) + - `PROJECT-COMMON-SYNC-ISSUE-LABELS.yaml` (라벨 동기화) + - `PROJECT-COMMON-PROJECTS-SYNC-MANAGER.yaml` (Projects 상태 동기화) +- ✅ README 버전 섹션 자동 추가 +- ✅ 불필요한 템플릿 파일 자동 삭제 +- ✅ Default 브랜치 자동 감지 + +--- + +### 원격 스크립트 사용 시 (기존 프로젝트에 통합) + +```bash +# 대화형 모드 (권장) +bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/main/template_integrator.sh") + +# 비대화형 모드 (Spring Boot 전체 통합 예시) +bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/main/template_integrator.sh") \ + --mode full --type spring --version 1.0.0 --force +``` + +**자동으로 수행되는 작업**: +- ✅ 프로젝트 타입 자동 감지 (Spring, Flutter, React, Node, Python 등) — 한 레포에 여러 타입이 섞인 모노레포는 멀티타입으로 함께 인식 +- ✅ 현재 버전 자동 감지 (Git 태그, build.gradle, package.json 등) +- ✅ 프로젝트 타입에 맞는 워크플로우만 선택 복사 +- ✅ `version.yml` 자동 생성 +- ✅ README에 버전 섹션 자동 추가 +- ✅ 버전 관리 스크립트 자동 설치 + +**지원하는 프로젝트 타입**: +- `spring` - Spring Boot / Java / Gradle +- `flutter` - Flutter / Dart +- `react` - React.js / Next.js +- `react-native` - React Native (iOS + Android) +- `react-native-expo` - Expo 기반 React Native +- `node` - Node.js / Express +- `python` - Python / FastAPI / Django +- `basic` - 기본 타입 (버전 관리만) + +--- + +### 매 커밋마다 자동 실행 + +#### main 브랜치 푸시 시 +- ✅ 커밋 메시지 분석 (`feat:`, `fix:`, `docs:` 등) +- ✅ 버전 자동 증가 (1.0.0 → 1.0.1) +- ✅ README 버전 자동 업데이트 +- ✅ Git 태그 자동 생성 (`v1.0.1`) +- ✅ 프로젝트별 버전 파일 동기화: + - Spring: `build.gradle` 또는 `pom.xml` + - Flutter: `pubspec.yaml` + - React/Node: `package.json` + - React Native: `package.json`, `ios/Info.plist`, `android/build.gradle` + - Python: `pyproject.toml` + +#### deploy 브랜치로 PR 생성/머지 시 +- ✅ CodeRabbit AI 자동 코드 리뷰 +- ✅ 체인지로그 자동 생성 (`CHANGELOG.json`, `CHANGELOG.md`) +- ✅ PR 자동 머지 (리뷰 통과 시) + +--- + +## 🏢 Organization 사용 시 추가 설정 + +Organization 저장소에서는 아래 3가지 추가 설정이 필요합니다: + +### 1. Actions 설정 +``` +Organization Settings → Actions → General +├── ✅ Allow GitHub Actions to create and approve pull requests +└── ✅ Allow GitHub Actions to merge pull requests +``` + +### 2. Repository 설정 +``` +Repository Settings → General → Pull Requests +├── ✅ Allow auto-merge +└── ✅ Allow squash merging +``` + +### 3. Member 권한 확인 +``` +Organization Settings → Member privileges +└── Personal access token expiration policy: 조직 정책에 맞게 설정 +``` + +💡 **개인 저장소는 추가 설정 불필요합니다.** + +--- + +## 🧪 동작 확인 + +### 첫 번째 테스트: 버전 자동 증가 + +```bash +# main 브랜치에 테스트 커밋 +echo "# 테스트" >> TEST.md +git add TEST.md +git commit -m "test: 자동화 테스트" +git push origin main +``` + +**예상 결과** (GitHub Actions 탭에서 확인): +1. `PROJECT-COMMON-VERSION-CONTROL` 워크플로우 실행 +2. 버전 자동 증가 (예: v0.0.0 → v0.0.1) +3. Git 태그 `v0.0.1` 자동 생성 +4. README 버전 자동 업데이트 + +--- + +### 두 번째 테스트: 체인지로그 자동 생성 + +```bash +# feature 브랜치 생성 및 작업 +git checkout -b feature/test-changelog +echo "# 체인지로그 테스트" >> TEST2.md +git add TEST2.md +git commit -m "feat: 체인지로그 테스트 기능" +git push origin feature/test-changelog + +# GitHub에서 deploy 브랜치로 PR 생성 +``` + +**예상 결과**: +1. CodeRabbit AI 자동 리뷰 +2. `CHANGELOG.json`, `CHANGELOG.md` 자동 생성 +3. PR 자동 머지 (리뷰 통과 시) + +--- + +## 🚨 자주 묻는 질문 + +### Q1: 워크플로우가 실행되지 않아요 + +**원인**: `deploy` 브랜치에 워크플로우 파일이 없을 수 있습니다. + +**해결**: +```bash +git checkout deploy +ls .github/workflows/ + +# 파일이 없다면 main에서 복사 +git checkout main +git checkout deploy +git merge main +git push origin deploy +``` + +--- + +### Q2: 토큰 권한 오류가 발생해요 + +**증상**: +``` +remote: Permission to ... denied to github-actions[bot] +``` + +**해결**: +1. 토큰이 **Classic** 타입인지 확인 (Fine-grained 아님) +2. `repo`, `workflow` 권한 **모두** 체크되었는지 확인 +3. Organization 설정에서 PAT 정책 확인 +4. 토큰 만료 날짜 확인 + +--- + +### Q3: 버전이 동기화되지 않아요 + +**해결**: 수동 동기화 실행 +```bash +# 현재 버전 상태 확인 +.github/scripts/version_manager.sh get + +# 모든 파일 동기화 +.github/scripts/version_manager.sh sync + +# 특정 버전으로 강제 설정 +.github/scripts/version_manager.sh set 1.0.0 +``` + +--- + +### Q4: CodeRabbit 리뷰가 동작하지 않아요 + +**확인 사항**: +1. `.coderabbit.yaml` 파일이 프로젝트 루트에 있는지 확인 +2. CodeRabbit이 저장소에 액세스 권한이 있는지 확인 +3. PR에 충분한 변경사항이 있는지 확인 (1줄 이상) + +--- + +### Q5: Spring Boot 프로젝트인데 Nexus 배포 워크플로우가 없어요 + +**답변**: Nexus 워크플로우는 Spring 타입 선택 시 자동으로 복사됩니다: +- `PROJECT-SPRING-NEXUS-CI.yml` +- `PROJECT-SPRING-NEXUS-PUBLISH.yml` + +**추가 설정 필요**: +1. GitHub Secrets에 Nexus 인증 정보 등록: + - `NEXUS_USERNAME` + - `NEXUS_PASSWORD` + - `NEXUS_URL` +2. `gradle.properties` 또는 `build.gradle`에 Nexus 저장소 설정 + +--- + +## 📚 추가 문서 + +- [CONTRIBUTING.md](CONTRIBUTING.md) - 상세 기여 가이드 및 워크플로우 설명 +- [CHANGELOG.md](CHANGELOG.md) - 전체 변경 이력 +- [README.md](README.md) - 프로젝트 개요 및 빠른 시작 + +--- + +## 💡 다음 단계 + +### 프로젝트 타입 변경하려면? + +```bash +# 원격 스크립트로 타입 변경 +bash <(curl -fsSL "https://raw.githubusercontent.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/main/template_integrator.sh") +``` + +### 고급 기능 활용 + +1. **수동 워크플로우 실행**: GitHub Actions 탭에서 `workflow_dispatch` 트리거 활용 +2. **멀티 환경 배포**: `version.yml`에 환경별 설정 추가 +3. **커스텀 워크플로우**: `.github/workflows/`에 프로젝트별 워크플로우 추가 가능 + +--- + +**🎉 축하합니다! 이제 완전 자동화된 DevOps 환경이 구축되었습니다.** + +추가 질문이나 문제가 있다면 [이슈를 생성](https://github.com/Cassiiopeia/SUH-DEVOPS-TEMPLATE/issues/new/choose)해 주세요. diff --git a/ai/pyproject.toml b/ai/pyproject.toml index 2dfee678..1e84f4aa 100644 --- a/ai/pyproject.toml +++ b/ai/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "passql-ai" -version = "0.0.189" +version = "0.0.190" description = "passQL AI Server - SQL 오류 설명, diff 해설, 유사 문제 검색" readme = "README.md" requires-python = ">=3.13" diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index d3f0be4e..ea7eb447 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -61,17 +61,17 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/vibration/ios" SPEC CHECKSUMS: - app_settings: 9217ccbe946aa9a3751b5459dcf50dabc87517b2 - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + app_settings: 0341ec6daa4f0c50f5a421bf0ad7c36084db6e90 + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f - flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - vibration: 8e4d81c3e73ab229a9fb5ab2692f789c4a46d74d + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/app/pubspec.lock b/app/pubspec.lock index 17f53fe0..5a5591a4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -660,10 +660,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1065,10 +1065,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timezone: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3c6f06af..66caca0d 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: passql_app description: "passQL - SQL 학습 앱" publish_to: none -version: 0.0.189+188 +version: 0.0.190+189 environment: sdk: ^3.9.2 dependencies: diff --git a/client/package-lock.json b/client/package-lock.json index 024976f4..8ae34365 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "1.0.0", + "version": "0.0.188", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "1.0.0", + "version": "0.0.188", "dependencies": { "@tanstack/react-query": "^5.96.2", "firebase": "^12.12.0", diff --git a/client/package.json b/client/package.json index c07b07cd..a4e20442 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.0.189", + "version": "0.0.190", "type": "module", "scripts": { "dev": "vite", diff --git a/server/PQL-Web/src/main/resources/application.yml b/server/PQL-Web/src/main/resources/application.yml index f5ef6aee..7ee31074 100644 --- a/server/PQL-Web/src/main/resources/application.yml +++ b/server/PQL-Web/src/main/resources/application.yml @@ -12,6 +12,9 @@ spring: baseline-on-migrate: true locations: classpath:db/migration validate-on-migrate: false + # DB가 이미 높은 버전까지 적용된 뒤 낮은 번호 마이그레이션이 늦게 추가되면 + # 기본 설정은 조용히 스킵함 (V0_0_95 로그인 장애 원인) — 반드시 유지 + out-of-order: true server: port: 8080 diff --git a/server/build.gradle b/server/build.gradle index e338cdc3..78fa8ead 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -8,7 +8,7 @@ bootJar { enabled = false } allprojects { group = 'com.passql' - version = '0.0.189' + version = '0.0.190' repositories { mavenCentral() maven { diff --git a/version.yml b/version.yml index 489717dd..ff95bd97 100644 --- a/version.yml +++ b/version.yml @@ -35,8 +35,8 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "0.0.189" -version_code: 189 # app build number +version: "0.0.190" +version_code: 190 # app build number project_types: ["spring", "flutter", "react", "python"] # 멀티타입 배열 — 첫 항목이 primary, 직접 편집 가능 project_type: "spring" # project_types[0] 자동 미러 — 직접 수정 금지 (spring, flutter, next, react, react-native, react-native-expo, node, python, basic) project_paths: # 타입별 프로젝트 폴더 (레포 루트 기준 상대경로) @@ -45,7 +45,7 @@ project_paths: # 타입별 프로젝트 폴더 (레포 루트 기준 상대경 react: "client" # client/package.json python: "ai" # ai/pyproject.toml metadata: - last_updated: "2026-06-16 14:43:54" + last_updated: "2026-07-04 09:00:24" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"