project-auto-wizard 0.5.0 → 0.6.0

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