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