project-auto-wizard 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/bin/project-auto-wizard.js +19 -0
  4. package/package.json +36 -0
  5. package/payload/coderabbit.yaml +19 -0
  6. package/payload/config/breaking-changes.json +1 -0
  7. package/payload/config/wizard-prompts.yml +62 -0
  8. package/payload/scripts/changelog_manager.py +726 -0
  9. package/payload/scripts/version_manager.py +617 -0
  10. package/payload/version.yml.template +48 -0
  11. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +303 -0
  12. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +293 -0
  13. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +289 -0
  14. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +192 -0
  15. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +209 -0
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +591 -0
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +700 -0
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +308 -0
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +992 -0
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +689 -0
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +987 -0
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +471 -0
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml +500 -0
  24. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +185 -0
  25. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +271 -0
  26. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +81 -0
  27. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +2191 -0
  28. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +386 -0
  29. package/payload/workflows/react/PROJECT-REACT-CI.yaml +194 -0
  30. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +255 -0
  31. package/payload/workflows/spring/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +84 -0
  32. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +316 -0
  33. package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +58 -0
  34. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +535 -0
  35. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +437 -0
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +2277 -0
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +425 -0
  38. package/src/cli/args.js +95 -0
  39. package/src/cli/help.js +27 -0
  40. package/src/commands/full.js +53 -0
  41. package/src/commands/interactive.js +276 -0
  42. package/src/commands/revert.js +60 -0
  43. package/src/commands/version.js +31 -0
  44. package/src/commands/workflows.js +49 -0
  45. package/src/context.js +26 -0
  46. package/src/core/assets.js +47 -0
  47. package/src/core/branches.js +60 -0
  48. package/src/core/branding.js +15 -0
  49. package/src/core/breaking-check.js +65 -0
  50. package/src/core/breaking.js +26 -0
  51. package/src/core/copy/coderabbit.js +26 -0
  52. package/src/core/copy/gitignore.js +55 -0
  53. package/src/core/copy/readme.js +30 -0
  54. package/src/core/copy/simple.js +23 -0
  55. package/src/core/copy/workflows.js +233 -0
  56. package/src/core/detect-fs.js +106 -0
  57. package/src/core/detect.js +62 -0
  58. package/src/core/fsutil.js +46 -0
  59. package/src/core/options-ask.js +99 -0
  60. package/src/core/paths-resolve.js +261 -0
  61. package/src/core/paths.js +16 -0
  62. package/src/core/version-yml.js +190 -0
  63. package/src/core/wizard-env.js +101 -0
  64. package/src/core/wizard-labels.js +107 -0
  65. package/src/index.js +162 -0
  66. package/src/ui/ansi.js +26 -0
  67. package/src/ui/banner.js +29 -0
  68. package/src/ui/env-plan.js +207 -0
  69. package/src/ui/prompts.js +99 -0
  70. package/src/ui/readline-engine.js +257 -0
  71. package/src/ui/status-cards.js +56 -0
  72. package/src/ui/summary.js +127 -0
