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 입력이 우선합니다.
+
+ - store_only — 내부 테스트(internal) 업로드까지만 (기본)
+ - store_prepare — production draft 승급 (콘솔에서 '출시 시작' 대기)
+ - store_submit — production 심사 자동 등록
+
+
⚠️ 신규 앱은 최초 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 입력이 우선합니다.
+
+ - store_only — TestFlight 업로드까지만 (기본)
+ - store_prepare — App Store 제출 직전까지 (ASC에서 사람이 'Add for Review')
+ - store_submit — App Store 심사 자동 제출
+
+
⚠️ 신규 앱은 최초 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"