@@ -0,0 +1,2191 @@
1
+ # ===================================================================
2
+ # Python/FastAPI PR Preview (SSH + Docker + Traefik)
3
+ # ===================================================================
4
+ #
5
+ # PR 또는 Issue 코멘트 명령어를 통해 Preview 환경을 자동으로 관리합니다.
6
+ # Traefik 리버스 프록시를 통해 동적 라우팅됩니다.
7
+ #
8
+ # ===================================================================
9
+ # 🔑 필수 GitHub Secrets
10
+ # ===================================================================
11
+ # ENV_FILE: .env 파일 전체 내용
12
+ # DOCKERHUB_USERNAME: Docker Hub 사용자명
13
+ # DOCKERHUB_TOKEN: Docker Hub 액세스 토큰
14
+ # SERVER_HOST: 서버 호스트(SSH 접속 주소)
15
+ # SERVER_USER: SSH 사용자명
16
+ # SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때)
17
+ # SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등)
18
+ # ===================================================================
19
+ #
20
+ # 🚀 사용법:
21
+ # @suh-lab server build - Preview 빌드 및 배포
22
+ # @suh-lab server destroy - Preview 환경 삭제
23
+ # @suh-lab server status - 현재 상태 확인
24
+ #
25
+ # 📊 리소스 네이밍:
26
+ # - 컨테이너: {PROJECT_NAME}-pr-{번호}
27
+ # - 도메인: {PROJECT_NAME}-pr-{번호}.pr.suhsaechan.kr
28
+ #
29
+ # 📋 사전 요구사항:
30
+ # - Traefik 컨테이너 실행 중 (traefik-network)
31
+ # - 와일드카드 DNS 설정: *.pr.suhsaechan.kr → 서버
32
+ # ===================================================================
33
+
34
+ name: PROJECT-PYTHON-PR-PREVIEW
35
+
36
+ # ===================================================================
37
+ # ⚠️ [영역 1] 프로젝트별 설정 - 다른 프로젝트에서 사용 시 이 섹션만 수정하세요
38
+ # ===================================================================
39
+ env:
40
+ # 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용)
41
+ PROJECT_NAME: "__PROJECT_NAME__" # @wizard ask:@repo
42
+
43
+ # Docker 설정
44
+ DOCKERFILE_PATH: './Dockerfile'
45
+ INTERNAL_PORT: '8000'
46
+
47
+ # 🗂️ 볼륨 마운트 설정 (프로젝트별 수정 필요, 빈값이면 마운트 안함)
48
+ PROJECT_TARGET_DIR: "/volume1/projects/프로젝트명/ai" # 서버의 데이터 디렉토리
49
+ PROJECT_MNT_DIR: "/mnt/프로젝트명" # 컨테이너 내부 마운트 경로
50
+
51
+ # Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지)
52
+ TRAEFIK_NETWORK: traefik-network
53
+ PREVIEW_DOMAIN_SUFFIX: pr.suhsaechan.kr
54
+ PREVIEW_PORT: '8079'
55
+
56
+ # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem)
57
+ # password → SERVER_PASSWORD secret 사용
58
+ # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로)
59
+ SSH_AUTH_METHOD: "password" # @wizard ask:password
60
+
61
+ # SSH 포트 (서버 포트 환경 구축 후 수정 금지)
62
+ SSH_PORT: '2022'
63
+
64
+ # Issue Helper 댓글 마커 (프로젝트별 수정 필요)
65
+ # Issue 댓글에서 브랜치명을 추출할 때 사용
66
+ ISSUE_HELPER_MARKER: 'Guide by SUH-LAB'
67
+
68
+ # Health Check 설정 (프로젝트별 수정 필요)
69
+ # - HEALTH_CHECK_PATH: HTTP Health Check 경로 (빈값이면 HTTP 체크 건너뜀)
70
+ # - HEALTH_CHECK_LOG_PATTERN: 로그 패턴 매칭 (HTTP 실패 시 폴백)
71
+ # - API_DOCS_PATH: API 문서 URL (빈값이면 배포 코멘트에 미표시)
72
+ #
73
+ # 프레임워크별 기본값 예시:
74
+ # FastAPI: '/docs', 'Uvicorn running on|Application startup complete'
75
+ # Spring Boot: '/actuator/health', 'Started.*Application|Tomcat started on port'
76
+ # Express: '/health', 'Server listening on port'
77
+ HEALTH_CHECK_PATH: '/docs'
78
+ HEALTH_CHECK_LOG_PATTERN: 'Uvicorn running on|Application startup complete'
79
+ API_DOCS_PATH: '/docs'
80
+
81
+ # ===================================================================
82
+ # 트리거 설정
83
+ # ===================================================================
84
+ on:
85
+ issue_comment:
86
+ types: [created] # PR 댓글 + Issue 댓글
87
+ issues:
88
+ types: [closed] # Issue 닫힘 시 destroy
89
+ pull_request:
90
+ types: [closed] # PR 닫힘 시 destroy
91
+
92
+ permissions:
93
+ contents: read
94
+ pull-requests: write
95
+ issues: write
96
+
97
+ # ===================================================================
98
+ # Jobs
99
+ # ===================================================================
100
+ jobs:
101
+ # -----------------------------------------------------------------
102
+ # Job 1: 명령어 파싱 (PR 댓글 + Issue 댓글 모두 지원)
103
+ # -----------------------------------------------------------------
104
+ check-command:
105
+ name: 명령어 확인
106
+ if: github.event_name == 'issue_comment'
107
+ runs-on: ubuntu-latest
108
+ outputs:
109
+ command: ${{ steps.parse.outputs.command }}
110
+ is_valid: ${{ steps.parse.outputs.is_valid }}
111
+ is_pr: ${{ steps.parse.outputs.is_pr }}
112
+ custom_branch: ${{ steps.parse.outputs.custom_branch }}
113
+ is_custom_branch: ${{ steps.parse.outputs.is_custom_branch }}
114
+ steps:
115
+ - name: 댓글에 👀 리액션 추가
116
+ if: contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server')
117
+ uses: actions/github-script@v7
118
+ with:
119
+ github-token: ${{ secrets.GITHUB_TOKEN }}
120
+ script: |
121
+ await github.rest.reactions.createForIssueComment({
122
+ owner: context.repo.owner,
123
+ repo: context.repo.repo,
124
+ comment_id: context.payload.comment.id,
125
+ content: 'eyes'
126
+ });
127
+
128
+ - name: 커맨드 파싱
129
+ id: parse
130
+ env:
131
+ COMMENT: ${{ github.event.comment.body }}
132
+ IS_PR: ${{ github.event.issue.pull_request != null }}
133
+ run: |
134
+ # PR인지 Issue인지 확인
135
+ if [[ "$IS_PR" == "true" ]]; then
136
+ echo "is_pr=true" >> $GITHUB_OUTPUT
137
+ echo "ℹ️ PR 댓글에서 명령어 감지"
138
+ else
139
+ echo "is_pr=false" >> $GITHUB_OUTPUT
140
+ echo "ℹ️ Issue 댓글에서 명령어 감지"
141
+ fi
142
+
143
+ # 명령어 파싱 (브랜치 파라미터 지원)
144
+ # 형식: @suh-lab server <command> [브랜치명]
145
+ if [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+build([[:space:]]+([^[:space:]]+))? ]]; then
146
+ echo "command=build" >> $GITHUB_OUTPUT
147
+ echo "is_valid=true" >> $GITHUB_OUTPUT
148
+ if [[ -n "${BASH_REMATCH[2]}" ]]; then
149
+ echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT
150
+ echo "is_custom_branch=true" >> $GITHUB_OUTPUT
151
+ echo "✅ 명령어 감지: build (브랜치: ${BASH_REMATCH[2]})"
152
+ else
153
+ echo "is_custom_branch=false" >> $GITHUB_OUTPUT
154
+ echo "✅ 명령어 감지: build"
155
+ fi
156
+ elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+destroy([[:space:]]+([^[:space:]]+))? ]]; then
157
+ echo "command=destroy" >> $GITHUB_OUTPUT
158
+ echo "is_valid=true" >> $GITHUB_OUTPUT
159
+ if [[ -n "${BASH_REMATCH[2]}" ]]; then
160
+ echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT
161
+ echo "is_custom_branch=true" >> $GITHUB_OUTPUT
162
+ echo "✅ 명령어 감지: destroy (브랜치: ${BASH_REMATCH[2]})"
163
+ else
164
+ echo "is_custom_branch=false" >> $GITHUB_OUTPUT
165
+ echo "✅ 명령어 감지: destroy"
166
+ fi
167
+ elif [[ "$COMMENT" =~ @suh-lab[[:space:]]+server[[:space:]]+status([[:space:]]+([^[:space:]]+))? ]]; then
168
+ echo "command=status" >> $GITHUB_OUTPUT
169
+ echo "is_valid=true" >> $GITHUB_OUTPUT
170
+ if [[ -n "${BASH_REMATCH[2]}" ]]; then
171
+ echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT
172
+ echo "is_custom_branch=true" >> $GITHUB_OUTPUT
173
+ echo "✅ 명령어 감지: status (브랜치: ${BASH_REMATCH[2]})"
174
+ else
175
+ echo "is_custom_branch=false" >> $GITHUB_OUTPUT
176
+ echo "✅ 명령어 감지: status"
177
+ fi
178
+ else
179
+ echo "is_valid=false" >> $GITHUB_OUTPUT
180
+ echo "ℹ️ @suh-lab server 명령어가 아님"
181
+ fi
182
+
183
+ # -----------------------------------------------------------------
184
+ # Job 2-1: 빌드 & 배포 (PR 댓글에서 실행)
185
+ # -----------------------------------------------------------------
186
+ build-preview-pr:
187
+ name: Preview 빌드 & 배포 (PR)
188
+ needs: check-command
189
+ if: |
190
+ needs.check-command.outputs.is_valid == 'true' &&
191
+ needs.check-command.outputs.command == 'build' &&
192
+ needs.check-command.outputs.is_pr == 'true' &&
193
+ needs.check-command.outputs.is_custom_branch != 'true'
194
+ runs-on: ubuntu-latest
195
+ steps:
196
+ - name: PR 정보 가져오기
197
+ id: pr
198
+ uses: actions/github-script@v7
199
+ with:
200
+ script: |
201
+ const pr = await github.rest.pulls.get({
202
+ owner: context.repo.owner,
203
+ repo: context.repo.repo,
204
+ pull_number: context.issue.number
205
+ });
206
+ core.setOutput('ref', pr.data.head.ref);
207
+ core.setOutput('sha', pr.data.head.sha.substring(0, 7));
208
+ return pr.data.number;
209
+
210
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
211
+ # 진행 상황 댓글 시스템
212
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
213
+ - name: 진행 상황 댓글 생성
214
+ id: progress
215
+ uses: actions/github-script@v7
216
+ with:
217
+ script: |
218
+ const prNumber = context.issue.number;
219
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
220
+ const startTime = Date.now();
221
+
222
+ const body = [
223
+ '## 🚀 PR Preview 빌드 중...',
224
+ '',
225
+ '| 단계 | 상태 | 소요 시간 |',
226
+ '|------|------|----------|',
227
+ '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
228
+ '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
229
+ '',
230
+ `**[📋 실시간 로그 보기](${runUrl})**`,
231
+ '',
232
+ '---',
233
+ '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
234
+ ].join('\n');
235
+
236
+ const { data: comment } = await github.rest.issues.createComment({
237
+ owner: context.repo.owner,
238
+ repo: context.repo.repo,
239
+ issue_number: prNumber,
240
+ body: body
241
+ });
242
+
243
+ core.setOutput('comment_id', comment.id);
244
+ core.setOutput('start_time', startTime);
245
+ core.setOutput('docker_start', startTime);
246
+
247
+ - name: 코드 체크아웃
248
+ uses: actions/checkout@v5
249
+ with:
250
+ ref: ${{ steps.pr.outputs.ref }}
251
+
252
+ # =================================================================
253
+ # ⚠️ [영역 2] 환경변수 파일 생성
254
+ # =================================================================
255
+ - name: "[필수] .env 파일 생성"
256
+ run: |
257
+ cat << 'EOF' > .env
258
+ ${{ secrets.ENV_FILE }}
259
+ EOF
260
+ # =================================================================
261
+ # ⚠️ [영역 2 끝] 환경변수 파일 생성 끝
262
+ # =================================================================
263
+
264
+ - name: Docker 로그인
265
+ uses: docker/login-action@v3
266
+ with:
267
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
268
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
269
+
270
+ - name: Docker Buildx 설정
271
+ uses: docker/setup-buildx-action@v3
272
+
273
+ - name: Docker 이미지 빌드 & Push
274
+ uses: docker/build-push-action@v5
275
+ with:
276
+ context: .
277
+ file: ${{ env.DOCKERFILE_PATH }}
278
+ push: true
279
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ github.event.issue.number }}
280
+ cache-from: type=gha
281
+ cache-to: type=gha,mode=max
282
+
283
+ - name: 진행 상황 - Docker 완료
284
+ id: docker_progress
285
+ uses: actions/github-script@v7
286
+ with:
287
+ script: |
288
+ const commentId = ${{ steps.progress.outputs.comment_id }};
289
+ const dockerStart = ${{ steps.progress.outputs.docker_start }};
290
+ const now = Date.now();
291
+ const dockerElapsed = now - dockerStart;
292
+ const minutes = Math.floor(dockerElapsed / 60000);
293
+ const seconds = Math.floor((dockerElapsed % 60000) / 1000);
294
+ const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
295
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
296
+
297
+ const body = [
298
+ '## 🚀 PR Preview 빌드 중...',
299
+ '',
300
+ '| 단계 | 상태 | 소요 시간 |',
301
+ '|------|------|----------|',
302
+ `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
303
+ '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
304
+ '',
305
+ `**[📋 실시간 로그 보기](${runUrl})**`,
306
+ '',
307
+ '---',
308
+ '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
309
+ ].join('\n');
310
+
311
+ await github.rest.issues.updateComment({
312
+ owner: context.repo.owner,
313
+ repo: context.repo.repo,
314
+ comment_id: commentId,
315
+ body: body
316
+ });
317
+
318
+ core.setOutput('docker_duration', dockerDuration);
319
+ core.setOutput('deploy_start', now);
320
+
321
+ - name: 서버에 배포
322
+ uses: appleboy/ssh-action@v1.0.3
323
+ env:
324
+ SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
325
+ with:
326
+ host: ${{ secrets.SERVER_HOST }}
327
+ username: ${{ secrets.SERVER_USER }}
328
+ password: ${{ secrets.SERVER_PASSWORD }}
329
+ key: ${{ secrets.SSH_KEY }}
330
+ port: ${{ env.SSH_PORT }}
331
+ envs: SSH_AUTH_METHOD
332
+ script: |
333
+ set -e
334
+
335
+ # 환경 변수 설정 (배포 서버용)
336
+ export PATH=$PATH:/usr/local/bin
337
+ export PW="${{ secrets.SERVER_PASSWORD }}"
338
+
339
+ # 🔐 SSH 인증 방식에 따른 sudo 추상화
340
+ SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
341
+ if [ "${SSH_AUTH_METHOD}" = "key" ]; then
342
+ SUDO() { sudo "$@"; }
343
+ else
344
+ SUDO() { echo "$PW" | sudo -S "$@"; }
345
+ fi
346
+ echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
347
+
348
+ # 변수 설정
349
+ PR_NUMBER=${{ github.event.issue.number }}
350
+ PROJECT_NAME="${{ env.PROJECT_NAME }}"
351
+ CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}"
352
+ IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}"
353
+ DOMAIN="${PROJECT_NAME}-pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}"
354
+ INTERNAL_PORT="${{ env.INTERNAL_PORT }}"
355
+ TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
356
+
357
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
358
+ echo "🚀 PR Preview 배포 시작"
359
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
360
+ echo "📦 프로젝트: ${PROJECT_NAME}"
361
+ echo "🔢 PR 번호: #${PR_NUMBER}"
362
+ echo "📛 컨테이너: ${CONTAINER_NAME}"
363
+ echo "🌐 도메인: ${DOMAIN}"
364
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
365
+
366
+ # 이미지 Pull
367
+ echo "📥 Docker 이미지 Pull 중..."
368
+ SUDO docker pull "${IMAGE}"
369
+
370
+ # 기존 컨테이너 삭제
371
+ echo "🗑️ 기존 컨테이너 정리 중..."
372
+ SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
373
+
374
+ # 볼륨 마운트 옵션 구성
375
+ VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro"
376
+ if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then
377
+ VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}"
378
+ fi
379
+
380
+ # 새 컨테이너 실행
381
+ echo "🐳 새 컨테이너 실행 중..."
382
+ SUDO docker run -d \
383
+ --name "${CONTAINER_NAME}" \
384
+ --network "${TRAEFIK_NETWORK}" \
385
+ --label "traefik.enable=true" \
386
+ --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \
387
+ --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \
388
+ --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \
389
+ -e TZ=Asia/Seoul \
390
+ -e ENVIRONMENT=prod \
391
+ $VOLUME_OPTS \
392
+ "${IMAGE}"
393
+
394
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
395
+ # Health Check (최대 120초 대기) - 하이브리드 방식
396
+ # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도
397
+ # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN)
398
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
399
+ echo ""
400
+ echo "⏳ Health Check 시작 (최대 120초 대기)..."
401
+ HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
402
+ LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
403
+ MAX_RETRIES=24
404
+ RETRY_COUNT=0
405
+ HEALTH_CHECK_PASSED=false
406
+ HEALTH_CHECK_METHOD=""
407
+
408
+ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
409
+ sleep 5
410
+ RETRY_COUNT=$((RETRY_COUNT + 1))
411
+
412
+ # 1. 컨테이너 상태 확인
413
+ STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
414
+
415
+ if [ "$STATUS" = "exited" ]; then
416
+ echo "❌ 컨테이너 비정상 종료!"
417
+ echo ""
418
+ echo "📋 컨테이너 로그 (최근 100줄):"
419
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
420
+ SUDO docker logs --tail 100 "${CONTAINER_NAME}"
421
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
422
+ exit 1
423
+ fi
424
+
425
+ if [ "$STATUS" = "running" ]; then
426
+ # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만)
427
+ if [ -n "$HEALTH_PATH" ]; then
428
+ HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
429
+
430
+ if [ -n "$HEALTH" ]; then
431
+ echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
432
+ HEALTH_CHECK_PASSED=true
433
+ HEALTH_CHECK_METHOD="HTTP"
434
+ break
435
+ fi
436
+ fi
437
+
438
+ # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백
439
+ if [ -n "$LOG_PATTERN" ]; then
440
+ STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
441
+
442
+ if [ -n "$STARTED" ]; then
443
+ echo "✅ 정상 기동 확인! (로그 패턴)"
444
+ echo " $STARTED"
445
+ HEALTH_CHECK_PASSED=true
446
+ HEALTH_CHECK_METHOD="Log"
447
+ break
448
+ fi
449
+ fi
450
+ fi
451
+
452
+ echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
453
+ done
454
+
455
+ if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
456
+ echo ""
457
+ echo "❌ Health Check 타임아웃 (120초)"
458
+ echo ""
459
+ echo "📋 컨테이너 로그 (최근 100줄):"
460
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
461
+ SUDO docker logs --tail 100 "${CONTAINER_NAME}"
462
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
463
+ exit 1
464
+ fi
465
+
466
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
467
+ echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
468
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
469
+
470
+ - name: 배포 완료 코멘트
471
+ uses: actions/github-script@v7
472
+ with:
473
+ script: |
474
+ const commentId = ${{ steps.progress.outputs.comment_id }};
475
+ const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
476
+ const deployStart = ${{ steps.docker_progress.outputs.deploy_start }};
477
+ const now = Date.now();
478
+ const deployElapsed = now - deployStart;
479
+ const minutes = Math.floor(deployElapsed / 60000);
480
+ const seconds = Math.floor((deployElapsed % 60000) / 1000);
481
+ const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
482
+
483
+ const projectName = '${{ env.PROJECT_NAME }}';
484
+ const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
485
+ const previewPort = '${{ env.PREVIEW_PORT }}';
486
+ const apiDocsPath = '${{ env.API_DOCS_PATH }}';
487
+ const prNumber = context.issue.number;
488
+ const domain = `${projectName}-pr-${prNumber}.${domainSuffix}`;
489
+ const previewUrl = `http://${domain}:${previewPort}`;
490
+ const sha = '${{ steps.pr.outputs.sha }}';
491
+
492
+ // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가)
493
+ const envRows = [
494
+ `| **Preview URL** | ${previewUrl} |`,
495
+ ];
496
+ if (apiDocsPath) {
497
+ envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
498
+ }
499
+ envRows.push(`| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`);
500
+ envRows.push(`| **커밋** | \`${sha}\` |`);
501
+
502
+ const branchName = '${{ steps.pr.outputs.ref }}';
503
+
504
+ const body = [
505
+ '## ✅ PR Preview 배포 완료!',
506
+ '',
507
+ '| 단계 | 상태 | 소요 시간 |',
508
+ '|------|------|----------|',
509
+ `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
510
+ `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
511
+ '',
512
+ '### 🌐 Preview 환경',
513
+ '| 항목 | 값 |',
514
+ '|------|-----|',
515
+ ...envRows,
516
+ '',
517
+ '### 📋 명령어',
518
+ '| 명령어 | 설명 |',
519
+ '|--------|------|',
520
+ '| `@suh-lab server build` | 최신 커밋으로 재배포 |',
521
+ '| `@suh-lab server destroy` | Preview 환경 삭제 |',
522
+ '| `@suh-lab server status` | 현재 상태 확인 |',
523
+ '',
524
+ '<details>',
525
+ '<summary>🔧 고급 명령어 (다른 Issue/PR에서 제어)</summary>',
526
+ '',
527
+ '```',
528
+ `@suh-lab server build ${branchName}`,
529
+ `@suh-lab server destroy ${branchName}`,
530
+ `@suh-lab server status ${branchName}`,
531
+ '```',
532
+ '</details>',
533
+ '',
534
+ '---',
535
+ '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*'
536
+ ].join('\n');
537
+
538
+ await github.rest.issues.updateComment({
539
+ owner: context.repo.owner,
540
+ repo: context.repo.repo,
541
+ comment_id: commentId,
542
+ body: body
543
+ });
544
+
545
+ - name: 빌드/배포 실패 시 에러 코멘트
546
+ if: failure()
547
+ uses: actions/github-script@v7
548
+ with:
549
+ script: |
550
+ const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
551
+ const projectName = '${{ env.PROJECT_NAME }}';
552
+ const prNumber = context.issue.number;
553
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
554
+
555
+ // 진행 상황에 따라 어디서 실패했는지 표시
556
+ const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
557
+
558
+ // 실패 지점 판단 - 초기 단계 실패도 구분
559
+ let dockerStatus, deployStatus;
560
+ let dockerTime = '-';
561
+
562
+ if (!commentId) {
563
+ // 진행 상황 댓글 생성 전 실패 (초기화 단계)
564
+ dockerStatus = '⚠️ 초기화 실패';
565
+ deployStatus = '-';
566
+ } else if (!dockerDuration || dockerDuration === '') {
567
+ // Docker 빌드 단계에서 실패
568
+ dockerStatus = '❌ 실패';
569
+ deployStatus = '⏸️ 대기';
570
+ } else {
571
+ // 배포 단계에서 실패
572
+ dockerStatus = '✅ 완료';
573
+ deployStatus = '❌ 실패';
574
+ dockerTime = dockerDuration;
575
+ }
576
+
577
+ const body = [
578
+ '## ❌ PR Preview 배포 실패!',
579
+ '',
580
+ '| 단계 | 상태 | 소요 시간 |',
581
+ '|------|------|----------|',
582
+ `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
583
+ `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
584
+ '',
585
+ `**[📋 빌드/배포 로그 확인](${runUrl})**`,
586
+ '',
587
+ '### 🔍 가능한 원인',
588
+ '- Docker 이미지 빌드 실패 (Python 의존성 문제)',
589
+ '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
590
+ '- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
591
+ '- 환경변수 누락 (.env 파일 설정 확인)',
592
+ '',
593
+ '### 💡 다음 단계',
594
+ '1. 위 링크에서 빌드/배포 로그를 확인하세요',
595
+ '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`',
596
+ '',
597
+ '---',
598
+ '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*'
599
+ ].join('\n');
600
+
601
+ // 진행 상황 댓글이 있으면 업데이트, 없으면 새로 생성
602
+ if (commentId) {
603
+ await github.rest.issues.updateComment({
604
+ owner: context.repo.owner,
605
+ repo: context.repo.repo,
606
+ comment_id: commentId,
607
+ body: body
608
+ });
609
+ } else {
610
+ await github.rest.issues.createComment({
611
+ owner: context.repo.owner,
612
+ repo: context.repo.repo,
613
+ issue_number: prNumber,
614
+ body: body
615
+ });
616
+ }
617
+
618
+ # -----------------------------------------------------------------
619
+ # Job 2-2: Issue에서 브랜치명 추출
620
+ # -----------------------------------------------------------------
621
+ get-branch-from-issue:
622
+ name: Issue에서 브랜치 추출
623
+ needs: check-command
624
+ if: |
625
+ needs.check-command.outputs.is_valid == 'true' &&
626
+ needs.check-command.outputs.is_pr == 'false' &&
627
+ needs.check-command.outputs.is_custom_branch != 'true'
628
+ runs-on: ubuntu-latest
629
+ outputs:
630
+ branch_name: ${{ steps.extract.outputs.branch }}
631
+ found: ${{ steps.extract.outputs.found }}
632
+ issue_number: ${{ steps.extract.outputs.issue_number }}
633
+ steps:
634
+ - name: Issue 댓글에서 브랜치명 추출
635
+ id: extract
636
+ uses: actions/github-script@v7
637
+ with:
638
+ script: |
639
+ const issueNumber = context.issue.number;
640
+ const marker = '${{ env.ISSUE_HELPER_MARKER }}';
641
+
642
+ console.log(`🔍 Issue #${issueNumber}에서 브랜치 검색 중...`);
643
+ console.log(`📝 마커: ${marker}`);
644
+
645
+ // Issue의 모든 댓글 조회
646
+ const comments = await github.rest.issues.listComments({
647
+ owner: context.repo.owner,
648
+ repo: context.repo.repo,
649
+ issue_number: issueNumber
650
+ });
651
+
652
+ // 브랜치 추출 패턴 (마커와 무관하게 동일한 형식)
653
+ // "### 브랜치" 또는 "### 브랜치명" 둘 다 매칭
654
+ const branchRegex = /###\s*브랜치(?:명)?\s*\n```\s*\n([^\n]+)\s*\n```/;
655
+
656
+ for (const comment of comments.data) {
657
+ // 마커가 포함된 댓글 찾기
658
+ if (comment.body.includes(marker)) {
659
+ const match = comment.body.match(branchRegex);
660
+ if (match) {
661
+ const branchName = match[1].trim();
662
+ core.setOutput('branch', branchName);
663
+ core.setOutput('found', 'true');
664
+ core.setOutput('issue_number', issueNumber);
665
+ console.log(`✅ 브랜치 발견: ${branchName}`);
666
+ return;
667
+ }
668
+ }
669
+ }
670
+
671
+ // 브랜치를 찾지 못한 경우 - 에러가 아님, graceful하게 처리
672
+ core.setOutput('found', 'false');
673
+ core.setOutput('issue_number', issueNumber);
674
+ console.log('⚠️ Issue Helper 댓글에서 브랜치를 찾을 수 없습니다.');
675
+
676
+ - name: 브랜치 없음 알림
677
+ if: steps.extract.outputs.found == 'false'
678
+ uses: actions/github-script@v7
679
+ with:
680
+ script: |
681
+ const issueNumber = context.issue.number;
682
+ const marker = '${{ env.ISSUE_HELPER_MARKER }}';
683
+
684
+ const body = [
685
+ '## ⚠️ 브랜치를 찾을 수 없습니다',
686
+ '',
687
+ '| 항목 | 값 |',
688
+ '|------|-----|',
689
+ `| **Issue** | #${issueNumber} |`,
690
+ `| **마커** | \`${marker}\` |`,
691
+ '',
692
+ '### 💡 확인 사항',
693
+ '1. Issue Helper 댓글이 존재하는지 확인하세요',
694
+ '2. 댓글에 `### 브랜치` 섹션이 있는지 확인하세요',
695
+ '3. `ISSUE_HELPER_MARKER` 환경변수가 올바른지 확인하세요',
696
+ '',
697
+ '---',
698
+ '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
699
+ ].join('\n');
700
+
701
+ await github.rest.issues.createComment({
702
+ owner: context.repo.owner,
703
+ repo: context.repo.repo,
704
+ issue_number: issueNumber,
705
+ body: body
706
+ });
707
+
708
+ # -----------------------------------------------------------------
709
+ # Job 2-3: 빌드 & 배포 (Issue 댓글에서 실행)
710
+ # -----------------------------------------------------------------
711
+ build-preview-issue:
712
+ name: Preview 빌드 & 배포 (Issue)
713
+ needs: [check-command, get-branch-from-issue]
714
+ if: |
715
+ needs.check-command.outputs.is_valid == 'true' &&
716
+ needs.check-command.outputs.command == 'build' &&
717
+ needs.check-command.outputs.is_pr == 'false' &&
718
+ needs.check-command.outputs.is_custom_branch != 'true' &&
719
+ needs.get-branch-from-issue.outputs.found == 'true'
720
+ runs-on: ubuntu-latest
721
+ steps:
722
+ - name: 브랜치 존재 확인
723
+ id: check_branch
724
+ uses: actions/github-script@v7
725
+ with:
726
+ script: |
727
+ const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
728
+ const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
729
+
730
+ console.log(`🔍 브랜치 존재 확인: ${branchName}`);
731
+
732
+ try {
733
+ await github.rest.repos.getBranch({
734
+ owner: context.repo.owner,
735
+ repo: context.repo.repo,
736
+ branch: branchName
737
+ });
738
+ core.setOutput('exists', 'true');
739
+ console.log(`✅ 브랜치 존재: ${branchName}`);
740
+ } catch (error) {
741
+ if (error.status === 404) {
742
+ core.setOutput('exists', 'false');
743
+ console.log(`❌ 브랜치 없음: ${branchName}`);
744
+
745
+ // 브랜치 없음 알림 댓글
746
+ const body = [
747
+ '## ❌ 브랜치를 찾을 수 없습니다',
748
+ '',
749
+ '| 항목 | 값 |',
750
+ '|------|-----|',
751
+ `| **Issue** | #${issueNumber} |`,
752
+ `| **브랜치** | \`${branchName}\` |`,
753
+ '',
754
+ '### 💡 확인 사항',
755
+ '1. 브랜치가 push되었는지 확인하세요',
756
+ '2. 브랜치명이 정확한지 확인하세요',
757
+ '3. 브랜치가 삭제되지 않았는지 확인하세요',
758
+ '',
759
+ '---',
760
+ '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
761
+ ].join('\n');
762
+
763
+ await github.rest.issues.createComment({
764
+ owner: context.repo.owner,
765
+ repo: context.repo.repo,
766
+ issue_number: issueNumber,
767
+ body: body
768
+ });
769
+ } else {
770
+ throw error;
771
+ }
772
+ }
773
+
774
+ - name: 브랜치 없으면 중단
775
+ if: steps.check_branch.outputs.exists == 'false'
776
+ run: |
777
+ echo "⚠️ 브랜치가 존재하지 않아 빌드를 건너뜁니다."
778
+ exit 0
779
+
780
+ - name: Issue 정보 설정
781
+ if: steps.check_branch.outputs.exists == 'true'
782
+ id: issue
783
+ run: |
784
+ echo "ref=${{ needs.get-branch-from-issue.outputs.branch_name }}" >> $GITHUB_OUTPUT
785
+ echo "issue_number=${{ needs.get-branch-from-issue.outputs.issue_number }}" >> $GITHUB_OUTPUT
786
+ # 브랜치의 최신 커밋 SHA 가져오기
787
+ 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
788
+
789
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
790
+ # 진행 상황 댓글 시스템
791
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
792
+ - name: 진행 상황 댓글 생성
793
+ if: steps.check_branch.outputs.exists == 'true'
794
+ id: progress
795
+ uses: actions/github-script@v7
796
+ with:
797
+ script: |
798
+ const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
799
+ const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
800
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
801
+ const startTime = Date.now();
802
+
803
+ const body = [
804
+ '## 🚀 Issue Preview 빌드 중...',
805
+ '',
806
+ `**브랜치**: \`${branchName}\``,
807
+ '',
808
+ '| 단계 | 상태 | 소요 시간 |',
809
+ '|------|------|----------|',
810
+ '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
811
+ '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
812
+ '',
813
+ `**[📋 실시간 로그 보기](${runUrl})**`,
814
+ '',
815
+ '---',
816
+ '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
817
+ ].join('\n');
818
+
819
+ const { data: comment } = await github.rest.issues.createComment({
820
+ owner: context.repo.owner,
821
+ repo: context.repo.repo,
822
+ issue_number: issueNumber,
823
+ body: body
824
+ });
825
+
826
+ core.setOutput('comment_id', comment.id);
827
+ core.setOutput('start_time', startTime);
828
+ core.setOutput('docker_start', startTime);
829
+
830
+ - name: 코드 체크아웃
831
+ if: steps.check_branch.outputs.exists == 'true'
832
+ uses: actions/checkout@v5
833
+ with:
834
+ ref: ${{ needs.get-branch-from-issue.outputs.branch_name }}
835
+
836
+ # =================================================================
837
+ # ⚠️ [영역 2] 환경변수 파일 생성
838
+ # =================================================================
839
+ - name: "[필수] .env 파일 생성"
840
+ if: steps.check_branch.outputs.exists == 'true'
841
+ run: |
842
+ cat << 'EOF' > .env
843
+ ${{ secrets.ENV_FILE }}
844
+ EOF
845
+ # =================================================================
846
+ # ⚠️ [영역 2 끝] 환경변수 파일 생성 끝
847
+ # =================================================================
848
+
849
+ - name: Docker 로그인
850
+ if: steps.check_branch.outputs.exists == 'true'
851
+ uses: docker/login-action@v3
852
+ with:
853
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
854
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
855
+
856
+ - name: Docker Buildx 설정
857
+ if: steps.check_branch.outputs.exists == 'true'
858
+ uses: docker/setup-buildx-action@v3
859
+
860
+ - name: Docker 이미지 빌드 & Push
861
+ if: steps.check_branch.outputs.exists == 'true'
862
+ uses: docker/build-push-action@v5
863
+ with:
864
+ context: .
865
+ file: ${{ env.DOCKERFILE_PATH }}
866
+ push: true
867
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:pr-${{ needs.get-branch-from-issue.outputs.issue_number }}
868
+ cache-from: type=gha
869
+ cache-to: type=gha,mode=max
870
+
871
+ - name: 진행 상황 - Docker 완료
872
+ if: steps.check_branch.outputs.exists == 'true'
873
+ id: docker_progress
874
+ uses: actions/github-script@v7
875
+ with:
876
+ script: |
877
+ const commentId = ${{ steps.progress.outputs.comment_id }};
878
+ const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
879
+ const dockerStart = ${{ steps.progress.outputs.docker_start }};
880
+ const now = Date.now();
881
+ const dockerElapsed = now - dockerStart;
882
+ const minutes = Math.floor(dockerElapsed / 60000);
883
+ const seconds = Math.floor((dockerElapsed % 60000) / 1000);
884
+ const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
885
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
886
+
887
+ const body = [
888
+ '## 🚀 Issue Preview 빌드 중...',
889
+ '',
890
+ `**브랜치**: \`${branchName}\``,
891
+ '',
892
+ '| 단계 | 상태 | 소요 시간 |',
893
+ '|------|------|----------|',
894
+ `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
895
+ '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
896
+ '',
897
+ `**[📋 실시간 로그 보기](${runUrl})**`,
898
+ '',
899
+ '---',
900
+ '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
901
+ ].join('\n');
902
+
903
+ await github.rest.issues.updateComment({
904
+ owner: context.repo.owner,
905
+ repo: context.repo.repo,
906
+ comment_id: commentId,
907
+ body: body
908
+ });
909
+
910
+ core.setOutput('docker_duration', dockerDuration);
911
+ core.setOutput('deploy_start', now);
912
+
913
+ - name: 서버에 배포
914
+ if: steps.check_branch.outputs.exists == 'true'
915
+ uses: appleboy/ssh-action@v1.0.3
916
+ env:
917
+ SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
918
+ with:
919
+ host: ${{ secrets.SERVER_HOST }}
920
+ username: ${{ secrets.SERVER_USER }}
921
+ password: ${{ secrets.SERVER_PASSWORD }}
922
+ key: ${{ secrets.SSH_KEY }}
923
+ port: ${{ env.SSH_PORT }}
924
+ envs: SSH_AUTH_METHOD
925
+ script: |
926
+ set -e
927
+
928
+ # 환경 변수 설정 (배포 서버용)
929
+ export PATH=$PATH:/usr/local/bin
930
+ export PW="${{ secrets.SERVER_PASSWORD }}"
931
+
932
+ # 🔐 SSH 인증 방식에 따른 sudo 추상화
933
+ SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
934
+ if [ "${SSH_AUTH_METHOD}" = "key" ]; then
935
+ SUDO() { sudo "$@"; }
936
+ else
937
+ SUDO() { echo "$PW" | sudo -S "$@"; }
938
+ fi
939
+ echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
940
+
941
+ # 변수 설정
942
+ ISSUE_NUMBER=${{ needs.get-branch-from-issue.outputs.issue_number }}
943
+ PROJECT_NAME="${{ env.PROJECT_NAME }}"
944
+ CONTAINER_NAME="${PROJECT_NAME}-pr-${ISSUE_NUMBER}"
945
+ IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${ISSUE_NUMBER}"
946
+ DOMAIN="${PROJECT_NAME}-pr-${ISSUE_NUMBER}.${{ env.PREVIEW_DOMAIN_SUFFIX }}"
947
+ INTERNAL_PORT="${{ env.INTERNAL_PORT }}"
948
+ TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
949
+
950
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
951
+ echo "🚀 Issue Preview 배포 시작"
952
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
953
+ echo "📦 프로젝트: ${PROJECT_NAME}"
954
+ echo "🔢 Issue 번호: #${ISSUE_NUMBER}"
955
+ echo "📛 컨테이너: ${CONTAINER_NAME}"
956
+ echo "🌐 도메인: ${DOMAIN}"
957
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
958
+
959
+ # 이미지 Pull
960
+ echo "📥 Docker 이미지 Pull 중..."
961
+ SUDO docker pull "${IMAGE}"
962
+
963
+ # 기존 컨테이너 삭제
964
+ echo "🗑️ 기존 컨테이너 정리 중..."
965
+ SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
966
+
967
+ # 볼륨 마운트 옵션 구성
968
+ VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro"
969
+ if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then
970
+ VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}"
971
+ fi
972
+
973
+ # 새 컨테이너 실행
974
+ echo "🐳 새 컨테이너 실행 중..."
975
+ SUDO docker run -d \
976
+ --name "${CONTAINER_NAME}" \
977
+ --network "${TRAEFIK_NETWORK}" \
978
+ --label "traefik.enable=true" \
979
+ --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \
980
+ --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \
981
+ --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \
982
+ -e TZ=Asia/Seoul \
983
+ -e ENVIRONMENT=prod \
984
+ $VOLUME_OPTS \
985
+ "${IMAGE}"
986
+
987
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
988
+ # Health Check (최대 120초 대기) - 하이브리드 방식
989
+ # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도
990
+ # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN)
991
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
992
+ echo ""
993
+ echo "⏳ Health Check 시작 (최대 120초 대기)..."
994
+ HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
995
+ LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
996
+ MAX_RETRIES=24
997
+ RETRY_COUNT=0
998
+ HEALTH_CHECK_PASSED=false
999
+ HEALTH_CHECK_METHOD=""
1000
+
1001
+ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
1002
+ sleep 5
1003
+ RETRY_COUNT=$((RETRY_COUNT + 1))
1004
+
1005
+ # 1. 컨테이너 상태 확인
1006
+ STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
1007
+
1008
+ if [ "$STATUS" = "exited" ]; then
1009
+ echo "❌ 컨테이너 비정상 종료!"
1010
+ echo ""
1011
+ echo "📋 컨테이너 로그 (최근 100줄):"
1012
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1013
+ SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1014
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1015
+ exit 1
1016
+ fi
1017
+
1018
+ if [ "$STATUS" = "running" ]; then
1019
+ # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만)
1020
+ if [ -n "$HEALTH_PATH" ]; then
1021
+ HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
1022
+
1023
+ if [ -n "$HEALTH" ]; then
1024
+ echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
1025
+ HEALTH_CHECK_PASSED=true
1026
+ HEALTH_CHECK_METHOD="HTTP"
1027
+ break
1028
+ fi
1029
+ fi
1030
+
1031
+ # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백
1032
+ if [ -n "$LOG_PATTERN" ]; then
1033
+ STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
1034
+
1035
+ if [ -n "$STARTED" ]; then
1036
+ echo "✅ 정상 기동 확인! (로그 패턴)"
1037
+ echo " $STARTED"
1038
+ HEALTH_CHECK_PASSED=true
1039
+ HEALTH_CHECK_METHOD="Log"
1040
+ break
1041
+ fi
1042
+ fi
1043
+ fi
1044
+
1045
+ echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
1046
+ done
1047
+
1048
+ if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
1049
+ echo ""
1050
+ echo "❌ Health Check 타임아웃 (120초)"
1051
+ echo ""
1052
+ echo "📋 컨테이너 로그 (최근 100줄):"
1053
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1054
+ SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1055
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1056
+ exit 1
1057
+ fi
1058
+
1059
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1060
+ echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
1061
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1062
+
1063
+ - name: 배포 완료 코멘트
1064
+ if: steps.check_branch.outputs.exists == 'true'
1065
+ uses: actions/github-script@v7
1066
+ with:
1067
+ script: |
1068
+ const commentId = ${{ steps.progress.outputs.comment_id }};
1069
+ const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
1070
+ const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
1071
+ const deployStart = ${{ steps.docker_progress.outputs.deploy_start }};
1072
+ const now = Date.now();
1073
+ const deployElapsed = now - deployStart;
1074
+ const minutes = Math.floor(deployElapsed / 60000);
1075
+ const seconds = Math.floor((deployElapsed % 60000) / 1000);
1076
+ const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
1077
+
1078
+ const projectName = '${{ env.PROJECT_NAME }}';
1079
+ const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
1080
+ const previewPort = '${{ env.PREVIEW_PORT }}';
1081
+ const apiDocsPath = '${{ env.API_DOCS_PATH }}';
1082
+ const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
1083
+ const domain = `${projectName}-pr-${issueNumber}.${domainSuffix}`;
1084
+ const previewUrl = `http://${domain}:${previewPort}`;
1085
+ const sha = '${{ steps.issue.outputs.sha }}';
1086
+
1087
+ // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가)
1088
+ const envRows = [
1089
+ `| **Preview URL** | ${previewUrl} |`,
1090
+ ];
1091
+ if (apiDocsPath) {
1092
+ envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
1093
+ }
1094
+ envRows.push(`| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`);
1095
+ envRows.push(`| **브랜치** | \`${branchName}\` |`);
1096
+ envRows.push(`| **커밋** | \`${sha}\` |`);
1097
+
1098
+ const body = [
1099
+ '## ✅ Issue Preview 배포 완료!',
1100
+ '',
1101
+ `**브랜치**: \`${branchName}\``,
1102
+ '',
1103
+ '| 단계 | 상태 | 소요 시간 |',
1104
+ '|------|------|----------|',
1105
+ `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
1106
+ `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
1107
+ '',
1108
+ '### 🌐 Preview 환경',
1109
+ '| 항목 | 값 |',
1110
+ '|------|-----|',
1111
+ ...envRows,
1112
+ '',
1113
+ '### 📋 명령어',
1114
+ '| 명령어 | 설명 |',
1115
+ '|--------|------|',
1116
+ '| `@suh-lab server build` | 최신 커밋으로 재배포 |',
1117
+ '| `@suh-lab server destroy` | Preview 환경 삭제 |',
1118
+ '| `@suh-lab server status` | 현재 상태 확인 |',
1119
+ '',
1120
+ '<details>',
1121
+ '<summary>🔧 고급 명령어 (다른 Issue/PR에서 제어)</summary>',
1122
+ '',
1123
+ '```',
1124
+ `@suh-lab server build ${branchName}`,
1125
+ `@suh-lab server destroy ${branchName}`,
1126
+ `@suh-lab server status ${branchName}`,
1127
+ '```',
1128
+ '</details>',
1129
+ '',
1130
+ '---',
1131
+ '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*'
1132
+ ].join('\n');
1133
+
1134
+ await github.rest.issues.updateComment({
1135
+ owner: context.repo.owner,
1136
+ repo: context.repo.repo,
1137
+ comment_id: commentId,
1138
+ body: body
1139
+ });
1140
+
1141
+ - name: 빌드/배포 실패 시 에러 코멘트
1142
+ if: failure() && steps.check_branch.outputs.exists == 'true'
1143
+ uses: actions/github-script@v7
1144
+ with:
1145
+ script: |
1146
+ const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
1147
+ const projectName = '${{ env.PROJECT_NAME }}';
1148
+ const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
1149
+ const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
1150
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
1151
+
1152
+ const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
1153
+
1154
+ let dockerStatus, deployStatus;
1155
+ let dockerTime = '-';
1156
+
1157
+ if (!commentId) {
1158
+ dockerStatus = '⚠️ 초기화 실패';
1159
+ deployStatus = '-';
1160
+ } else if (!dockerDuration || dockerDuration === '') {
1161
+ dockerStatus = '❌ 실패';
1162
+ deployStatus = '⏸️ 대기';
1163
+ } else {
1164
+ dockerStatus = '✅ 완료';
1165
+ deployStatus = '❌ 실패';
1166
+ dockerTime = dockerDuration;
1167
+ }
1168
+
1169
+ const body = [
1170
+ '## ❌ Issue Preview 배포 실패!',
1171
+ '',
1172
+ `**브랜치**: \`${branchName}\``,
1173
+ '',
1174
+ '| 단계 | 상태 | 소요 시간 |',
1175
+ '|------|------|----------|',
1176
+ `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
1177
+ `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
1178
+ '',
1179
+ `**[📋 빌드/배포 로그 확인](${runUrl})**`,
1180
+ '',
1181
+ '### 🔍 가능한 원인',
1182
+ '- Docker 이미지 빌드 실패 (Python 의존성 문제)',
1183
+ '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
1184
+ '- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
1185
+ '- 환경변수 누락 (.env 파일 설정 확인)',
1186
+ '',
1187
+ '### 💡 다음 단계',
1188
+ '1. 위 링크에서 빌드/배포 로그를 확인하세요',
1189
+ '2. 문제를 수정한 후 다시 시도하세요: `@suh-lab server build`',
1190
+ '',
1191
+ '---',
1192
+ '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*'
1193
+ ].join('\n');
1194
+
1195
+ if (commentId) {
1196
+ await github.rest.issues.updateComment({
1197
+ owner: context.repo.owner,
1198
+ repo: context.repo.repo,
1199
+ comment_id: commentId,
1200
+ body: body
1201
+ });
1202
+ } else {
1203
+ await github.rest.issues.createComment({
1204
+ owner: context.repo.owner,
1205
+ repo: context.repo.repo,
1206
+ issue_number: issueNumber,
1207
+ body: body
1208
+ });
1209
+ }
1210
+
1211
+ # -----------------------------------------------------------------
1212
+ # Job 3: Preview 삭제 (PR/Issue 닫힘 또는 destroy 명령)
1213
+ # -----------------------------------------------------------------
1214
+ destroy-preview:
1215
+ name: Preview 삭제
1216
+ # needs 의존성 제거: PR/Issue 닫힘 이벤트에서도 독립적으로 실행되도록 함
1217
+ if: |
1218
+ (github.event_name == 'pull_request' && github.event.action == 'closed') ||
1219
+ (github.event_name == 'issues' && github.event.action == 'closed') ||
1220
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@suh-lab') && contains(github.event.comment.body, 'server destroy'))
1221
+ runs-on: ubuntu-latest
1222
+ steps:
1223
+ - name: PR/Issue 번호 가져오기
1224
+ id: pr_number
1225
+ run: |
1226
+ if [[ "${{ github.event_name }}" == "issue_comment" ]]; then
1227
+ echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
1228
+ echo "type=comment" >> $GITHUB_OUTPUT
1229
+ elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
1230
+ echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
1231
+ echo "type=pr_closed" >> $GITHUB_OUTPUT
1232
+ else
1233
+ echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
1234
+ echo "type=issue_closed" >> $GITHUB_OUTPUT
1235
+ fi
1236
+
1237
+ - name: 컨테이너 & 이미지 삭제
1238
+ uses: appleboy/ssh-action@v1.0.3
1239
+ env:
1240
+ SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
1241
+ with:
1242
+ host: ${{ secrets.SERVER_HOST }}
1243
+ username: ${{ secrets.SERVER_USER }}
1244
+ password: ${{ secrets.SERVER_PASSWORD }}
1245
+ key: ${{ secrets.SSH_KEY }}
1246
+ port: ${{ env.SSH_PORT }}
1247
+ envs: SSH_AUTH_METHOD
1248
+ script: |
1249
+ # 환경 변수 설정 (배포 서버용)
1250
+ export PATH=$PATH:/usr/local/bin
1251
+ export PW="${{ secrets.SERVER_PASSWORD }}"
1252
+
1253
+ # 🔐 SSH 인증 방식에 따른 sudo 추상화
1254
+ SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
1255
+ if [ "${SSH_AUTH_METHOD}" = "key" ]; then
1256
+ SUDO() { sudo "$@"; }
1257
+ else
1258
+ SUDO() { echo "$PW" | sudo -S "$@"; }
1259
+ fi
1260
+ echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
1261
+
1262
+ PR_NUMBER=${{ steps.pr_number.outputs.number }}
1263
+ PROJECT_NAME="${{ env.PROJECT_NAME }}"
1264
+ CONTAINER_NAME="${PROJECT_NAME}-pr-${PR_NUMBER}"
1265
+ IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}"
1266
+
1267
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1268
+ echo "🗑️ PR Preview 삭제"
1269
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1270
+ echo "📦 프로젝트: ${PROJECT_NAME}"
1271
+ echo "🔢 PR 번호: #${PR_NUMBER}"
1272
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1273
+
1274
+ # 컨테이너 삭제
1275
+ echo "🐳 컨테이너 삭제 중..."
1276
+ SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
1277
+
1278
+ # 이미지 삭제
1279
+ echo "🖼️ 이미지 삭제 중..."
1280
+ SUDO docker rmi "${IMAGE}" 2>/dev/null || true
1281
+
1282
+ echo "✅ 삭제 완료!"
1283
+
1284
+ - name: 삭제 완료 코멘트
1285
+ if: |
1286
+ github.event_name == 'issue_comment' ||
1287
+ (github.event_name == 'pull_request' && github.event.action == 'closed') ||
1288
+ (github.event_name == 'issues' && github.event.action == 'closed')
1289
+ uses: actions/github-script@v7
1290
+ with:
1291
+ script: |
1292
+ const number = ${{ steps.pr_number.outputs.number }};
1293
+ const projectName = '${{ env.PROJECT_NAME }}';
1294
+
1295
+ const body = [
1296
+ '## 🗑️ Preview 환경 삭제 완료!',
1297
+ '',
1298
+ '| 항목 | 값 |',
1299
+ '|------|-----|',
1300
+ `| **컨테이너** | \`${projectName}-pr-${number}\` |`,
1301
+ '| **상태** | 삭제됨 |',
1302
+ '',
1303
+ '다시 배포하려면: `@suh-lab server build`',
1304
+ '',
1305
+ '---',
1306
+ '*🤖 이 댓글은 PR/Issue Preview 시스템에 의해 자동 생성되었습니다.*'
1307
+ ].join('\n');
1308
+
1309
+ await github.rest.issues.createComment({
1310
+ owner: context.repo.owner,
1311
+ repo: context.repo.repo,
1312
+ issue_number: number,
1313
+ body: body
1314
+ });
1315
+
1316
+ # -----------------------------------------------------------------
1317
+ # Job 4: 상태 확인 (PR/Issue 모두 지원)
1318
+ # -----------------------------------------------------------------
1319
+ check-status:
1320
+ name: Preview 상태 확인
1321
+ needs: check-command
1322
+ if: needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'status'
1323
+ runs-on: ubuntu-latest
1324
+ steps:
1325
+ - name: 컨테이너 상태 확인
1326
+ id: status
1327
+ uses: appleboy/ssh-action@v1.0.3
1328
+ with:
1329
+ host: ${{ secrets.SERVER_HOST }}
1330
+ username: ${{ secrets.SERVER_USER }}
1331
+ password: ${{ secrets.SERVER_PASSWORD }}
1332
+ key: ${{ secrets.SSH_KEY }}
1333
+ port: ${{ env.SSH_PORT }}
1334
+ script: |
1335
+ # 환경 변수 설정 (배포 서버용)
1336
+ export PATH=$PATH:/usr/local/bin
1337
+
1338
+ NUMBER=${{ github.event.issue.number }}
1339
+ PROJECT_NAME="${{ env.PROJECT_NAME }}"
1340
+ CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}"
1341
+
1342
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1343
+ echo "🔍 Preview 상태 확인"
1344
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1345
+
1346
+ if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then
1347
+ echo "STATUS=running"
1348
+ echo "✅ 컨테이너 실행 중"
1349
+ docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}"
1350
+ else
1351
+ echo "STATUS=not_found"
1352
+ echo "❌ 컨테이너 없음"
1353
+ fi
1354
+
1355
+ - name: 상태 코멘트 (실행 중)
1356
+ uses: appleboy/ssh-action@v1.0.3
1357
+ id: check_running
1358
+ continue-on-error: true
1359
+ with:
1360
+ host: ${{ secrets.SERVER_HOST }}
1361
+ username: ${{ secrets.SERVER_USER }}
1362
+ password: ${{ secrets.SERVER_PASSWORD }}
1363
+ key: ${{ secrets.SSH_KEY }}
1364
+ port: ${{ env.SSH_PORT }}
1365
+ script: |
1366
+ # 환경 변수 설정 (배포 서버용)
1367
+ export PATH=$PATH:/usr/local/bin
1368
+
1369
+ NUMBER=${{ github.event.issue.number }}
1370
+ PROJECT_NAME="${{ env.PROJECT_NAME }}"
1371
+ CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}"
1372
+ docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"
1373
+
1374
+ - name: 상태 코멘트 작성
1375
+ uses: actions/github-script@v7
1376
+ with:
1377
+ script: |
1378
+ const number = context.issue.number;
1379
+ const projectName = '${{ env.PROJECT_NAME }}';
1380
+ const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
1381
+ const previewPort = '${{ env.PREVIEW_PORT }}';
1382
+ const domain = `${projectName}-pr-${number}.${domainSuffix}`;
1383
+ const previewUrl = `http://${domain}:${previewPort}`;
1384
+ const isRunning = '${{ steps.check_running.outcome }}' === 'success';
1385
+ const isPr = '${{ needs.check-command.outputs.is_pr }}' === 'true';
1386
+ const contextType = isPr ? 'PR' : 'Issue';
1387
+
1388
+ let body;
1389
+ if (isRunning) {
1390
+ body = [
1391
+ '## ✅ Preview 환경 실행 중',
1392
+ '',
1393
+ '| 항목 | 값 |',
1394
+ '|------|-----|',
1395
+ `| **Preview URL** | ${previewUrl} |`,
1396
+ `| **컨테이너** | \`${projectName}-pr-${number}\` |`,
1397
+ '| **상태** | 🟢 Running |',
1398
+ '',
1399
+ '### 📋 명령어',
1400
+ '| 명령어 | 설명 |',
1401
+ '|--------|------|',
1402
+ '| `@suh-lab server build` | 최신 커밋으로 재배포 |',
1403
+ '| `@suh-lab server destroy` | Preview 환경 삭제 |',
1404
+ '',
1405
+ '---',
1406
+ `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*`
1407
+ ].join('\n');
1408
+ } else {
1409
+ body = [
1410
+ '## ❌ Preview 환경 없음',
1411
+ '',
1412
+ '| 항목 | 값 |',
1413
+ '|------|-----|',
1414
+ `| **컨테이너** | \`${projectName}-pr-${number}\` |`,
1415
+ '| **상태** | 🔴 Not Found |',
1416
+ '',
1417
+ '배포하려면: `@suh-lab server build`',
1418
+ '',
1419
+ '---',
1420
+ `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*`
1421
+ ].join('\n');
1422
+ }
1423
+
1424
+ await github.rest.issues.createComment({
1425
+ owner: context.repo.owner,
1426
+ repo: context.repo.repo,
1427
+ issue_number: number,
1428
+ body: body
1429
+ });
1430
+
1431
+ # -----------------------------------------------------------------
1432
+ # Job 5: 커스텀 브랜치 빌드 & 배포
1433
+ # -----------------------------------------------------------------
1434
+ build-preview-custom-branch:
1435
+ name: Preview 빌드 & 배포 (Custom Branch)
1436
+ needs: check-command
1437
+ if: |
1438
+ needs.check-command.outputs.is_valid == 'true' &&
1439
+ needs.check-command.outputs.command == 'build' &&
1440
+ needs.check-command.outputs.is_custom_branch == 'true'
1441
+ runs-on: ubuntu-latest
1442
+ steps:
1443
+ - name: 브랜치 존재 확인 및 정보 추출
1444
+ id: branch_info
1445
+ uses: actions/github-script@v7
1446
+ with:
1447
+ script: |
1448
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1449
+ const issueNumber = context.issue.number;
1450
+
1451
+ console.log(`🔍 브랜치 확인: ${branchName}`);
1452
+
1453
+ // 브랜치 존재 여부 확인
1454
+ try {
1455
+ await github.rest.repos.getBranch({
1456
+ owner: context.repo.owner,
1457
+ repo: context.repo.repo,
1458
+ branch: branchName
1459
+ });
1460
+ console.log(`✅ 브랜치 존재: ${branchName}`);
1461
+ } catch (error) {
1462
+ if (error.status === 404) {
1463
+ console.log(`❌ 브랜치 없음: ${branchName}`);
1464
+
1465
+ // 에러 댓글 작성
1466
+ const body = [
1467
+ '## ❌ 브랜치를 찾을 수 없습니다',
1468
+ '',
1469
+ '| 항목 | 값 |',
1470
+ '|------|-----|',
1471
+ `| **브랜치** | \`${branchName}\` |`,
1472
+ '',
1473
+ '### 💡 확인 사항',
1474
+ '1. 브랜치명이 정확한지 확인하세요',
1475
+ '2. 브랜치가 push되었는지 확인하세요',
1476
+ '3. 브랜치가 삭제되지 않았는지 확인하세요',
1477
+ '',
1478
+ '---',
1479
+ '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
1480
+ ].join('\n');
1481
+
1482
+ await github.rest.issues.createComment({
1483
+ owner: context.repo.owner,
1484
+ repo: context.repo.repo,
1485
+ issue_number: issueNumber,
1486
+ body: body
1487
+ });
1488
+
1489
+ core.setFailed('브랜치를 찾을 수 없습니다');
1490
+ return;
1491
+ }
1492
+ throw error;
1493
+ }
1494
+
1495
+ // 브랜치명에서 Issue 번호 추출 (#번호 패턴)
1496
+ const issueMatch = branchName.match(/#(\d+)/);
1497
+
1498
+ if (issueMatch) {
1499
+ // 번호 추출 성공
1500
+ core.setOutput('container_number', issueMatch[1]);
1501
+ core.setOutput('use_hash', 'false');
1502
+ console.log(`✅ Issue 번호 추출: #${issueMatch[1]}`);
1503
+ } else {
1504
+ // 해시 생성
1505
+ let hash = 0;
1506
+ for (let i = 0; i < branchName.length; i++) {
1507
+ const char = branchName.charCodeAt(i);
1508
+ hash = ((hash << 5) - hash) + char;
1509
+ hash = hash & hash;
1510
+ }
1511
+ const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0');
1512
+ core.setOutput('container_suffix', `custom-${hashStr}`);
1513
+ core.setOutput('use_hash', 'true');
1514
+ console.log(`✅ 해시 생성: custom-${hashStr}`);
1515
+ }
1516
+
1517
+ core.setOutput('branch_name', branchName);
1518
+ core.setOutput('branch_exists', 'true');
1519
+
1520
+ - name: 브랜치의 최신 커밋 SHA 가져오기
1521
+ if: steps.branch_info.outputs.branch_exists == 'true'
1522
+ id: get_sha
1523
+ run: |
1524
+ SHA=$(git ls-remote https://github.com/${{ github.repository }}.git refs/heads/${{ needs.check-command.outputs.custom_branch }} | cut -c1-7)
1525
+ echo "sha=$SHA" >> $GITHUB_OUTPUT
1526
+
1527
+ # 진행 상황 댓글 생성
1528
+ - name: 진행 상황 댓글 생성
1529
+ if: steps.branch_info.outputs.branch_exists == 'true'
1530
+ id: progress
1531
+ uses: actions/github-script@v7
1532
+ with:
1533
+ script: |
1534
+ const issueNumber = context.issue.number;
1535
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1536
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
1537
+ const startTime = Date.now();
1538
+
1539
+ const body = [
1540
+ '## 🚀 Custom Branch Preview 빌드 중...',
1541
+ '',
1542
+ `**브랜치**: \`${branchName}\``,
1543
+ '',
1544
+ '| 단계 | 상태 | 소요 시간 |',
1545
+ '|------|------|----------|',
1546
+ '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
1547
+ '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
1548
+ '',
1549
+ `**[📋 실시간 로그 보기](${runUrl})**`,
1550
+ '',
1551
+ '---',
1552
+ '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
1553
+ ].join('\n');
1554
+
1555
+ const { data: comment } = await github.rest.issues.createComment({
1556
+ owner: context.repo.owner,
1557
+ repo: context.repo.repo,
1558
+ issue_number: issueNumber,
1559
+ body: body
1560
+ });
1561
+
1562
+ core.setOutput('comment_id', comment.id);
1563
+ core.setOutput('start_time', startTime);
1564
+ core.setOutput('docker_start', startTime);
1565
+
1566
+ - name: 코드 체크아웃
1567
+ if: steps.branch_info.outputs.branch_exists == 'true'
1568
+ uses: actions/checkout@v5
1569
+ with:
1570
+ ref: ${{ needs.check-command.outputs.custom_branch }}
1571
+
1572
+ # 환경변수 파일 생성
1573
+ - name: "[필수] .env 파일 생성"
1574
+ if: steps.branch_info.outputs.branch_exists == 'true'
1575
+ run: |
1576
+ cat << 'EOF' > .env
1577
+ ${{ secrets.ENV_FILE }}
1578
+ EOF
1579
+
1580
+ - name: Docker 로그인
1581
+ if: steps.branch_info.outputs.branch_exists == 'true'
1582
+ uses: docker/login-action@v3
1583
+ with:
1584
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
1585
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
1586
+
1587
+ - name: Docker Buildx 설정
1588
+ if: steps.branch_info.outputs.branch_exists == 'true'
1589
+ uses: docker/setup-buildx-action@v3
1590
+
1591
+ - name: 컨테이너 이름 결정
1592
+ if: steps.branch_info.outputs.branch_exists == 'true'
1593
+ id: container
1594
+ run: |
1595
+ if [[ "${{ steps.branch_info.outputs.use_hash }}" == "true" ]]; then
1596
+ SUFFIX="${{ steps.branch_info.outputs.container_suffix }}"
1597
+ else
1598
+ SUFFIX="pr-${{ steps.branch_info.outputs.container_number }}"
1599
+ fi
1600
+ echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
1601
+ echo "name=${{ env.PROJECT_NAME }}-${SUFFIX}" >> $GITHUB_OUTPUT
1602
+
1603
+ - name: Docker 이미지 빌드 & Push
1604
+ if: steps.branch_info.outputs.branch_exists == 'true'
1605
+ uses: docker/build-push-action@v5
1606
+ with:
1607
+ context: .
1608
+ file: ${{ env.DOCKERFILE_PATH }}
1609
+ push: true
1610
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}:${{ steps.container.outputs.suffix }}
1611
+ cache-from: type=gha
1612
+ cache-to: type=gha,mode=max
1613
+
1614
+ - name: 진행 상황 - Docker 완료
1615
+ if: steps.branch_info.outputs.branch_exists == 'true'
1616
+ id: docker_progress
1617
+ uses: actions/github-script@v7
1618
+ with:
1619
+ script: |
1620
+ const commentId = ${{ steps.progress.outputs.comment_id }};
1621
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1622
+ const dockerStart = ${{ steps.progress.outputs.docker_start }};
1623
+ const now = Date.now();
1624
+ const dockerElapsed = now - dockerStart;
1625
+ const minutes = Math.floor(dockerElapsed / 60000);
1626
+ const seconds = Math.floor((dockerElapsed % 60000) / 1000);
1627
+ const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
1628
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
1629
+
1630
+ const body = [
1631
+ '## 🚀 Custom Branch Preview 빌드 중...',
1632
+ '',
1633
+ `**브랜치**: \`${branchName}\``,
1634
+ '',
1635
+ '| 단계 | 상태 | 소요 시간 |',
1636
+ '|------|------|----------|',
1637
+ `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
1638
+ '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
1639
+ '',
1640
+ `**[📋 실시간 로그 보기](${runUrl})**`,
1641
+ '',
1642
+ '---',
1643
+ '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
1644
+ ].join('\n');
1645
+
1646
+ await github.rest.issues.updateComment({
1647
+ owner: context.repo.owner,
1648
+ repo: context.repo.repo,
1649
+ comment_id: commentId,
1650
+ body: body
1651
+ });
1652
+
1653
+ core.setOutput('docker_duration', dockerDuration);
1654
+ core.setOutput('deploy_start', now);
1655
+
1656
+ - name: 서버에 배포
1657
+ if: steps.branch_info.outputs.branch_exists == 'true'
1658
+ uses: appleboy/ssh-action@v1.0.3
1659
+ env:
1660
+ SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
1661
+ with:
1662
+ host: ${{ secrets.SERVER_HOST }}
1663
+ username: ${{ secrets.SERVER_USER }}
1664
+ password: ${{ secrets.SERVER_PASSWORD }}
1665
+ key: ${{ secrets.SSH_KEY }}
1666
+ port: ${{ env.SSH_PORT }}
1667
+ envs: SSH_AUTH_METHOD
1668
+ script: |
1669
+ set -e
1670
+
1671
+ # 환경 변수 설정 (배포 서버용)
1672
+ export PATH=$PATH:/usr/local/bin
1673
+ export PW="${{ secrets.SERVER_PASSWORD }}"
1674
+
1675
+ # 🔐 SSH 인증 방식에 따른 sudo 추상화
1676
+ SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
1677
+ if [ "${SSH_AUTH_METHOD}" = "key" ]; then
1678
+ SUDO() { sudo "$@"; }
1679
+ else
1680
+ SUDO() { echo "$PW" | sudo -S "$@"; }
1681
+ fi
1682
+ echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
1683
+
1684
+ # 변수 설정
1685
+ PROJECT_NAME="${{ env.PROJECT_NAME }}"
1686
+ CONTAINER_NAME="${{ steps.container.outputs.name }}"
1687
+ CONTAINER_SUFFIX="${{ steps.container.outputs.suffix }}"
1688
+ IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${CONTAINER_SUFFIX}"
1689
+ DOMAIN="${CONTAINER_NAME}.${{ env.PREVIEW_DOMAIN_SUFFIX }}"
1690
+ INTERNAL_PORT="${{ env.INTERNAL_PORT }}"
1691
+ TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
1692
+
1693
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1694
+ echo "🚀 Custom Branch Preview 배포 시작"
1695
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1696
+ echo "📦 프로젝트: ${PROJECT_NAME}"
1697
+ echo "📛 컨테이너: ${CONTAINER_NAME}"
1698
+ echo "🌐 도메인: ${DOMAIN}"
1699
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1700
+
1701
+ # 이미지 Pull
1702
+ echo "📥 Docker 이미지 Pull 중..."
1703
+ SUDO docker pull "${IMAGE}"
1704
+
1705
+ # 기존 컨테이너 삭제
1706
+ echo "🗑️ 기존 컨테이너 정리 중..."
1707
+ SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
1708
+
1709
+ # 볼륨 마운트 옵션 구성
1710
+ VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro"
1711
+ if [ -n "${{ env.PROJECT_TARGET_DIR }}" ] && [ -n "${{ env.PROJECT_MNT_DIR }}" ]; then
1712
+ VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}"
1713
+ fi
1714
+
1715
+ # 새 컨테이너 실행
1716
+ echo "🐳 새 컨테이너 실행 중..."
1717
+ SUDO docker run -d \
1718
+ --name "${CONTAINER_NAME}" \
1719
+ --network "${TRAEFIK_NETWORK}" \
1720
+ --label "traefik.enable=true" \
1721
+ --label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${DOMAIN}\`)" \
1722
+ --label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \
1723
+ --label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=${INTERNAL_PORT}" \
1724
+ -e TZ=Asia/Seoul \
1725
+ -e ENVIRONMENT=prod \
1726
+ $VOLUME_OPTS \
1727
+ "${IMAGE}"
1728
+
1729
+ # Health Check
1730
+ echo ""
1731
+ echo "⏳ Health Check 시작 (최대 120초 대기)..."
1732
+ HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
1733
+ LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
1734
+ MAX_RETRIES=24
1735
+ RETRY_COUNT=0
1736
+ HEALTH_CHECK_PASSED=false
1737
+ HEALTH_CHECK_METHOD=""
1738
+
1739
+ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
1740
+ sleep 5
1741
+ RETRY_COUNT=$((RETRY_COUNT + 1))
1742
+
1743
+ STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
1744
+
1745
+ if [ "$STATUS" = "exited" ]; then
1746
+ echo "❌ 컨테이너 비정상 종료!"
1747
+ echo ""
1748
+ echo "📋 컨테이너 로그 (최근 100줄):"
1749
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1750
+ SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1751
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1752
+ exit 1
1753
+ fi
1754
+
1755
+ if [ "$STATUS" = "running" ]; then
1756
+ if [ -n "$HEALTH_PATH" ]; then
1757
+ HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
1758
+
1759
+ if [ -n "$HEALTH" ]; then
1760
+ echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
1761
+ HEALTH_CHECK_PASSED=true
1762
+ HEALTH_CHECK_METHOD="HTTP"
1763
+ break
1764
+ fi
1765
+ fi
1766
+
1767
+ if [ -n "$LOG_PATTERN" ]; then
1768
+ STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
1769
+
1770
+ if [ -n "$STARTED" ]; then
1771
+ echo "✅ 정상 기동 확인! (로그 패턴)"
1772
+ echo " $STARTED"
1773
+ HEALTH_CHECK_PASSED=true
1774
+ HEALTH_CHECK_METHOD="Log"
1775
+ break
1776
+ fi
1777
+ fi
1778
+ fi
1779
+
1780
+ echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
1781
+ done
1782
+
1783
+ if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
1784
+ echo ""
1785
+ echo "❌ Health Check 타임아웃 (120초)"
1786
+ echo ""
1787
+ echo "📋 컨테이너 로그 (최근 100줄):"
1788
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1789
+ SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1790
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1791
+ exit 1
1792
+ fi
1793
+
1794
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1795
+ echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
1796
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1797
+
1798
+ - name: 배포 완료 코멘트
1799
+ if: steps.branch_info.outputs.branch_exists == 'true'
1800
+ uses: actions/github-script@v7
1801
+ with:
1802
+ script: |
1803
+ const commentId = ${{ steps.progress.outputs.comment_id }};
1804
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1805
+ const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
1806
+ const deployStart = ${{ steps.docker_progress.outputs.deploy_start }};
1807
+ const now = Date.now();
1808
+ const deployElapsed = now - deployStart;
1809
+ const minutes = Math.floor(deployElapsed / 60000);
1810
+ const seconds = Math.floor((deployElapsed % 60000) / 1000);
1811
+ const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
1812
+
1813
+ const projectName = '${{ env.PROJECT_NAME }}';
1814
+ const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
1815
+ const previewPort = '${{ env.PREVIEW_PORT }}';
1816
+ const apiDocsPath = '${{ env.API_DOCS_PATH }}';
1817
+ const containerName = '${{ steps.container.outputs.name }}';
1818
+ const domain = `${containerName}.${domainSuffix}`;
1819
+ const previewUrl = `http://${domain}:${previewPort}`;
1820
+ const sha = '${{ steps.get_sha.outputs.sha }}';
1821
+
1822
+ // Preview 환경 테이블 구성
1823
+ const envRows = [
1824
+ `| **Preview URL** | ${previewUrl} |`,
1825
+ ];
1826
+ if (apiDocsPath) {
1827
+ envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
1828
+ }
1829
+ envRows.push(`| **컨테이너** | \`${containerName}\` |`);
1830
+ envRows.push(`| **브랜치** | \`${branchName}\` |`);
1831
+ envRows.push(`| **커밋** | \`${sha}\` |`);
1832
+
1833
+ const body = [
1834
+ '## ✅ Custom Branch Preview 배포 완료!',
1835
+ '',
1836
+ `**브랜치**: \`${branchName}\``,
1837
+ '',
1838
+ '| 단계 | 상태 | 소요 시간 |',
1839
+ '|------|------|----------|',
1840
+ `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
1841
+ `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
1842
+ '',
1843
+ '### 🌐 Preview 환경',
1844
+ '| 항목 | 값 |',
1845
+ '|------|-----|',
1846
+ ...envRows,
1847
+ '',
1848
+ '### 📋 명령어',
1849
+ '| 명령어 | 설명 |',
1850
+ '|--------|------|',
1851
+ '| `@suh-lab server build` | 최신 커밋으로 재배포 |',
1852
+ '| `@suh-lab server destroy` | Preview 환경 삭제 |',
1853
+ '| `@suh-lab server status` | 현재 상태 확인 |',
1854
+ '',
1855
+ '<details>',
1856
+ '<summary>🔧 고급 명령어 (다른 Issue/PR에서 제어)</summary>',
1857
+ '',
1858
+ '```',
1859
+ `@suh-lab server build ${branchName}`,
1860
+ `@suh-lab server destroy ${branchName}`,
1861
+ `@suh-lab server status ${branchName}`,
1862
+ '```',
1863
+ '</details>',
1864
+ '',
1865
+ '---',
1866
+ '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
1867
+ ].join('\n');
1868
+
1869
+ await github.rest.issues.updateComment({
1870
+ owner: context.repo.owner,
1871
+ repo: context.repo.repo,
1872
+ comment_id: commentId,
1873
+ body: body
1874
+ });
1875
+
1876
+ - name: 빌드/배포 실패 시 에러 코멘트
1877
+ if: failure() && steps.branch_info.outputs.branch_exists == 'true'
1878
+ uses: actions/github-script@v7
1879
+ with:
1880
+ script: |
1881
+ const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
1882
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1883
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
1884
+
1885
+ const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
1886
+
1887
+ let dockerStatus, deployStatus;
1888
+ let dockerTime = '-';
1889
+
1890
+ if (!commentId) {
1891
+ dockerStatus = '⚠️ 초기화 실패';
1892
+ deployStatus = '-';
1893
+ } else if (!dockerDuration || dockerDuration === '') {
1894
+ dockerStatus = '❌ 실패';
1895
+ deployStatus = '⏸️ 대기';
1896
+ } else {
1897
+ dockerStatus = '✅ 완료';
1898
+ deployStatus = '❌ 실패';
1899
+ dockerTime = dockerDuration;
1900
+ }
1901
+
1902
+ const body = [
1903
+ '## ❌ Custom Branch Preview 배포 실패!',
1904
+ '',
1905
+ `**브랜치**: \`${branchName}\``,
1906
+ '',
1907
+ '| 단계 | 상태 | 소요 시간 |',
1908
+ '|------|------|----------|',
1909
+ `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
1910
+ `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
1911
+ '',
1912
+ `**[📋 빌드/배포 로그 확인](${runUrl})**`,
1913
+ '',
1914
+ '### 🔍 가능한 원인',
1915
+ '- Docker 이미지 빌드 실패 (Python 의존성 문제)',
1916
+ '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
1917
+ '- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
1918
+ '- 환경변수 누락 (.env 파일 설정 확인)',
1919
+ '',
1920
+ '### 💡 다음 단계',
1921
+ '1. 위 링크에서 빌드/배포 로그를 확인하세요',
1922
+ `2. 문제를 수정한 후 다시 시도하세요: \`@suh-lab server build ${branchName}\``,
1923
+ '',
1924
+ '---',
1925
+ '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
1926
+ ].join('\n');
1927
+
1928
+ if (commentId) {
1929
+ await github.rest.issues.updateComment({
1930
+ owner: context.repo.owner,
1931
+ repo: context.repo.repo,
1932
+ comment_id: commentId,
1933
+ body: body
1934
+ });
1935
+ } else {
1936
+ await github.rest.issues.createComment({
1937
+ owner: context.repo.owner,
1938
+ repo: context.repo.repo,
1939
+ issue_number: context.issue.number,
1940
+ body: body
1941
+ });
1942
+ }
1943
+
1944
+ # -----------------------------------------------------------------
1945
+ # Job 6: 커스텀 브랜치 Preview 삭제
1946
+ # -----------------------------------------------------------------
1947
+ destroy-preview-custom-branch:
1948
+ name: Preview 삭제 (Custom Branch)
1949
+ needs: check-command
1950
+ if: |
1951
+ needs.check-command.outputs.is_valid == 'true' &&
1952
+ needs.check-command.outputs.command == 'destroy' &&
1953
+ needs.check-command.outputs.is_custom_branch == 'true'
1954
+ runs-on: ubuntu-latest
1955
+ steps:
1956
+ - name: 브랜치명에서 컨테이너 정보 추출
1957
+ id: container_info
1958
+ uses: actions/github-script@v7
1959
+ with:
1960
+ script: |
1961
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1962
+ const projectName = '${{ env.PROJECT_NAME }}';
1963
+
1964
+ // 브랜치명에서 Issue 번호 추출 (#번호 패턴)
1965
+ const issueMatch = branchName.match(/#(\d+)/);
1966
+
1967
+ let containerName;
1968
+ if (issueMatch) {
1969
+ containerName = `${projectName}-pr-${issueMatch[1]}`;
1970
+ console.log(`✅ Issue 번호 추출: #${issueMatch[1]}`);
1971
+ } else {
1972
+ // 해시 생성
1973
+ let hash = 0;
1974
+ for (let i = 0; i < branchName.length; i++) {
1975
+ const char = branchName.charCodeAt(i);
1976
+ hash = ((hash << 5) - hash) + char;
1977
+ hash = hash & hash;
1978
+ }
1979
+ const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0');
1980
+ containerName = `${projectName}-custom-${hashStr}`;
1981
+ console.log(`✅ 해시 생성: custom-${hashStr}`);
1982
+ }
1983
+
1984
+ core.setOutput('container_name', containerName);
1985
+ core.setOutput('branch_name', branchName);
1986
+
1987
+ - name: 컨테이너 & 이미지 삭제
1988
+ uses: appleboy/ssh-action@v1.0.3
1989
+ env:
1990
+ SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
1991
+ with:
1992
+ host: ${{ secrets.SERVER_HOST }}
1993
+ username: ${{ secrets.SERVER_USER }}
1994
+ password: ${{ secrets.SERVER_PASSWORD }}
1995
+ key: ${{ secrets.SSH_KEY }}
1996
+ port: ${{ env.SSH_PORT }}
1997
+ envs: SSH_AUTH_METHOD
1998
+ script: |
1999
+ # 환경 변수 설정 (배포 서버용)
2000
+ export PATH=$PATH:/usr/local/bin
2001
+ export PW="${{ secrets.SERVER_PASSWORD }}"
2002
+
2003
+ # 🔐 SSH 인증 방식에 따른 sudo 추상화
2004
+ SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
2005
+ if [ "${SSH_AUTH_METHOD}" = "key" ]; then
2006
+ SUDO() { sudo "$@"; }
2007
+ else
2008
+ SUDO() { echo "$PW" | sudo -S "$@"; }
2009
+ fi
2010
+ echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
2011
+
2012
+ CONTAINER_NAME="${{ steps.container_info.outputs.container_name }}"
2013
+ PROJECT_NAME="${{ env.PROJECT_NAME }}"
2014
+
2015
+ # 컨테이너명에서 suffix 추출
2016
+ SUFFIX="${CONTAINER_NAME#${PROJECT_NAME}-}"
2017
+ IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${SUFFIX}"
2018
+
2019
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2020
+ echo "🗑️ Custom Branch Preview 삭제"
2021
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2022
+ echo "📦 프로젝트: ${PROJECT_NAME}"
2023
+ echo "📛 컨테이너: ${CONTAINER_NAME}"
2024
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2025
+
2026
+ # 컨테이너 삭제
2027
+ echo "🐳 컨테이너 삭제 중..."
2028
+ SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
2029
+
2030
+ # 이미지 삭제
2031
+ echo "🖼️ 이미지 삭제 중..."
2032
+ SUDO docker rmi "${IMAGE}" 2>/dev/null || true
2033
+
2034
+ echo "✅ 삭제 완료!"
2035
+
2036
+ - name: 삭제 완료 코멘트
2037
+ uses: actions/github-script@v7
2038
+ with:
2039
+ script: |
2040
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
2041
+ const containerName = '${{ steps.container_info.outputs.container_name }}';
2042
+
2043
+ const body = [
2044
+ '## 🗑️ Custom Branch Preview 환경 삭제 완료!',
2045
+ '',
2046
+ '| 항목 | 값 |',
2047
+ '|------|-----|',
2048
+ `| **브랜치** | \`${branchName}\` |`,
2049
+ `| **컨테이너** | \`${containerName}\` |`,
2050
+ '| **상태** | 삭제됨 |',
2051
+ '',
2052
+ `다시 배포하려면: \`@suh-lab server build ${branchName}\``,
2053
+ '',
2054
+ '---',
2055
+ '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2056
+ ].join('\n');
2057
+
2058
+ await github.rest.issues.createComment({
2059
+ owner: context.repo.owner,
2060
+ repo: context.repo.repo,
2061
+ issue_number: context.issue.number,
2062
+ body: body
2063
+ });
2064
+
2065
+ # -----------------------------------------------------------------
2066
+ # Job 7: 커스텀 브랜치 Preview 상태 확인
2067
+ # -----------------------------------------------------------------
2068
+ check-status-custom-branch:
2069
+ name: Preview 상태 확인 (Custom Branch)
2070
+ needs: check-command
2071
+ if: |
2072
+ needs.check-command.outputs.is_valid == 'true' &&
2073
+ needs.check-command.outputs.command == 'status' &&
2074
+ needs.check-command.outputs.is_custom_branch == 'true'
2075
+ runs-on: ubuntu-latest
2076
+ steps:
2077
+ - name: 브랜치명에서 컨테이너 정보 추출
2078
+ id: container_info
2079
+ uses: actions/github-script@v7
2080
+ with:
2081
+ script: |
2082
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
2083
+ const projectName = '${{ env.PROJECT_NAME }}';
2084
+
2085
+ // 브랜치명에서 Issue 번호 추출 (#번호 패턴)
2086
+ const issueMatch = branchName.match(/#(\d+)/);
2087
+
2088
+ let containerName;
2089
+ if (issueMatch) {
2090
+ containerName = `${projectName}-pr-${issueMatch[1]}`;
2091
+ } else {
2092
+ // 해시 생성
2093
+ let hash = 0;
2094
+ for (let i = 0; i < branchName.length; i++) {
2095
+ const char = branchName.charCodeAt(i);
2096
+ hash = ((hash << 5) - hash) + char;
2097
+ hash = hash & hash;
2098
+ }
2099
+ const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0');
2100
+ containerName = `${projectName}-custom-${hashStr}`;
2101
+ }
2102
+
2103
+ core.setOutput('container_name', containerName);
2104
+ core.setOutput('branch_name', branchName);
2105
+
2106
+ - name: 컨테이너 상태 확인
2107
+ uses: appleboy/ssh-action@v1.0.3
2108
+ id: check_running
2109
+ continue-on-error: true
2110
+ with:
2111
+ host: ${{ secrets.SERVER_HOST }}
2112
+ username: ${{ secrets.SERVER_USER }}
2113
+ password: ${{ secrets.SERVER_PASSWORD }}
2114
+ key: ${{ secrets.SSH_KEY }}
2115
+ port: ${{ env.SSH_PORT }}
2116
+ script: |
2117
+ export PATH=$PATH:/usr/local/bin
2118
+
2119
+ CONTAINER_NAME="${{ steps.container_info.outputs.container_name }}"
2120
+
2121
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2122
+ echo "🔍 Custom Branch Preview 상태 확인"
2123
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2124
+
2125
+ if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then
2126
+ echo "STATUS=running"
2127
+ echo "✅ 컨테이너 실행 중"
2128
+ docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}"
2129
+ else
2130
+ echo "STATUS=not_found"
2131
+ echo "❌ 컨테이너 없음"
2132
+ exit 1
2133
+ fi
2134
+
2135
+ - name: 상태 코멘트 작성
2136
+ uses: actions/github-script@v7
2137
+ with:
2138
+ script: |
2139
+ const branchName = '${{ needs.check-command.outputs.custom_branch }}';
2140
+ const containerName = '${{ steps.container_info.outputs.container_name }}';
2141
+ const projectName = '${{ env.PROJECT_NAME }}';
2142
+ const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
2143
+ const previewPort = '${{ env.PREVIEW_PORT }}';
2144
+ const domain = `${containerName}.${domainSuffix}`;
2145
+ const previewUrl = `http://${domain}:${previewPort}`;
2146
+ const isRunning = '${{ steps.check_running.outcome }}' === 'success';
2147
+
2148
+ let body;
2149
+ if (isRunning) {
2150
+ body = [
2151
+ '## ✅ Custom Branch Preview 환경 실행 중',
2152
+ '',
2153
+ '| 항목 | 값 |',
2154
+ '|------|-----|',
2155
+ `| **브랜치** | \`${branchName}\` |`,
2156
+ `| **Preview URL** | ${previewUrl} |`,
2157
+ `| **컨테이너** | \`${containerName}\` |`,
2158
+ '| **상태** | 🟢 Running |',
2159
+ '',
2160
+ '### 📋 명령어',
2161
+ '| 명령어 | 설명 |',
2162
+ '|--------|------|',
2163
+ `| \`@suh-lab server build ${branchName}\` | 최신 커밋으로 재배포 |`,
2164
+ `| \`@suh-lab server destroy ${branchName}\` | Preview 환경 삭제 |`,
2165
+ '',
2166
+ '---',
2167
+ '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2168
+ ].join('\n');
2169
+ } else {
2170
+ body = [
2171
+ '## ❌ Custom Branch Preview 환경 없음',
2172
+ '',
2173
+ '| 항목 | 값 |',
2174
+ '|------|-----|',
2175
+ `| **브랜치** | \`${branchName}\` |`,
2176
+ `| **컨테이너** | \`${containerName}\` |`,
2177
+ '| **상태** | 🔴 Not Found |',
2178
+ '',
2179
+ `배포하려면: \`@suh-lab server build ${branchName}\``,
2180
+ '',
2181
+ '---',
2182
+ '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2183
+ ].join('\n');
2184
+ }
2185
+
2186
+ await github.rest.issues.createComment({
2187
+ owner: context.repo.owner,
2188
+ repo: context.repo.repo,
2189
+ issue_number: context.issue.number,
2190
+ body: body
2191
+ });