project-auto-wizard 0.13.2 → 0.14.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.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/bin/project-auto-wizard.js +18 -4
  3. package/package.json +2 -2
  4. package/payload/config/breaking-changes.json +28 -14
  5. package/payload/config/wizard-prompts.yml +102 -46
  6. package/payload/flutter-app/android/fastlane/Fastfile.playstore +44 -32
  7. package/payload/flutter-app/ios/ExportOptions.plist +8 -8
  8. package/payload/flutter-app/ios/fastlane/Fastfile +57 -43
  9. package/payload/scripts/changelog_manager.py +296 -257
  10. package/payload/scripts/issue_helper.py +54 -50
  11. package/payload/scripts/messages.py +2279 -0
  12. package/payload/scripts/truncate_release_notes.py +7 -6
  13. package/payload/scripts/version_manager.py +64 -61
  14. package/payload/version.yml.template +2 -1
  15. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +17 -12
  16. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +59 -34
  17. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +17 -15
  18. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +17 -10
  19. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +54 -44
  20. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +24 -17
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +194 -184
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +226 -213
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +46 -42
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +334 -241
  25. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +152 -128
  26. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +246 -188
  27. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +353 -238
  28. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +96 -85
  29. package/payload/workflows/go/PROJECT-GO-CI.yaml +47 -36
  30. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +945 -508
  31. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +155 -133
  32. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +76 -60
  33. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +97 -77
  34. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +62 -47
  35. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +942 -505
  36. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +148 -126
  37. package/payload/workflows/react/PROJECT-REACT-CI.yaml +86 -70
  38. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +112 -92
  39. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +112 -82
  40. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +180 -150
  41. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +161 -131
  42. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +970 -549
  43. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +193 -162
  44. package/src/cli/args.js +98 -52
  45. package/src/cli/help.js +8 -41
  46. package/src/commands/doctor.js +85 -89
  47. package/src/commands/dry-run.js +63 -43
  48. package/src/commands/full.js +79 -77
  49. package/src/commands/install-settings.js +16 -15
  50. package/src/commands/interactive-flutter.js +20 -19
  51. package/src/commands/interactive.js +135 -132
  52. package/src/commands/purge.js +47 -42
  53. package/src/commands/status.js +36 -36
  54. package/src/commands/uninstall.js +43 -41
  55. package/src/context.js +12 -11
  56. package/src/core/assets.js +12 -11
  57. package/src/core/baseline.js +27 -27
  58. package/src/core/branches.js +29 -27
  59. package/src/core/branding.js +7 -6
  60. package/src/core/breaking-check.js +22 -21
  61. package/src/core/breaking.js +15 -8
  62. package/src/core/copy/app-files.js +5 -4
  63. package/src/core/copy/flutter-app.js +8 -8
  64. package/src/core/copy/gitignore.js +58 -50
  65. package/src/core/copy/readme.js +45 -35
  66. package/src/core/copy/simple.js +16 -15
  67. package/src/core/copy/workflows.js +131 -124
  68. package/src/core/deploy-style.js +51 -48
  69. package/src/core/detect-fs.js +57 -57
  70. package/src/core/detect.js +67 -65
  71. package/src/core/errors.js +2 -2
  72. package/src/core/flutter-doctor.js +17 -16
  73. package/src/core/flutter-hooks.js +33 -32
  74. package/src/core/flutter-options.js +34 -33
  75. package/src/core/fsutil.js +12 -12
  76. package/src/core/installed-stores.js +4 -4
  77. package/src/core/logger.js +42 -40
  78. package/src/core/paths-resolve.js +88 -86
  79. package/src/core/paths.js +10 -10
  80. package/src/core/release-options.js +8 -8
  81. package/src/core/removal-exec.js +14 -13
  82. package/src/core/removal-plan.js +37 -37
  83. package/src/core/types.js +47 -47
  84. package/src/core/verify.js +44 -38
  85. package/src/core/version-yml.js +76 -68
  86. package/src/core/wizard-env.js +46 -46
  87. package/src/core/wizard-labels.js +63 -41
  88. package/src/i18n/catalog/en/cli.js +50 -0
  89. package/src/i18n/catalog/en/commands.js +187 -0
  90. package/src/i18n/catalog/en/copy.js +19 -0
  91. package/src/i18n/catalog/en/core.js +74 -0
  92. package/src/i18n/catalog/en/core2.js +15 -0
  93. package/src/i18n/catalog/en/ui.js +221 -0
  94. package/src/i18n/catalog/en.js +12 -0
  95. package/src/i18n/catalog/index.js +5 -0
  96. package/src/i18n/catalog/ko/cli.js +50 -0
  97. package/src/i18n/catalog/ko/commands.js +187 -0
  98. package/src/i18n/catalog/ko/copy.js +19 -0
  99. package/src/i18n/catalog/ko/core.js +74 -0
  100. package/src/i18n/catalog/ko/core2.js +15 -0
  101. package/src/i18n/catalog/ko/ui.js +221 -0
  102. package/src/i18n/catalog/ko.js +10 -0
  103. package/src/i18n/index.js +44 -0
  104. package/src/i18n/languages.js +9 -0
  105. package/src/index.js +128 -94
  106. package/src/ui/ansi.js +7 -7
  107. package/src/ui/banner.js +7 -6
  108. package/src/ui/env-plan.js +82 -81
  109. package/src/ui/prompts.js +97 -122
  110. package/src/ui/readline-engine.js +59 -58
  111. package/src/ui/status-cards.js +37 -31
  112. package/src/ui/summary.js +89 -88
@@ -1,72 +1,72 @@
1
1
  # project-auto-wizard:managed-workflow
2
2
  # Python/FastAPI PR Preview (SSH + Docker + Traefik)
3
- # PR 또는 Issue 코멘트 명령어를 통해 Preview 환경을 자동으로 관리합니다.
4
- # Traefik 리버스 프록시를 통해 동적 라우팅됩니다.
3
+ # Manages the preview environment automatically through PR or issue comment commands.
4
+ # Routing is dynamic via the Traefik reverse proxy.
5
5
  #
6
- # 필수 GitHub Secrets
7
- # ENV_FILE: .env 파일 전체 내용
8
- # DOCKERHUB_USERNAME: Docker Hub 사용자명
9
- # DOCKERHUB_TOKEN: Docker Hub 액세스 토큰
10
- # SERVER_HOST: 서버 호스트(SSH 접속 주소)
11
- # SERVER_USER: SSH 사용자명
12
- # SERVER_PASSWORD: SSH 비밀번호 (SSH_AUTH_METHOD=password일 때)
13
- # SSH_KEY: SSH 개인키 .pem 내용 (SSH_AUTH_METHOD=key일 때, AWS EC2 등)
6
+ # Required GitHub Secrets
7
+ # ENV_FILE: full contents of the .env file
8
+ # DOCKERHUB_USERNAME: Docker Hub username
9
+ # DOCKERHUB_TOKEN: Docker Hub access token
10
+ # SERVER_HOST: server host (SSH address)
11
+ # SERVER_USER: SSH username
12
+ # SERVER_PASSWORD: SSH password (when SSH_AUTH_METHOD=password)
13
+ # SSH_KEY: contents of the SSH private key .pem (when SSH_AUTH_METHOD=key, e.g. AWS EC2)
14
14
  #
15
- # 사용법
16
- # /wizard server build - Preview 빌드 및 배포
17
- # /wizard server destroy - Preview 환경 삭제
18
- # /wizard server status - 현재 상태 확인
15
+ # Usage
16
+ # /wizard server build - Build and deploy the preview
17
+ # /wizard server destroy - Delete the preview environment
18
+ # /wizard server status - Check the current status
19
19
  #
20
- # 리소스 네이밍
21
- # - 컨테이너: {PROJECT_NAME}-pr-{번호}
22
- # - 도메인: {PROJECT_NAME}-pr-{번호}.pr.example.com
20
+ # Resource naming
21
+ # - Container: {PROJECT_NAME}-pr-{number}
22
+ # - Domain: {PROJECT_NAME}-pr-{number}.pr.example.com
23
23
  #
24
- # 사전 요구사항
25
- # - Traefik 컨테이너 실행 중 (traefik-network)
26
- # - 와일드카드 DNS 설정: *.pr.example.com → 서버
24
+ # Prerequisites
25
+ # - Traefik container running (traefik-network)
26
+ # - Wildcard DNS: *.pr.example.com → server
27
27
 
28
28
  name: PROJECT-PYTHON-PR-PREVIEW
29
29
 
30
- # 프로젝트별 설정: 다른 프로젝트에서 사용 시 이 섹션만 수정하세요
30
+ # Project settings: when reusing this in another project, edit only this section
31
31
  env:
32
- # 프로젝트 고유 식별자 (컨테이너명, 이미지명, 도메인에 사용)
32
+ # Unique project identifier (used in container, image and domain names)
33
33
  PROJECT_NAME: "__PROJECT_NAME__" # @wizard ask:@repo
34
- # 모노레포(--paths)에서 이 프로젝트가 있는 하위 경로. 빌드·Docker context의 기준 ('.'이면 레포 루트)
34
+ # Subdirectory of this project in a monorepo (--paths). Base for the build and Docker context ('.' means the repo root)
35
35
  PROJECT_PATH: "." # @wizard auto:project-path
36
- # 모노레포(PROJECT_PATH가 '.'이 아님)에서는 이미지·컨테이너·도메인 이름에 '-python'를 붙여
37
- # 같은 레포의 다른 타입 서비스와 이름이 겹치지 않게 한다 (예: my-app-python)
36
+ # In a monorepo (PROJECT_PATH is not '.') '-python' is appended to image, container and domain names
37
+ # so they do not collide with services of other types in the same repo (e.g. my-app-python)
38
38
 
39
- # Docker 설정
40
- DOCKERFILE_PATH: './Dockerfile' # PROJECT_PATH 기준 상대 경로
39
+ # Docker settings
40
+ DOCKERFILE_PATH: './Dockerfile' # path relative to PROJECT_PATH
41
41
  INTERNAL_PORT: '8000'
42
42
 
43
- # 볼륨 마운트 설정 (프로젝트별 수정 필요, 빈값이면 마운트 안함)
43
+ # Volume mount settings (edit per project; leave empty to skip mounting)
44
44
  PROJECT_TARGET_DIR: "__PROJECT_TARGET_DIR__" # @wizard ask:/volume1/projects/__PROJECT_NAME__
45
45
  PROJECT_MNT_DIR: "__PROJECT_MNT_DIR__" # @wizard ask:/mnt/__PROJECT_NAME__
46
46
 
47
- # Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지)
47
+ # Traefik & preview domain settings (do not change after the environment is set up)
48
48
  TRAEFIK_NETWORK: traefik-network
49
49
  PREVIEW_DOMAIN_SUFFIX: "__PREVIEW_DOMAIN_SUFFIX__" # @wizard ask:pr.example.com
50
50
  PREVIEW_PORT: '8079'
51
51
 
52
- # SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem)
53
- # password → SERVER_PASSWORD secret 사용
54
- # key → SSH_KEY secret 사용 (.pem 파일 전체 내용을 secret 값으로)
52
+ # SSH auth method: "password" (Synology / regular servers) | "key" (AWS EC2 / .pem)
53
+ # password → uses the SERVER_PASSWORD secret
54
+ # key → uses the SSH_KEY secret (store the whole .pem contents as the secret value)
55
55
  SSH_AUTH_METHOD: "password" # @wizard ask:password
56
56
 
57
- # SSH 포트 (서버 포트 환경 구축 후 수정 금지)
57
+ # SSH port (do not change after the server port is set up)
58
58
  SSH_PORT: "__SSH_PORT__" # @wizard ask:2022
59
59
 
60
- # Issue Helper 댓글 마커 (프로젝트별 수정 필요)
61
- # Issue 댓글에서 브랜치명을 추출할 때 사용
60
+ # Issue Helper comment marker (edit per project)
61
+ # Used to extract the branch name from an issue comment
62
62
  ISSUE_HELPER_MARKER: '<!-- project-auto-wizard issue helper -->'
63
63
 
64
- # Health Check 설정 (프로젝트별 수정 필요)
65
- # - HEALTH_CHECK_PATH: HTTP Health Check 경로 (빈값이면 HTTP 체크 건너뜀)
66
- # - HEALTH_CHECK_LOG_PATTERN: 로그 패턴 매칭 (HTTP 실패 시 폴백)
67
- # - API_DOCS_PATH: API 문서 URL (빈값이면 배포 코멘트에 미표시)
64
+ # Health check settings (edit per project)
65
+ # - HEALTH_CHECK_PATH: HTTP health check path (empty skips the HTTP check)
66
+ # - HEALTH_CHECK_LOG_PATTERN: log pattern match (fallback when HTTP fails)
67
+ # - API_DOCS_PATH: API docs URL (empty hides it from the deploy comment)
68
68
  #
69
- # 프레임워크별 기본값 예시:
69
+ # Default examples per framework:
70
70
  # FastAPI: '/docs', 'Uvicorn running on|Application startup complete'
71
71
  # Spring Boot: '/actuator/health', 'Started.*Application|Tomcat started on port'
72
72
  # Express: '/health', 'Server listening on port'
@@ -76,11 +76,11 @@ env:
76
76
 
77
77
  on:
78
78
  issue_comment:
79
- types: [created] # PR 댓글 + Issue 댓글
79
+ types: [created] # PR comments + issue comments
80
80
  issues:
81
- types: [closed] # Issue 닫힘 시 destroy
81
+ types: [closed] # destroy when the issue is closed
82
82
  pull_request:
83
- types: [closed] # PR 닫힘 시 destroy
83
+ types: [closed] # destroy when the PR is closed
84
84
 
85
85
  permissions:
86
86
  contents: read
@@ -88,9 +88,9 @@ permissions:
88
88
  issues: write
89
89
 
90
90
  jobs:
91
- # Job 1: 명령어 파싱 (PR 댓글 + Issue 댓글 모두 지원)
91
+ # Job 1: parse the command (supports both PR and issue comments)
92
92
  check-command:
93
- name: 명령어 확인
93
+ name: Check command
94
94
  if: github.event_name == 'issue_comment'
95
95
  runs-on: ubuntu-latest
96
96
  outputs:
@@ -100,7 +100,28 @@ jobs:
100
100
  custom_branch: ${{ steps.parse.outputs.custom_branch }}
101
101
  is_custom_branch: ${{ steps.parse.outputs.is_custom_branch }}
102
102
  steps:
103
- - name: 댓글에 👀 리액션 추가
103
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
104
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
105
+ - name: Checkout messages
106
+ uses: actions/checkout@v7
107
+ with:
108
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
109
+ sparse-checkout: |
110
+ .github/scripts/messages.py
111
+ version.yml
112
+ sparse-checkout-cone-mode: false
113
+
114
+ - name: Load messages
115
+ run: |
116
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
117
+ mkdir -p "$RUNNER_TEMP/paw"
118
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
119
+ {
120
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
121
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
122
+ } >> "$GITHUB_ENV"
123
+
124
+ - name: Add 👀 reaction to comment
104
125
  if: contains(github.event.comment.body, '/wizard') && contains(github.event.comment.body, 'server')
105
126
  uses: actions/github-script@v9
106
127
  with:
@@ -113,31 +134,33 @@ jobs:
113
134
  content: 'eyes'
114
135
  });
115
136
 
116
- - name: 커맨드 파싱
137
+ - name: Parse command
117
138
  id: parse
118
139
  env:
119
140
  COMMENT: ${{ github.event.comment.body }}
120
141
  IS_PR: ${{ github.event.issue.pull_request != null }}
121
142
  run: |
143
+ m() { python3 "$RUNNER_TEMP/paw/messages.py" get "$@"; }
144
+
122
145
  if [[ "$IS_PR" == "true" ]]; then
123
146
  echo "is_pr=true" >> $GITHUB_OUTPUT
124
- echo "ℹ️ PR 댓글에서 명령어 감지"
147
+ m wf_preview.cmd_from_pr
125
148
  else
126
149
  echo "is_pr=false" >> $GITHUB_OUTPUT
127
- echo "ℹ️ Issue 댓글에서 명령어 감지"
150
+ m wf_preview.cmd_from_issue
128
151
  fi
129
152
 
130
- # 형식: /wizard server <command> [브랜치명]
153
+ # Format: /wizard server <command> [branch]
131
154
  if [[ "$COMMENT" =~ /wizard[[:space:]]+server[[:space:]]+build([[:space:]]+([^[:space:]]+))? ]]; then
132
155
  echo "command=build" >> $GITHUB_OUTPUT
133
156
  echo "is_valid=true" >> $GITHUB_OUTPUT
134
157
  if [[ -n "${BASH_REMATCH[2]}" ]]; then
135
158
  echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT
136
159
  echo "is_custom_branch=true" >> $GITHUB_OUTPUT
137
- echo "✅ 명령어 감지: build (브랜치: ${BASH_REMATCH[2]})"
160
+ m wf_preview.cmd_detected_build_branch branch="${BASH_REMATCH[2]}"
138
161
  else
139
162
  echo "is_custom_branch=false" >> $GITHUB_OUTPUT
140
- echo "✅ 명령어 감지: build"
163
+ m wf_preview.cmd_detected_build
141
164
  fi
142
165
  elif [[ "$COMMENT" =~ /wizard[[:space:]]+server[[:space:]]+destroy([[:space:]]+([^[:space:]]+))? ]]; then
143
166
  echo "command=destroy" >> $GITHUB_OUTPUT
@@ -145,10 +168,10 @@ jobs:
145
168
  if [[ -n "${BASH_REMATCH[2]}" ]]; then
146
169
  echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT
147
170
  echo "is_custom_branch=true" >> $GITHUB_OUTPUT
148
- echo "✅ 명령어 감지: destroy (브랜치: ${BASH_REMATCH[2]})"
171
+ m wf_preview.cmd_detected_destroy_branch branch="${BASH_REMATCH[2]}"
149
172
  else
150
173
  echo "is_custom_branch=false" >> $GITHUB_OUTPUT
151
- echo "✅ 명령어 감지: destroy"
174
+ m wf_preview.cmd_detected_destroy
152
175
  fi
153
176
  elif [[ "$COMMENT" =~ /wizard[[:space:]]+server[[:space:]]+status([[:space:]]+([^[:space:]]+))? ]]; then
154
177
  echo "command=status" >> $GITHUB_OUTPUT
@@ -156,19 +179,19 @@ jobs:
156
179
  if [[ -n "${BASH_REMATCH[2]}" ]]; then
157
180
  echo "custom_branch=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT
158
181
  echo "is_custom_branch=true" >> $GITHUB_OUTPUT
159
- echo "✅ 명령어 감지: status (브랜치: ${BASH_REMATCH[2]})"
182
+ m wf_preview.cmd_detected_status_branch branch="${BASH_REMATCH[2]}"
160
183
  else
161
184
  echo "is_custom_branch=false" >> $GITHUB_OUTPUT
162
- echo "✅ 명령어 감지: status"
185
+ m wf_preview.cmd_detected_status
163
186
  fi
164
187
  else
165
188
  echo "is_valid=false" >> $GITHUB_OUTPUT
166
- echo "ℹ️ /wizard server 명령어가 아님"
189
+ m wf_preview.cmd_not_wizard
167
190
  fi
168
191
 
169
- # Job 2-1: 빌드 & 배포 (PR 댓글에서 실행)
192
+ # Job 2-1: build & deploy (triggered from a PR comment)
170
193
  build-preview-pr:
171
- name: Preview 빌드 & 배포 (PR)
194
+ name: Preview build & deploy (PR)
172
195
  needs: check-command
173
196
  if: |
174
197
  needs.check-command.outputs.is_valid == 'true' &&
@@ -177,7 +200,51 @@ jobs:
177
200
  needs.check-command.outputs.is_custom_branch != 'true'
178
201
  runs-on: ubuntu-latest
179
202
  steps:
180
- - name: PR 정보 가져오기
203
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
204
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
205
+ - name: Checkout messages
206
+ uses: actions/checkout@v7
207
+ with:
208
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
209
+ sparse-checkout: |
210
+ .github/scripts/messages.py
211
+ version.yml
212
+ sparse-checkout-cone-mode: false
213
+
214
+ - name: Load messages
215
+ run: |
216
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
217
+ mkdir -p "$RUNNER_TEMP/paw"
218
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
219
+ {
220
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
221
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
222
+ } >> "$GITHUB_ENV"
223
+ # The deploy script runs on the remote server, where messages.py does not exist:
224
+ # ship the messages it needs as a small shell function (m KEY name=value ...)
225
+ python3 - >> "$GITHUB_ENV" <<'PY'
226
+ import json, os, shlex, subprocess
227
+ script = os.path.join(os.environ["RUNNER_TEMP"], "paw", "messages.py")
228
+ msgs = json.loads(subprocess.check_output(["python3", script, "dump", "wf_preview.r_"], text=True))
229
+ head = ["m() {", ' case "$1" in']
230
+ for key, text in msgs.items():
231
+ head.append(" %s) _t=%s ;;" % (key, shlex.quote(text)))
232
+ tail = r''' *) _t="$1" ;;
233
+ esac
234
+ shift
235
+ while [ "$#" -gt 0 ]; do
236
+ _v=$(printf '%s' "${1#*=}" | sed 's/[\\&|]/\\&/g')
237
+ _t=$(printf '%s' "$_t" | sed "s|{${1%%=*}}|$_v|g")
238
+ shift
239
+ done
240
+ printf '%s\n' "$_t"
241
+ }'''
242
+ print("PAW_REMOTE<<PAW_EOF")
243
+ print("\n".join(head) + "\n" + tail)
244
+ print("PAW_EOF")
245
+ PY
246
+
247
+ - name: Get PR info
181
248
  id: pr
182
249
  uses: actions/github-script@v9
183
250
  with:
@@ -191,28 +258,31 @@ jobs:
191
258
  core.setOutput('sha', pr.data.head.sha.substring(0, 7));
192
259
  return pr.data.number;
193
260
 
194
- # 진행 상황 댓글 시스템
195
- - name: 진행 상황 댓글 생성
261
+ # Progress comment system
262
+ - name: Create progress comment
196
263
  id: progress
197
264
  uses: actions/github-script@v9
198
265
  with:
199
266
  script: |
267
+ const M = JSON.parse(process.env.PAW_MSG);
268
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
269
+
200
270
  const prNumber = context.issue.number;
201
271
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
202
272
  const startTime = Date.now();
203
273
 
204
274
  const body = [
205
- '## 🚀 PR Preview 빌드 중...',
275
+ m('wf_preview.title_pr_building'),
206
276
  '',
207
- '| 단계 | 상태 | 소요 시간 |',
277
+ m('wf_preview.tbl_steps'),
208
278
  '|------|------|----------|',
209
- '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
210
- '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
279
+ m('wf_preview.row_docker_progress'),
280
+ m('wf_preview.row_deploy_wait'),
211
281
  '',
212
- `**[📋 실시간 로그 보기](${runUrl})**`,
282
+ m('wf_preview.link_logs', {runUrl}),
213
283
  '',
214
284
  '---',
215
- '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
285
+ m('wf_preview.footer_progress')
216
286
  ].join('\n');
217
287
 
218
288
  const { data: comment } = await github.rest.issues.createComment({
@@ -226,13 +296,13 @@ jobs:
226
296
  core.setOutput('start_time', startTime);
227
297
  core.setOutput('docker_start', startTime);
228
298
 
229
- - name: 코드 체크아웃
299
+ - name: Checkout code
230
300
  uses: actions/checkout@v7
231
301
  with:
232
302
  ref: ${{ steps.pr.outputs.ref }}
233
303
 
234
- # 필수 Secret·Dockerfile이 없으면 docker 로그인 같은 서드파티 오류 대신 빠진 항목을 알려 주고 멈춘다
235
- - name: 배포 사전 점검 (Secret·Dockerfile)
304
+ # If a required secret or the Dockerfile is missing, report the missing items and stop instead of surfacing a third-party error such as a docker login failure
305
+ - name: Deploy pre-check (Secrets and Dockerfile)
236
306
  working-directory: ${{ env.PROJECT_PATH }}
237
307
  env:
238
308
  DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -243,11 +313,13 @@ jobs:
243
313
  SSH_KEY: ${{ secrets.SSH_KEY }}
244
314
  DOCKERFILE: ${{ env.DOCKERFILE_PATH }}
245
315
  run: |
316
+ m() { python3 "$RUNNER_TEMP/paw/messages.py" get "$@"; }
317
+
246
318
  MISSING=""
247
319
  for NAME in DOCKERHUB_USERNAME DOCKERHUB_TOKEN SERVER_HOST SERVER_USER; do
248
320
  [ -n "${!NAME}" ] || MISSING="$MISSING $NAME"
249
321
  done
250
- # SSH_AUTH_METHOD=key면 SSH_KEY, 아니면 SERVER_PASSWORD가 필요하다
322
+ # SSH_KEY is required when SSH_AUTH_METHOD=key, otherwise SERVER_PASSWORD
251
323
  if [ "${SSH_AUTH_METHOD:-password}" = "key" ]; then
252
324
  [ -n "$SSH_KEY" ] || MISSING="$MISSING SSH_KEY"
253
325
  else
@@ -255,36 +327,36 @@ jobs:
255
327
  fi
256
328
  FAILED=false
257
329
  if [ -n "$MISSING" ]; then
258
- echo "::error title=필수 Secret 누락::등록되지 않은 GitHub Secret:$MISSING — Settings → Secrets and variables → Actions에서 등록하세요"
330
+ echo "::error title=$(m wf_preview.precheck_secret_title)::$(m wf_preview.precheck_secret_body missing="$MISSING")"
259
331
  FAILED=true
260
332
  fi
261
333
  if [ ! -f "$DOCKERFILE" ]; then
262
- echo "::error title=Dockerfile 없음::${PROJECT_PATH}/${DOCKERFILE#./} 파일이 없습니다. 이 워크플로우는 Docker 이미지로 배포하므로 Dockerfile이 필요합니다 (서버 배포를 하지 않는다면 이 워크플로우 파일을 삭제하세요)"
334
+ echo "::error title=$(m wf_preview.precheck_dockerfile_title)::$(m wf_preview.precheck_dockerfile_body path="${PROJECT_PATH}/${DOCKERFILE#./}")"
263
335
  FAILED=true
264
336
  fi
265
337
  if [ "$FAILED" = "true" ]; then
266
338
  exit 1
267
339
  fi
268
- echo "✅ 사전 점검 통과: 필수 Secret과 Dockerfile이 모두 있습니다"
340
+ m wf_preview.precheck_ok
269
341
 
270
- # 환경변수 파일 생성 (프로젝트별 수정 영역)
271
- - name: "[필수] .env 파일 생성"
342
+ # Create the environment file (project-specific section)
343
+ - name: "[Required] Create .env file"
272
344
  working-directory: ${{ env.PROJECT_PATH }}
273
345
  run: |
274
346
  cat << 'EOF' > .env
275
347
  ${{ secrets.ENV_FILE }}
276
348
  EOF
277
349
 
278
- - name: Docker 로그인
350
+ - name: Log in to Docker Hub
279
351
  uses: docker/login-action@v3
280
352
  with:
281
353
  username: ${{ secrets.DOCKERHUB_USERNAME }}
282
354
  password: ${{ secrets.DOCKERHUB_TOKEN }}
283
355
 
284
- - name: Docker Buildx 설정
356
+ - name: Set up Docker Buildx
285
357
  uses: docker/setup-buildx-action@v3
286
358
 
287
- - name: Docker 이미지 빌드 & Push
359
+ - name: Build & push Docker image
288
360
  uses: docker/build-push-action@v5
289
361
  with:
290
362
  context: ${{ env.PROJECT_PATH }}
@@ -294,32 +366,35 @@ jobs:
294
366
  cache-from: type=gha
295
367
  cache-to: type=gha,mode=max
296
368
 
297
- - name: 진행 상황 - Docker 완료
369
+ - name: Progress - Docker done
298
370
  id: docker_progress
299
371
  uses: actions/github-script@v9
300
372
  with:
301
373
  script: |
374
+ const M = JSON.parse(process.env.PAW_MSG);
375
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
376
+
302
377
  const commentId = ${{ steps.progress.outputs.comment_id }};
303
378
  const dockerStart = ${{ steps.progress.outputs.docker_start }};
304
379
  const now = Date.now();
305
380
  const dockerElapsed = now - dockerStart;
306
381
  const minutes = Math.floor(dockerElapsed / 60000);
307
382
  const seconds = Math.floor((dockerElapsed % 60000) / 1000);
308
- const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
383
+ const dockerDuration = minutes > 0 ? m('wf_preview.dur_min_sec', {minutes, seconds}) : m('wf_preview.dur_sec', {seconds});
309
384
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
310
385
 
311
386
  const body = [
312
- '## 🚀 PR Preview 빌드 중...',
387
+ m('wf_preview.title_pr_building'),
313
388
  '',
314
- '| 단계 | 상태 | 소요 시간 |',
389
+ m('wf_preview.tbl_steps'),
315
390
  '|------|------|----------|',
316
- `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
317
- '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
391
+ m('wf_preview.row_docker_done', {dockerDuration}),
392
+ m('wf_preview.row_deploy_progress'),
318
393
  '',
319
- `**[📋 실시간 로그 보기](${runUrl})**`,
394
+ m('wf_preview.link_logs', {runUrl}),
320
395
  '',
321
396
  '---',
322
- '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
397
+ m('wf_preview.footer_progress')
323
398
  ].join('\n');
324
399
 
325
400
  await github.rest.issues.updateComment({
@@ -332,7 +407,7 @@ jobs:
332
407
  core.setOutput('docker_duration', dockerDuration);
333
408
  core.setOutput('deploy_start', now);
334
409
 
335
- - name: 서버에 배포
410
+ - name: Deploy to server
336
411
  uses: appleboy/ssh-action@v1.0.3
337
412
  env:
338
413
  SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
@@ -344,19 +419,21 @@ jobs:
344
419
  port: ${{ env.SSH_PORT }}
345
420
  envs: SSH_AUTH_METHOD
346
421
  script: |
422
+ ${{ env.PAW_REMOTE }}
423
+
347
424
  set -e
348
425
 
349
426
  export PATH=$PATH:/usr/local/bin
350
427
  export PW="${{ secrets.SERVER_PASSWORD }}"
351
428
 
352
- # SSH 인증 방식에 따른 sudo 추상화
429
+ # sudo wrapper that depends on the SSH auth method
353
430
  SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
354
431
  if [ "${SSH_AUTH_METHOD}" = "key" ]; then
355
432
  SUDO() { sudo "$@"; }
356
433
  else
357
434
  SUDO() { echo "$PW" | sudo -S "$@"; }
358
435
  fi
359
- echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
436
+ m wf_preview.r_ssh_auth ssh_auth_method="${SSH_AUTH_METHOD}"
360
437
 
361
438
  PR_NUMBER=${{ github.event.issue.number }}
362
439
  PROJECT_NAME="${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}"
@@ -367,18 +444,18 @@ jobs:
367
444
  TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
368
445
 
369
446
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
370
- echo "🚀 PR Preview 배포 시작"
447
+ m wf_preview.r_deploy_head_pr
371
448
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
372
- echo "📦 프로젝트: ${PROJECT_NAME}"
373
- echo "🔢 PR 번호: #${PR_NUMBER}"
374
- echo "📛 컨테이너: ${CONTAINER_NAME}"
375
- echo "🌐 도메인: ${DOMAIN}"
449
+ m wf_preview.r_project_name project_name="${PROJECT_NAME}"
450
+ m wf_preview.r_num_pr pr_number="${PR_NUMBER}"
451
+ m wf_preview.r_container_name container_name="${CONTAINER_NAME}"
452
+ m wf_preview.r_domain domain="${DOMAIN}"
376
453
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
377
454
 
378
- echo "📥 Docker 이미지 Pull 중..."
455
+ m wf_preview.r_image_pulling
379
456
  SUDO docker pull "${IMAGE}"
380
457
 
381
- echo "🗑️ 기존 컨테이너 정리 중..."
458
+ m wf_preview.r_existing_cleanup
382
459
  SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
383
460
 
384
461
  VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro"
@@ -386,7 +463,7 @@ jobs:
386
463
  VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}"
387
464
  fi
388
465
 
389
- echo "🐳 새 컨테이너 실행 중..."
466
+ m wf_preview.r_container_starting
390
467
  SUDO docker run -d \
391
468
  --name "${CONTAINER_NAME}" \
392
469
  --network "${TRAEFIK_NETWORK}" \
@@ -399,11 +476,11 @@ jobs:
399
476
  $VOLUME_OPTS \
400
477
  "${IMAGE}"
401
478
 
402
- # Health Check (최대 120초 대기) - 하이브리드 방식
403
- # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도
404
- # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN)
479
+ # Health check (waits up to 120s) - hybrid approach
480
+ # 1. Try the HTTP check when HEALTH_CHECK_PATH is set
481
+ # 2. Fallback: log pattern match (HEALTH_CHECK_LOG_PATTERN)
405
482
  echo ""
406
- echo "⏳ Health Check 시작 (최대 120초 대기)..."
483
+ m wf_preview.r_hc_start
407
484
  HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
408
485
  LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
409
486
  MAX_RETRIES=24
@@ -415,13 +492,13 @@ jobs:
415
492
  sleep 5
416
493
  RETRY_COUNT=$((RETRY_COUNT + 1))
417
494
 
418
- # 1. 컨테이너 상태 확인
495
+ # 1. Check the container state
419
496
  STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
420
497
 
421
498
  if [ "$STATUS" = "exited" ]; then
422
- echo "❌ 컨테이너 비정상 종료!"
499
+ m wf_preview.r_container_exited
423
500
  echo ""
424
- echo "📋 컨테이너 로그 (최근 100줄):"
501
+ m wf_preview.r_container_logs
425
502
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
426
503
  SUDO docker logs --tail 100 "${CONTAINER_NAME}"
427
504
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -429,24 +506,24 @@ jobs:
429
506
  fi
430
507
 
431
508
  if [ "$STATUS" = "running" ]; then
432
- # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만)
509
+ # 2. Try the HTTP health check (only when HEALTH_CHECK_PATH is set)
433
510
  if [ -n "$HEALTH_PATH" ]; then
434
511
  HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
435
512
 
436
513
  if [ -n "$HEALTH" ]; then
437
- echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
514
+ m wf_preview.r_hc_ok_http health_path="${HEALTH_PATH}"
438
515
  HEALTH_CHECK_PASSED=true
439
516
  HEALTH_CHECK_METHOD="HTTP"
440
517
  break
441
518
  fi
442
519
  fi
443
520
 
444
- # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백
521
+ # 3. Without an HTTP response, fall back to log pattern matching
445
522
  if [ -n "$LOG_PATTERN" ]; then
446
523
  STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
447
524
 
448
525
  if [ -n "$STARTED" ]; then
449
- echo "✅ 정상 기동 확인! (로그 패턴)"
526
+ m wf_preview.r_hc_ok_log
450
527
  echo " $STARTED"
451
528
  HEALTH_CHECK_PASSED=true
452
529
  HEALTH_CHECK_METHOD="Log"
@@ -455,14 +532,14 @@ jobs:
455
532
  fi
456
533
  fi
457
534
 
458
- echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
535
+ m wf_preview.r_hc_waiting retry_count="$RETRY_COUNT" max_retries="$MAX_RETRIES" status="$STATUS"
459
536
  done
460
537
 
461
538
  if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
462
539
  echo ""
463
- echo "❌ Health Check 타임아웃 (120초)"
540
+ m wf_preview.r_hc_timeout
464
541
  echo ""
465
- echo "📋 컨테이너 로그 (최근 100줄):"
542
+ m wf_preview.r_container_logs
466
543
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
467
544
  SUDO docker logs --tail 100 "${CONTAINER_NAME}"
468
545
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -470,13 +547,16 @@ jobs:
470
547
  fi
471
548
 
472
549
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
473
- echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
550
+ m wf_preview.r_hc_done health_check_method="${HEALTH_CHECK_METHOD}"
474
551
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
475
552
 
476
- - name: 배포 완료 코멘트
553
+ - name: Deploy complete comment
477
554
  uses: actions/github-script@v9
478
555
  with:
479
556
  script: |
557
+ const M = JSON.parse(process.env.PAW_MSG);
558
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
559
+
480
560
  const commentId = ${{ steps.progress.outputs.comment_id }};
481
561
  const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
482
562
  const deployStart = ${{ steps.docker_progress.outputs.deploy_start }};
@@ -484,7 +564,7 @@ jobs:
484
564
  const deployElapsed = now - deployStart;
485
565
  const minutes = Math.floor(deployElapsed / 60000);
486
566
  const seconds = Math.floor((deployElapsed % 60000) / 1000);
487
- const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
567
+ const deployDuration = minutes > 0 ? m('wf_preview.dur_min_sec', {minutes, seconds}) : m('wf_preview.dur_sec', {seconds});
488
568
 
489
569
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
490
570
  const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
@@ -495,40 +575,40 @@ jobs:
495
575
  const previewUrl = `http://${domain}:${previewPort}`;
496
576
  const sha = '${{ steps.pr.outputs.sha }}';
497
577
 
498
- // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가)
578
+ // Build the preview environment table (the API Docs row is added only when API_DOCS_PATH is set)
499
579
  const envRows = [
500
580
  `| **Preview URL** | ${previewUrl} |`,
501
581
  ];
502
582
  if (apiDocsPath) {
503
583
  envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
504
584
  }
505
- envRows.push(`| **컨테이너** | \`${projectName}-pr-${prNumber}\` |`);
506
- envRows.push(`| **커밋** | \`${sha}\` |`);
585
+ envRows.push(m('wf_preview.env_container_pr', {projectName, prNumber}));
586
+ envRows.push(m('wf_preview.env_commit', {sha}));
507
587
 
508
588
  const branchName = '${{ steps.pr.outputs.ref }}';
509
589
 
510
590
  const body = [
511
- '## ✅ PR Preview 배포 완료!',
591
+ m('wf_preview.title_pr_deployed'),
512
592
  '',
513
- '| 단계 | 상태 | 소요 시간 |',
593
+ m('wf_preview.tbl_steps'),
514
594
  '|------|------|----------|',
515
- `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
516
- `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
595
+ m('wf_preview.row_docker_done', {dockerDuration}),
596
+ m('wf_preview.row_deploy_done', {deployDuration}),
517
597
  '',
518
- '### 🌐 Preview 환경',
519
- '| 항목 | 값 |',
598
+ m('wf_preview.sec_env'),
599
+ m('wf_preview.tbl_items'),
520
600
  '|------|-----|',
521
601
  ...envRows,
522
602
  '',
523
- '### 📋 명령어',
524
- '| 명령어 | 설명 |',
603
+ m('wf_preview.sec_commands'),
604
+ m('wf_preview.tbl_commands'),
525
605
  '|--------|------|',
526
- '| `/wizard server build` | 최신 커밋으로 재배포 |',
527
- '| `/wizard server destroy` | Preview 환경 삭제 |',
528
- '| `/wizard server status` | 현재 상태 확인 |',
606
+ m('wf_preview.cmd_build'),
607
+ m('wf_preview.cmd_destroy'),
608
+ m('wf_preview.cmd_status'),
529
609
  '',
530
610
  '<details>',
531
- '<summary>🔧 고급 명령어 (다른 Issue/PR에서 제어)</summary>',
611
+ m('wf_preview.cmd_advanced'),
532
612
  '',
533
613
  '```',
534
614
  `/wizard server build ${branchName}`,
@@ -538,7 +618,7 @@ jobs:
538
618
  '</details>',
539
619
  '',
540
620
  '---',
541
- '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*'
621
+ m('wf_preview.footer_pr')
542
622
  ].join('\n');
543
623
 
544
624
  await github.rest.issues.updateComment({
@@ -548,64 +628,67 @@ jobs:
548
628
  body: body
549
629
  });
550
630
 
551
- - name: 빌드/배포 실패 시 에러 코멘트
631
+ - name: Build/deploy failure comment
552
632
  if: failure()
553
633
  uses: actions/github-script@v9
554
634
  with:
555
635
  script: |
636
+ const M = JSON.parse(process.env.PAW_MSG);
637
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
638
+
556
639
  const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
557
640
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
558
641
  const prNumber = context.issue.number;
559
642
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
560
643
 
561
- // 진행 상황에 따라 어디서 실패했는지 표시
644
+ // Show where it failed based on progress
562
645
  const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
563
646
 
564
- // 실패 지점 판단 - 초기 단계 실패도 구분
647
+ // Work out where it failed - early-stage failures are distinguished too
565
648
  let dockerStatus, deployStatus;
566
649
  let dockerTime = '-';
567
650
 
568
651
  if (!commentId) {
569
- // 진행 상황 댓글 생성 전 실패 (초기화 단계)
570
- dockerStatus = '⚠️ 초기화 실패';
652
+ // Failed before the progress comment was created (initialization stage)
653
+ dockerStatus = m('wf_preview.status_init_failed');
571
654
  deployStatus = '-';
572
655
  } else if (!dockerDuration || dockerDuration === '') {
573
- // Docker 빌드 단계에서 실패
574
- dockerStatus = '❌ 실패';
575
- deployStatus = '⏸️ 대기';
656
+ // Failed at the Docker build stage
657
+ dockerStatus = m('wf_preview.status_failed');
658
+ deployStatus = m('wf_preview.status_waiting');
576
659
  } else {
577
- // 배포 단계에서 실패
578
- dockerStatus = '✅ 완료';
579
- deployStatus = '❌ 실패';
660
+ // Failed at the deploy stage
661
+ dockerStatus = m('wf_preview.status_done');
662
+ deployStatus = m('wf_preview.status_failed');
580
663
  dockerTime = dockerDuration;
581
664
  }
582
665
 
583
666
  const body = [
584
- '## ❌ PR Preview 배포 실패!',
667
+ m('wf_preview.title_pr_failed'),
585
668
  '',
586
- '| 단계 | 상태 | 소요 시간 |',
669
+ m('wf_preview.tbl_steps'),
587
670
  '|------|------|----------|',
588
- `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
589
- `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
671
+ m('wf_preview.row_docker_status', {dockerStatus, dockerTime}),
672
+ m('wf_preview.row_deploy_status', {deployStatus}),
590
673
  '',
591
- `**[📋 빌드/배포 로그 확인](${runUrl})**`,
674
+ m('wf_preview.link_build_logs', {runUrl}),
592
675
  '',
593
- '### 🔍 가능한 원인',
594
- '- 필수 Secret(DOCKERHUB_*, SERVER_*) 미등록 또는 Dockerfile 없음 → 로그의 "배포 사전 점검" 단계 확인',
595
- '- Docker 이미지 빌드 실패 (Python 의존성 문제)',
596
- '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
597
- '- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
598
- '- 환경변수 누락 (.env 파일 설정 확인)',
676
+ m('wf_preview.sec_causes'),
677
+ m('wf_preview.hint_secrets'),
678
+ m('wf_preview.hint_docker_python'),
679
+ m('wf_preview.hint_start_python'),
680
+ m('wf_preview.hint_health_timeout'),
681
+ m('wf_preview.hint_env_missing'),
599
682
  '',
600
- '### 💡 다음 단계',
601
- '1. 위 링크에서 빌드/배포 로그를 확인하세요',
602
- '2. 문제를 수정한 후 다시 시도하세요: `/wizard server build`',
683
+ m('wf_preview.sec_next'),
684
+ m('wf_preview.next_logs'),
685
+ m('wf_preview.next_retry'),
603
686
  '',
604
687
  '---',
605
- '*🤖 이 댓글은 PR Preview 시스템에 의해 자동 생성되었습니다.*'
688
+ m('wf_preview.footer_pr')
606
689
  ].join('\n');
607
690
 
608
- // 진행 상황 댓글이 있으면 업데이트, 없으면 새로 생성
691
+ // Update the progress comment if it exists, otherwise create a new one
609
692
  if (commentId) {
610
693
  await github.rest.issues.updateComment({
611
694
  owner: context.repo.owner,
@@ -622,9 +705,9 @@ jobs:
622
705
  });
623
706
  }
624
707
 
625
- # Job 2-2: Issue에서 브랜치명 추출
708
+ # Job 2-2: extract the branch name from the issue
626
709
  get-branch-from-issue:
627
- name: Issue에서 브랜치 추출
710
+ name: Extract branch from issue
628
711
  needs: check-command
629
712
  if: |
630
713
  needs.check-command.outputs.is_valid == 'true' &&
@@ -636,30 +719,54 @@ jobs:
636
719
  found: ${{ steps.extract.outputs.found }}
637
720
  issue_number: ${{ steps.extract.outputs.issue_number }}
638
721
  steps:
639
- - name: Issue 댓글에서 브랜치명 추출
722
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
723
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
724
+ - name: Checkout messages
725
+ uses: actions/checkout@v7
726
+ with:
727
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
728
+ sparse-checkout: |
729
+ .github/scripts/messages.py
730
+ version.yml
731
+ sparse-checkout-cone-mode: false
732
+
733
+ - name: Load messages
734
+ run: |
735
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
736
+ mkdir -p "$RUNNER_TEMP/paw"
737
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
738
+ {
739
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
740
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
741
+ } >> "$GITHUB_ENV"
742
+
743
+ - name: Extract branch name from issue comment
640
744
  id: extract
641
745
  uses: actions/github-script@v9
642
746
  with:
643
747
  script: |
748
+ const M = JSON.parse(process.env.PAW_MSG);
749
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
750
+
644
751
  const issueNumber = context.issue.number;
645
752
  const marker = '${{ env.ISSUE_HELPER_MARKER }}';
646
753
 
647
- console.log(`🔍 Issue #${issueNumber}에서 브랜치 검색 중...`);
648
- console.log(`📝 마커: ${marker}`);
754
+ console.log(m('wf_preview.log_search_branch', {issueNumber}));
755
+ console.log(m('wf_preview.log_marker', {marker}));
649
756
 
650
- // Issue의 모든 댓글 조회
757
+ // Fetch all comments of the issue
651
758
  const comments = await github.rest.issues.listComments({
652
759
  owner: context.repo.owner,
653
760
  repo: context.repo.repo,
654
761
  issue_number: issueNumber
655
762
  });
656
763
 
657
- // 브랜치 추출 패턴 (마커와 무관하게 동일한 형식)
658
- // "### 브랜치" 또는 "### 브랜치명" 둘 다 매칭
659
- const branchRegex = /###\s*브랜치(?:명)?\s*\n```\s*\n([^\n]+)\s*\n```/;
764
+ // Branch extraction pattern (same format regardless of the marker)
765
+ // Match the issue helper heading in either language: "### Branch name" (en) or the Korean "### \uBE0C\uB79C\uCE58(\uBA85)" (ko)
766
+ const branchRegex = /###\s*(?:\uBE0C\uB79C\uCE58(?:\uBA85)?|Branch(?: name)?)\s*\n```\s*\n([^\n]+)\s*\n```/i;
660
767
 
661
768
  for (const comment of comments.data) {
662
- // 마커가 포함된 댓글 찾기
769
+ // Find comments that contain the marker
663
770
  if (comment.body.includes(marker)) {
664
771
  const match = comment.body.match(branchRegex);
665
772
  if (match) {
@@ -667,40 +774,43 @@ jobs:
667
774
  core.setOutput('branch', branchName);
668
775
  core.setOutput('found', 'true');
669
776
  core.setOutput('issue_number', issueNumber);
670
- console.log(`✅ 브랜치 발견: ${branchName}`);
777
+ console.log(m('wf_preview.log_branch_found', {branchName}));
671
778
  return;
672
779
  }
673
780
  }
674
781
  }
675
782
 
676
- // 브랜치를 찾지 못한 경우 - 에러가 아님, graceful하게 처리
783
+ // Branch not found - not an error, handle it gracefully
677
784
  core.setOutput('found', 'false');
678
785
  core.setOutput('issue_number', issueNumber);
679
- console.log('⚠️ Issue Helper 댓글에서 브랜치를 찾을 수 없습니다.');
786
+ console.log(m('wf_preview.log_helper_no_branch'));
680
787
 
681
- - name: 브랜치 없음 알림
788
+ - name: Branch not found notice
682
789
  if: steps.extract.outputs.found == 'false'
683
790
  uses: actions/github-script@v9
684
791
  with:
685
792
  script: |
793
+ const M = JSON.parse(process.env.PAW_MSG);
794
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
795
+
686
796
  const issueNumber = context.issue.number;
687
797
  const marker = '${{ env.ISSUE_HELPER_MARKER }}';
688
798
 
689
799
  const body = [
690
- '## ⚠️ 브랜치를 찾을 수 없습니다',
800
+ m('wf_preview.title_branch_missing_warn'),
691
801
  '',
692
- '| 항목 | 값 |',
802
+ m('wf_preview.tbl_items'),
693
803
  '|------|-----|',
694
804
  `| **Issue** | #${issueNumber} |`,
695
- `| **마커** | \`${marker}\` |`,
805
+ m('wf_preview.env_marker', {marker}),
696
806
  '',
697
- '### 💡 확인 사항',
698
- '1. Issue Helper 댓글이 존재하는지 확인하세요',
699
- '2. 댓글에 `### 브랜치` 섹션이 있는지 확인하세요',
700
- '3. `ISSUE_HELPER_MARKER` 환경변수가 올바른지 확인하세요',
807
+ m('wf_preview.sec_check'),
808
+ m('wf_preview.chk_helper_exists'),
809
+ m('wf_preview.chk_helper_section'),
810
+ m('wf_preview.chk_helper_marker'),
701
811
  '',
702
812
  '---',
703
- '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
813
+ m('wf_preview.footer_generic')
704
814
  ].join('\n');
705
815
 
706
816
  await github.rest.issues.createComment({
@@ -710,9 +820,9 @@ jobs:
710
820
  body: body
711
821
  });
712
822
 
713
- # Job 2-3: 빌드 & 배포 (Issue 댓글에서 실행)
823
+ # Job 2-3: build & deploy (triggered from an issue comment)
714
824
  build-preview-issue:
715
- name: Preview 빌드 & 배포 (Issue)
825
+ name: Preview build & deploy (Issue)
716
826
  needs: [check-command, get-branch-from-issue]
717
827
  if: |
718
828
  needs.check-command.outputs.is_valid == 'true' &&
@@ -722,15 +832,62 @@ jobs:
722
832
  needs.get-branch-from-issue.outputs.found == 'true'
723
833
  runs-on: ubuntu-latest
724
834
  steps:
725
- - name: 브랜치 존재 확인
835
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
836
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
837
+ - name: Checkout messages
838
+ uses: actions/checkout@v7
839
+ with:
840
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
841
+ sparse-checkout: |
842
+ .github/scripts/messages.py
843
+ version.yml
844
+ sparse-checkout-cone-mode: false
845
+
846
+ - name: Load messages
847
+ run: |
848
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
849
+ mkdir -p "$RUNNER_TEMP/paw"
850
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
851
+ {
852
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
853
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
854
+ } >> "$GITHUB_ENV"
855
+ # The deploy script runs on the remote server, where messages.py does not exist:
856
+ # ship the messages it needs as a small shell function (m KEY name=value ...)
857
+ python3 - >> "$GITHUB_ENV" <<'PY'
858
+ import json, os, shlex, subprocess
859
+ script = os.path.join(os.environ["RUNNER_TEMP"], "paw", "messages.py")
860
+ msgs = json.loads(subprocess.check_output(["python3", script, "dump", "wf_preview.r_"], text=True))
861
+ head = ["m() {", ' case "$1" in']
862
+ for key, text in msgs.items():
863
+ head.append(" %s) _t=%s ;;" % (key, shlex.quote(text)))
864
+ tail = r''' *) _t="$1" ;;
865
+ esac
866
+ shift
867
+ while [ "$#" -gt 0 ]; do
868
+ _v=$(printf '%s' "${1#*=}" | sed 's/[\\&|]/\\&/g')
869
+ _t=$(printf '%s' "$_t" | sed "s|{${1%%=*}}|$_v|g")
870
+ shift
871
+ done
872
+ printf '%s\n' "$_t"
873
+ }'''
874
+ print("PAW_REMOTE<<PAW_EOF")
875
+ print("\n".join(head) + "\n" + tail)
876
+ print("PAW_EOF")
877
+ PY
878
+
879
+ - name: Check branch exists
726
880
  id: check_branch
727
881
  uses: actions/github-script@v9
728
882
  with:
729
883
  script: |
884
+ const M = JSON.parse(process.env.PAW_MSG);
885
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
886
+
730
887
  const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
731
888
  const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
732
889
 
733
- console.log(`🔍 브랜치 존재 확인: ${branchName}`);
890
+ console.log(m('wf_preview.log_check_exists', {branchName}));
734
891
 
735
892
  try {
736
893
  await github.rest.repos.getBranch({
@@ -739,28 +896,28 @@ jobs:
739
896
  branch: branchName
740
897
  });
741
898
  core.setOutput('exists', 'true');
742
- console.log(`✅ 브랜치 존재: ${branchName}`);
899
+ console.log(m('wf_preview.log_branch_exists', {branchName}));
743
900
  } catch (error) {
744
901
  if (error.status === 404) {
745
902
  core.setOutput('exists', 'false');
746
- console.log(`❌ 브랜치 없음: ${branchName}`);
903
+ console.log(m('wf_preview.log_branch_missing', {branchName}));
747
904
 
748
- // 브랜치 없음 알림 댓글
905
+ // Comment for a missing branch
749
906
  const body = [
750
- '## ❌ 브랜치를 찾을 수 없습니다',
907
+ m('wf_preview.title_branch_missing'),
751
908
  '',
752
- '| 항목 | 값 |',
909
+ m('wf_preview.tbl_items'),
753
910
  '|------|-----|',
754
911
  `| **Issue** | #${issueNumber} |`,
755
- `| **브랜치** | \`${branchName}\` |`,
912
+ m('wf_preview.env_branch', {branchName}),
756
913
  '',
757
- '### 💡 확인 사항',
758
- '1. 브랜치가 push되었는지 확인하세요',
759
- '2. 브랜치명이 정확한지 확인하세요',
760
- '3. 브랜치가 삭제되지 않았는지 확인하세요',
914
+ m('wf_preview.sec_check'),
915
+ m('wf_preview.chk_pushed_1'),
916
+ m('wf_preview.chk_name_2'),
917
+ m('wf_preview.chk_deleted_3'),
761
918
  '',
762
919
  '---',
763
- '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
920
+ m('wf_preview.footer_generic')
764
921
  ].join('\n');
765
922
 
766
923
  await github.rest.issues.createComment({
@@ -774,13 +931,15 @@ jobs:
774
931
  }
775
932
  }
776
933
 
777
- - name: 브랜치 없으면 중단
934
+ - name: Skip when branch is missing
778
935
  if: steps.check_branch.outputs.exists == 'false'
779
936
  run: |
780
- echo "⚠️ 브랜치가 존재하지 않아 빌드를 건너뜁니다."
937
+ m() { python3 "$RUNNER_TEMP/paw/messages.py" get "$@"; }
938
+
939
+ m wf_preview.skip_no_branch
781
940
  exit 0
782
941
 
783
- - name: Issue 정보 설정
942
+ - name: Set issue info
784
943
  if: steps.check_branch.outputs.exists == 'true'
785
944
  id: issue
786
945
  run: |
@@ -788,32 +947,35 @@ jobs:
788
947
  echo "issue_number=${{ needs.get-branch-from-issue.outputs.issue_number }}" >> $GITHUB_OUTPUT
789
948
  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
949
 
791
- # 진행 상황 댓글 시스템
792
- - name: 진행 상황 댓글 생성
950
+ # Progress comment system
951
+ - name: Create progress comment
793
952
  if: steps.check_branch.outputs.exists == 'true'
794
953
  id: progress
795
954
  uses: actions/github-script@v9
796
955
  with:
797
956
  script: |
957
+ const M = JSON.parse(process.env.PAW_MSG);
958
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
959
+
798
960
  const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
799
961
  const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
800
962
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
801
963
  const startTime = Date.now();
802
964
 
803
965
  const body = [
804
- '## 🚀 Issue Preview 빌드 중...',
966
+ m('wf_preview.title_issue_building'),
805
967
  '',
806
- `**브랜치**: \`${branchName}\``,
968
+ m('wf_preview.line_branch', {branchName}),
807
969
  '',
808
- '| 단계 | 상태 | 소요 시간 |',
970
+ m('wf_preview.tbl_steps'),
809
971
  '|------|------|----------|',
810
- '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
811
- '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
972
+ m('wf_preview.row_docker_progress'),
973
+ m('wf_preview.row_deploy_wait'),
812
974
  '',
813
- `**[📋 실시간 로그 보기](${runUrl})**`,
975
+ m('wf_preview.link_logs', {runUrl}),
814
976
  '',
815
977
  '---',
816
- '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
978
+ m('wf_preview.footer_progress')
817
979
  ].join('\n');
818
980
 
819
981
  const { data: comment } = await github.rest.issues.createComment({
@@ -827,14 +989,14 @@ jobs:
827
989
  core.setOutput('start_time', startTime);
828
990
  core.setOutput('docker_start', startTime);
829
991
 
830
- - name: 코드 체크아웃
992
+ - name: Checkout code
831
993
  if: steps.check_branch.outputs.exists == 'true'
832
994
  uses: actions/checkout@v7
833
995
  with:
834
996
  ref: ${{ needs.get-branch-from-issue.outputs.branch_name }}
835
997
 
836
- # 필수 Secret·Dockerfile이 없으면 docker 로그인 같은 서드파티 오류 대신 빠진 항목을 알려 주고 멈춘다
837
- - name: 배포 사전 점검 (Secret·Dockerfile)
998
+ # If a required secret or the Dockerfile is missing, report the missing items and stop instead of surfacing a third-party error such as a docker login failure
999
+ - name: Deploy pre-check (Secrets and Dockerfile)
838
1000
  if: steps.check_branch.outputs.exists == 'true'
839
1001
  working-directory: ${{ env.PROJECT_PATH }}
840
1002
  env:
@@ -846,11 +1008,13 @@ jobs:
846
1008
  SSH_KEY: ${{ secrets.SSH_KEY }}
847
1009
  DOCKERFILE: ${{ env.DOCKERFILE_PATH }}
848
1010
  run: |
1011
+ m() { python3 "$RUNNER_TEMP/paw/messages.py" get "$@"; }
1012
+
849
1013
  MISSING=""
850
1014
  for NAME in DOCKERHUB_USERNAME DOCKERHUB_TOKEN SERVER_HOST SERVER_USER; do
851
1015
  [ -n "${!NAME}" ] || MISSING="$MISSING $NAME"
852
1016
  done
853
- # SSH_AUTH_METHOD=key면 SSH_KEY, 아니면 SERVER_PASSWORD가 필요하다
1017
+ # SSH_KEY is required when SSH_AUTH_METHOD=key, otherwise SERVER_PASSWORD
854
1018
  if [ "${SSH_AUTH_METHOD:-password}" = "key" ]; then
855
1019
  [ -n "$SSH_KEY" ] || MISSING="$MISSING SSH_KEY"
856
1020
  else
@@ -858,20 +1022,20 @@ jobs:
858
1022
  fi
859
1023
  FAILED=false
860
1024
  if [ -n "$MISSING" ]; then
861
- echo "::error title=필수 Secret 누락::등록되지 않은 GitHub Secret:$MISSING — Settings → Secrets and variables → Actions에서 등록하세요"
1025
+ echo "::error title=$(m wf_preview.precheck_secret_title)::$(m wf_preview.precheck_secret_body missing="$MISSING")"
862
1026
  FAILED=true
863
1027
  fi
864
1028
  if [ ! -f "$DOCKERFILE" ]; then
865
- echo "::error title=Dockerfile 없음::${PROJECT_PATH}/${DOCKERFILE#./} 파일이 없습니다. 이 워크플로우는 Docker 이미지로 배포하므로 Dockerfile이 필요합니다 (서버 배포를 하지 않는다면 이 워크플로우 파일을 삭제하세요)"
1029
+ echo "::error title=$(m wf_preview.precheck_dockerfile_title)::$(m wf_preview.precheck_dockerfile_body path="${PROJECT_PATH}/${DOCKERFILE#./}")"
866
1030
  FAILED=true
867
1031
  fi
868
1032
  if [ "$FAILED" = "true" ]; then
869
1033
  exit 1
870
1034
  fi
871
- echo "✅ 사전 점검 통과: 필수 Secret과 Dockerfile이 모두 있습니다"
1035
+ m wf_preview.precheck_ok
872
1036
 
873
- # 환경변수 파일 생성 (프로젝트별 수정 영역)
874
- - name: "[필수] .env 파일 생성"
1037
+ # Create the environment file (project-specific section)
1038
+ - name: "[Required] Create .env file"
875
1039
  working-directory: ${{ env.PROJECT_PATH }}
876
1040
  if: steps.check_branch.outputs.exists == 'true'
877
1041
  run: |
@@ -879,18 +1043,18 @@ jobs:
879
1043
  ${{ secrets.ENV_FILE }}
880
1044
  EOF
881
1045
 
882
- - name: Docker 로그인
1046
+ - name: Log in to Docker Hub
883
1047
  if: steps.check_branch.outputs.exists == 'true'
884
1048
  uses: docker/login-action@v3
885
1049
  with:
886
1050
  username: ${{ secrets.DOCKERHUB_USERNAME }}
887
1051
  password: ${{ secrets.DOCKERHUB_TOKEN }}
888
1052
 
889
- - name: Docker Buildx 설정
1053
+ - name: Set up Docker Buildx
890
1054
  if: steps.check_branch.outputs.exists == 'true'
891
1055
  uses: docker/setup-buildx-action@v3
892
1056
 
893
- - name: Docker 이미지 빌드 & Push
1057
+ - name: Build & push Docker image
894
1058
  if: steps.check_branch.outputs.exists == 'true'
895
1059
  uses: docker/build-push-action@v5
896
1060
  with:
@@ -901,12 +1065,15 @@ jobs:
901
1065
  cache-from: type=gha
902
1066
  cache-to: type=gha,mode=max
903
1067
 
904
- - name: 진행 상황 - Docker 완료
1068
+ - name: Progress - Docker done
905
1069
  if: steps.check_branch.outputs.exists == 'true'
906
1070
  id: docker_progress
907
1071
  uses: actions/github-script@v9
908
1072
  with:
909
1073
  script: |
1074
+ const M = JSON.parse(process.env.PAW_MSG);
1075
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1076
+
910
1077
  const commentId = ${{ steps.progress.outputs.comment_id }};
911
1078
  const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
912
1079
  const dockerStart = ${{ steps.progress.outputs.docker_start }};
@@ -914,23 +1081,23 @@ jobs:
914
1081
  const dockerElapsed = now - dockerStart;
915
1082
  const minutes = Math.floor(dockerElapsed / 60000);
916
1083
  const seconds = Math.floor((dockerElapsed % 60000) / 1000);
917
- const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
1084
+ const dockerDuration = minutes > 0 ? m('wf_preview.dur_min_sec', {minutes, seconds}) : m('wf_preview.dur_sec', {seconds});
918
1085
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
919
1086
 
920
1087
  const body = [
921
- '## 🚀 Issue Preview 빌드 중...',
1088
+ m('wf_preview.title_issue_building'),
922
1089
  '',
923
- `**브랜치**: \`${branchName}\``,
1090
+ m('wf_preview.line_branch', {branchName}),
924
1091
  '',
925
- '| 단계 | 상태 | 소요 시간 |',
1092
+ m('wf_preview.tbl_steps'),
926
1093
  '|------|------|----------|',
927
- `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
928
- '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
1094
+ m('wf_preview.row_docker_done', {dockerDuration}),
1095
+ m('wf_preview.row_deploy_progress'),
929
1096
  '',
930
- `**[📋 실시간 로그 보기](${runUrl})**`,
1097
+ m('wf_preview.link_logs', {runUrl}),
931
1098
  '',
932
1099
  '---',
933
- '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
1100
+ m('wf_preview.footer_progress')
934
1101
  ].join('\n');
935
1102
 
936
1103
  await github.rest.issues.updateComment({
@@ -943,7 +1110,7 @@ jobs:
943
1110
  core.setOutput('docker_duration', dockerDuration);
944
1111
  core.setOutput('deploy_start', now);
945
1112
 
946
- - name: 서버에 배포
1113
+ - name: Deploy to server
947
1114
  if: steps.check_branch.outputs.exists == 'true'
948
1115
  uses: appleboy/ssh-action@v1.0.3
949
1116
  env:
@@ -956,19 +1123,21 @@ jobs:
956
1123
  port: ${{ env.SSH_PORT }}
957
1124
  envs: SSH_AUTH_METHOD
958
1125
  script: |
1126
+ ${{ env.PAW_REMOTE }}
1127
+
959
1128
  set -e
960
1129
 
961
1130
  export PATH=$PATH:/usr/local/bin
962
1131
  export PW="${{ secrets.SERVER_PASSWORD }}"
963
1132
 
964
- # SSH 인증 방식에 따른 sudo 추상화
1133
+ # sudo wrapper that depends on the SSH auth method
965
1134
  SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
966
1135
  if [ "${SSH_AUTH_METHOD}" = "key" ]; then
967
1136
  SUDO() { sudo "$@"; }
968
1137
  else
969
1138
  SUDO() { echo "$PW" | sudo -S "$@"; }
970
1139
  fi
971
- echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
1140
+ m wf_preview.r_ssh_auth ssh_auth_method="${SSH_AUTH_METHOD}"
972
1141
 
973
1142
  ISSUE_NUMBER=${{ needs.get-branch-from-issue.outputs.issue_number }}
974
1143
  PROJECT_NAME="${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}"
@@ -979,18 +1148,18 @@ jobs:
979
1148
  TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
980
1149
 
981
1150
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
982
- echo "🚀 Issue Preview 배포 시작"
1151
+ m wf_preview.r_deploy_head_issue
983
1152
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
984
- echo "📦 프로젝트: ${PROJECT_NAME}"
985
- echo "🔢 Issue 번호: #${ISSUE_NUMBER}"
986
- echo "📛 컨테이너: ${CONTAINER_NAME}"
987
- echo "🌐 도메인: ${DOMAIN}"
1153
+ m wf_preview.r_project_name project_name="${PROJECT_NAME}"
1154
+ m wf_preview.r_num_issue issue_number="${ISSUE_NUMBER}"
1155
+ m wf_preview.r_container_name container_name="${CONTAINER_NAME}"
1156
+ m wf_preview.r_domain domain="${DOMAIN}"
988
1157
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
989
1158
 
990
- echo "📥 Docker 이미지 Pull 중..."
1159
+ m wf_preview.r_image_pulling
991
1160
  SUDO docker pull "${IMAGE}"
992
1161
 
993
- echo "🗑️ 기존 컨테이너 정리 중..."
1162
+ m wf_preview.r_existing_cleanup
994
1163
  SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
995
1164
 
996
1165
  VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro"
@@ -998,7 +1167,7 @@ jobs:
998
1167
  VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}"
999
1168
  fi
1000
1169
 
1001
- echo "🐳 새 컨테이너 실행 중..."
1170
+ m wf_preview.r_container_starting
1002
1171
  SUDO docker run -d \
1003
1172
  --name "${CONTAINER_NAME}" \
1004
1173
  --network "${TRAEFIK_NETWORK}" \
@@ -1011,11 +1180,11 @@ jobs:
1011
1180
  $VOLUME_OPTS \
1012
1181
  "${IMAGE}"
1013
1182
 
1014
- # Health Check (최대 120초 대기) - 하이브리드 방식
1015
- # 1. HEALTH_CHECK_PATH가 설정되어 있으면 HTTP 체크 시도
1016
- # 2. 폴백: 로그 패턴 매칭 (HEALTH_CHECK_LOG_PATTERN)
1183
+ # Health check (waits up to 120s) - hybrid approach
1184
+ # 1. Try the HTTP check when HEALTH_CHECK_PATH is set
1185
+ # 2. Fallback: log pattern match (HEALTH_CHECK_LOG_PATTERN)
1017
1186
  echo ""
1018
- echo "⏳ Health Check 시작 (최대 120초 대기)..."
1187
+ m wf_preview.r_hc_start
1019
1188
  HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
1020
1189
  LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
1021
1190
  MAX_RETRIES=24
@@ -1027,13 +1196,13 @@ jobs:
1027
1196
  sleep 5
1028
1197
  RETRY_COUNT=$((RETRY_COUNT + 1))
1029
1198
 
1030
- # 1. 컨테이너 상태 확인
1199
+ # 1. Check the container state
1031
1200
  STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
1032
1201
 
1033
1202
  if [ "$STATUS" = "exited" ]; then
1034
- echo "❌ 컨테이너 비정상 종료!"
1203
+ m wf_preview.r_container_exited
1035
1204
  echo ""
1036
- echo "📋 컨테이너 로그 (최근 100줄):"
1205
+ m wf_preview.r_container_logs
1037
1206
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1038
1207
  SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1039
1208
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -1041,24 +1210,24 @@ jobs:
1041
1210
  fi
1042
1211
 
1043
1212
  if [ "$STATUS" = "running" ]; then
1044
- # 2. HTTP Health Check 시도 (HEALTH_CHECK_PATH가 설정된 경우에만)
1213
+ # 2. Try the HTTP health check (only when HEALTH_CHECK_PATH is set)
1045
1214
  if [ -n "$HEALTH_PATH" ]; then
1046
1215
  HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
1047
1216
 
1048
1217
  if [ -n "$HEALTH" ]; then
1049
- echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
1218
+ m wf_preview.r_hc_ok_http health_path="${HEALTH_PATH}"
1050
1219
  HEALTH_CHECK_PASSED=true
1051
1220
  HEALTH_CHECK_METHOD="HTTP"
1052
1221
  break
1053
1222
  fi
1054
1223
  fi
1055
1224
 
1056
- # 3. HTTP 응답 없으면 로그 패턴 매칭으로 폴백
1225
+ # 3. Without an HTTP response, fall back to log pattern matching
1057
1226
  if [ -n "$LOG_PATTERN" ]; then
1058
1227
  STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
1059
1228
 
1060
1229
  if [ -n "$STARTED" ]; then
1061
- echo "✅ 정상 기동 확인! (로그 패턴)"
1230
+ m wf_preview.r_hc_ok_log
1062
1231
  echo " $STARTED"
1063
1232
  HEALTH_CHECK_PASSED=true
1064
1233
  HEALTH_CHECK_METHOD="Log"
@@ -1067,14 +1236,14 @@ jobs:
1067
1236
  fi
1068
1237
  fi
1069
1238
 
1070
- echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
1239
+ m wf_preview.r_hc_waiting retry_count="$RETRY_COUNT" max_retries="$MAX_RETRIES" status="$STATUS"
1071
1240
  done
1072
1241
 
1073
1242
  if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
1074
1243
  echo ""
1075
- echo "❌ Health Check 타임아웃 (120초)"
1244
+ m wf_preview.r_hc_timeout
1076
1245
  echo ""
1077
- echo "📋 컨테이너 로그 (최근 100줄):"
1246
+ m wf_preview.r_container_logs
1078
1247
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1079
1248
  SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1080
1249
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -1082,14 +1251,17 @@ jobs:
1082
1251
  fi
1083
1252
 
1084
1253
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1085
- echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
1254
+ m wf_preview.r_hc_done health_check_method="${HEALTH_CHECK_METHOD}"
1086
1255
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1087
1256
 
1088
- - name: 배포 완료 코멘트
1257
+ - name: Deploy complete comment
1089
1258
  if: steps.check_branch.outputs.exists == 'true'
1090
1259
  uses: actions/github-script@v9
1091
1260
  with:
1092
1261
  script: |
1262
+ const M = JSON.parse(process.env.PAW_MSG);
1263
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1264
+
1093
1265
  const commentId = ${{ steps.progress.outputs.comment_id }};
1094
1266
  const branchName = '${{ needs.get-branch-from-issue.outputs.branch_name }}';
1095
1267
  const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
@@ -1098,7 +1270,7 @@ jobs:
1098
1270
  const deployElapsed = now - deployStart;
1099
1271
  const minutes = Math.floor(deployElapsed / 60000);
1100
1272
  const seconds = Math.floor((deployElapsed % 60000) / 1000);
1101
- const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
1273
+ const deployDuration = minutes > 0 ? m('wf_preview.dur_min_sec', {minutes, seconds}) : m('wf_preview.dur_sec', {seconds});
1102
1274
 
1103
1275
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
1104
1276
  const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
@@ -1109,41 +1281,41 @@ jobs:
1109
1281
  const previewUrl = `http://${domain}:${previewPort}`;
1110
1282
  const sha = '${{ steps.issue.outputs.sha }}';
1111
1283
 
1112
- // Preview 환경 테이블 구성 (API_DOCS_PATH가 있을 때만 API Docs 행 추가)
1284
+ // Build the preview environment table (the API Docs row is added only when API_DOCS_PATH is set)
1113
1285
  const envRows = [
1114
1286
  `| **Preview URL** | ${previewUrl} |`,
1115
1287
  ];
1116
1288
  if (apiDocsPath) {
1117
1289
  envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
1118
1290
  }
1119
- envRows.push(`| **컨테이너** | \`${projectName}-pr-${issueNumber}\` |`);
1120
- envRows.push(`| **브랜치** | \`${branchName}\` |`);
1121
- envRows.push(`| **커밋** | \`${sha}\` |`);
1291
+ envRows.push(m('wf_preview.env_container_issue', {projectName, issueNumber}));
1292
+ envRows.push(m('wf_preview.env_branch', {branchName}));
1293
+ envRows.push(m('wf_preview.env_commit', {sha}));
1122
1294
 
1123
1295
  const body = [
1124
- '## ✅ Issue Preview 배포 완료!',
1296
+ m('wf_preview.title_issue_deployed'),
1125
1297
  '',
1126
- `**브랜치**: \`${branchName}\``,
1298
+ m('wf_preview.line_branch', {branchName}),
1127
1299
  '',
1128
- '| 단계 | 상태 | 소요 시간 |',
1300
+ m('wf_preview.tbl_steps'),
1129
1301
  '|------|------|----------|',
1130
- `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
1131
- `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
1302
+ m('wf_preview.row_docker_done', {dockerDuration}),
1303
+ m('wf_preview.row_deploy_done', {deployDuration}),
1132
1304
  '',
1133
- '### 🌐 Preview 환경',
1134
- '| 항목 | 값 |',
1305
+ m('wf_preview.sec_env'),
1306
+ m('wf_preview.tbl_items'),
1135
1307
  '|------|-----|',
1136
1308
  ...envRows,
1137
1309
  '',
1138
- '### 📋 명령어',
1139
- '| 명령어 | 설명 |',
1310
+ m('wf_preview.sec_commands'),
1311
+ m('wf_preview.tbl_commands'),
1140
1312
  '|--------|------|',
1141
- '| `/wizard server build` | 최신 커밋으로 재배포 |',
1142
- '| `/wizard server destroy` | Preview 환경 삭제 |',
1143
- '| `/wizard server status` | 현재 상태 확인 |',
1313
+ m('wf_preview.cmd_build'),
1314
+ m('wf_preview.cmd_destroy'),
1315
+ m('wf_preview.cmd_status'),
1144
1316
  '',
1145
1317
  '<details>',
1146
- '<summary>🔧 고급 명령어 (다른 Issue/PR에서 제어)</summary>',
1318
+ m('wf_preview.cmd_advanced'),
1147
1319
  '',
1148
1320
  '```',
1149
1321
  `/wizard server build ${branchName}`,
@@ -1153,7 +1325,7 @@ jobs:
1153
1325
  '</details>',
1154
1326
  '',
1155
1327
  '---',
1156
- '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*'
1328
+ m('wf_preview.footer_issue')
1157
1329
  ].join('\n');
1158
1330
 
1159
1331
  await github.rest.issues.updateComment({
@@ -1163,11 +1335,14 @@ jobs:
1163
1335
  body: body
1164
1336
  });
1165
1337
 
1166
- - name: 빌드/배포 실패 시 에러 코멘트
1338
+ - name: Build/deploy failure comment
1167
1339
  if: failure() && steps.check_branch.outputs.exists == 'true'
1168
1340
  uses: actions/github-script@v9
1169
1341
  with:
1170
1342
  script: |
1343
+ const M = JSON.parse(process.env.PAW_MSG);
1344
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1345
+
1171
1346
  const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
1172
1347
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
1173
1348
  const issueNumber = ${{ needs.get-branch-from-issue.outputs.issue_number }};
@@ -1180,42 +1355,42 @@ jobs:
1180
1355
  let dockerTime = '-';
1181
1356
 
1182
1357
  if (!commentId) {
1183
- dockerStatus = '⚠️ 초기화 실패';
1358
+ dockerStatus = m('wf_preview.status_init_failed');
1184
1359
  deployStatus = '-';
1185
1360
  } else if (!dockerDuration || dockerDuration === '') {
1186
- dockerStatus = '❌ 실패';
1187
- deployStatus = '⏸️ 대기';
1361
+ dockerStatus = m('wf_preview.status_failed');
1362
+ deployStatus = m('wf_preview.status_waiting');
1188
1363
  } else {
1189
- dockerStatus = '✅ 완료';
1190
- deployStatus = '❌ 실패';
1364
+ dockerStatus = m('wf_preview.status_done');
1365
+ deployStatus = m('wf_preview.status_failed');
1191
1366
  dockerTime = dockerDuration;
1192
1367
  }
1193
1368
 
1194
1369
  const body = [
1195
- '## ❌ Issue Preview 배포 실패!',
1370
+ m('wf_preview.title_issue_failed'),
1196
1371
  '',
1197
- `**브랜치**: \`${branchName}\``,
1372
+ m('wf_preview.line_branch', {branchName}),
1198
1373
  '',
1199
- '| 단계 | 상태 | 소요 시간 |',
1374
+ m('wf_preview.tbl_steps'),
1200
1375
  '|------|------|----------|',
1201
- `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
1202
- `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
1376
+ m('wf_preview.row_docker_status', {dockerStatus, dockerTime}),
1377
+ m('wf_preview.row_deploy_status', {deployStatus}),
1203
1378
  '',
1204
- `**[📋 빌드/배포 로그 확인](${runUrl})**`,
1379
+ m('wf_preview.link_build_logs', {runUrl}),
1205
1380
  '',
1206
- '### 🔍 가능한 원인',
1207
- '- 필수 Secret(DOCKERHUB_*, SERVER_*) 미등록 또는 Dockerfile 없음 → 로그의 "배포 사전 점검" 단계 확인',
1208
- '- Docker 이미지 빌드 실패 (Python 의존성 문제)',
1209
- '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
1210
- '- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
1211
- '- 환경변수 누락 (.env 파일 설정 확인)',
1381
+ m('wf_preview.sec_causes'),
1382
+ m('wf_preview.hint_secrets'),
1383
+ m('wf_preview.hint_docker_python'),
1384
+ m('wf_preview.hint_start_python'),
1385
+ m('wf_preview.hint_health_timeout'),
1386
+ m('wf_preview.hint_env_missing'),
1212
1387
  '',
1213
- '### 💡 다음 단계',
1214
- '1. 위 링크에서 빌드/배포 로그를 확인하세요',
1215
- '2. 문제를 수정한 후 다시 시도하세요: `/wizard server build`',
1388
+ m('wf_preview.sec_next'),
1389
+ m('wf_preview.next_logs'),
1390
+ m('wf_preview.next_retry'),
1216
1391
  '',
1217
1392
  '---',
1218
- '*🤖 이 댓글은 Issue Preview 시스템에 의해 자동 생성되었습니다.*'
1393
+ m('wf_preview.footer_issue')
1219
1394
  ].join('\n');
1220
1395
 
1221
1396
  if (commentId) {
@@ -1234,17 +1409,61 @@ jobs:
1234
1409
  });
1235
1410
  }
1236
1411
 
1237
- # Job 3: Preview 삭제 (PR/Issue 닫힘 또는 destroy 명령)
1412
+ # Job 3: destroy the preview (PR/issue closed or destroy command)
1238
1413
  destroy-preview:
1239
- name: Preview 삭제
1240
- # needs 의존성 제거: PR/Issue 닫힘 이벤트에서도 독립적으로 실행되도록 함
1414
+ name: Destroy preview
1415
+ # No needs dependency: lets this run on its own for PR/issue closed events
1241
1416
  if: |
1242
1417
  (github.event_name == 'pull_request' && github.event.action == 'closed') ||
1243
1418
  (github.event_name == 'issues' && github.event.action == 'closed') ||
1244
1419
  (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/wizard') && contains(github.event.comment.body, 'server destroy'))
1245
1420
  runs-on: ubuntu-latest
1246
1421
  steps:
1247
- - name: PR/Issue 번호 가져오기
1422
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
1423
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
1424
+ - name: Checkout messages
1425
+ uses: actions/checkout@v7
1426
+ with:
1427
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
1428
+ sparse-checkout: |
1429
+ .github/scripts/messages.py
1430
+ version.yml
1431
+ sparse-checkout-cone-mode: false
1432
+
1433
+ - name: Load messages
1434
+ run: |
1435
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
1436
+ mkdir -p "$RUNNER_TEMP/paw"
1437
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
1438
+ {
1439
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
1440
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
1441
+ } >> "$GITHUB_ENV"
1442
+ # The deploy script runs on the remote server, where messages.py does not exist:
1443
+ # ship the messages it needs as a small shell function (m KEY name=value ...)
1444
+ python3 - >> "$GITHUB_ENV" <<'PY'
1445
+ import json, os, shlex, subprocess
1446
+ script = os.path.join(os.environ["RUNNER_TEMP"], "paw", "messages.py")
1447
+ msgs = json.loads(subprocess.check_output(["python3", script, "dump", "wf_preview.r_"], text=True))
1448
+ head = ["m() {", ' case "$1" in']
1449
+ for key, text in msgs.items():
1450
+ head.append(" %s) _t=%s ;;" % (key, shlex.quote(text)))
1451
+ tail = r''' *) _t="$1" ;;
1452
+ esac
1453
+ shift
1454
+ while [ "$#" -gt 0 ]; do
1455
+ _v=$(printf '%s' "${1#*=}" | sed 's/[\\&|]/\\&/g')
1456
+ _t=$(printf '%s' "$_t" | sed "s|{${1%%=*}}|$_v|g")
1457
+ shift
1458
+ done
1459
+ printf '%s\n' "$_t"
1460
+ }'''
1461
+ print("PAW_REMOTE<<PAW_EOF")
1462
+ print("\n".join(head) + "\n" + tail)
1463
+ print("PAW_EOF")
1464
+ PY
1465
+
1466
+ - name: Get PR/issue number
1248
1467
  id: pr_number
1249
1468
  run: |
1250
1469
  if [[ "${{ github.event_name }}" == "issue_comment" ]]; then
@@ -1258,7 +1477,7 @@ jobs:
1258
1477
  echo "type=issue_closed" >> $GITHUB_OUTPUT
1259
1478
  fi
1260
1479
 
1261
- - name: 컨테이너 & 이미지 삭제
1480
+ - name: Remove container & image
1262
1481
  uses: appleboy/ssh-action@v1.0.3
1263
1482
  env:
1264
1483
  SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
@@ -1270,17 +1489,19 @@ jobs:
1270
1489
  port: ${{ env.SSH_PORT }}
1271
1490
  envs: SSH_AUTH_METHOD
1272
1491
  script: |
1492
+ ${{ env.PAW_REMOTE }}
1493
+
1273
1494
  export PATH=$PATH:/usr/local/bin
1274
1495
  export PW="${{ secrets.SERVER_PASSWORD }}"
1275
1496
 
1276
- # SSH 인증 방식에 따른 sudo 추상화
1497
+ # sudo wrapper that depends on the SSH auth method
1277
1498
  SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
1278
1499
  if [ "${SSH_AUTH_METHOD}" = "key" ]; then
1279
1500
  SUDO() { sudo "$@"; }
1280
1501
  else
1281
1502
  SUDO() { echo "$PW" | sudo -S "$@"; }
1282
1503
  fi
1283
- echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
1504
+ m wf_preview.r_ssh_auth ssh_auth_method="${SSH_AUTH_METHOD}"
1284
1505
 
1285
1506
  PR_NUMBER=${{ steps.pr_number.outputs.number }}
1286
1507
  PROJECT_NAME="${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}"
@@ -1288,21 +1509,21 @@ jobs:
1288
1509
  IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:pr-${PR_NUMBER}"
1289
1510
 
1290
1511
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1291
- echo "🗑️ PR Preview 삭제"
1512
+ m wf_preview.r_destroy_head_pr
1292
1513
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1293
- echo "📦 프로젝트: ${PROJECT_NAME}"
1294
- echo "🔢 PR 번호: #${PR_NUMBER}"
1514
+ m wf_preview.r_project_name project_name="${PROJECT_NAME}"
1515
+ m wf_preview.r_num_pr pr_number="${PR_NUMBER}"
1295
1516
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1296
1517
 
1297
- echo "🐳 컨테이너 삭제 중..."
1518
+ m wf_preview.r_container_removing
1298
1519
  SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
1299
1520
 
1300
- echo "🖼️ 이미지 삭제 중..."
1521
+ m wf_preview.r_image_removing
1301
1522
  SUDO docker rmi "${IMAGE}" 2>/dev/null || true
1302
1523
 
1303
- echo "✅ 삭제 완료!"
1524
+ m wf_preview.r_removed
1304
1525
 
1305
- - name: 삭제 완료 코멘트
1526
+ - name: Destroy complete comment
1306
1527
  if: |
1307
1528
  github.event_name == 'issue_comment' ||
1308
1529
  (github.event_name == 'pull_request' && github.event.action == 'closed') ||
@@ -1310,21 +1531,24 @@ jobs:
1310
1531
  uses: actions/github-script@v9
1311
1532
  with:
1312
1533
  script: |
1534
+ const M = JSON.parse(process.env.PAW_MSG);
1535
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1536
+
1313
1537
  const number = ${{ steps.pr_number.outputs.number }};
1314
1538
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
1315
1539
 
1316
1540
  const body = [
1317
- '## 🗑️ Preview 환경 삭제 완료!',
1541
+ m('wf_preview.title_destroyed'),
1318
1542
  '',
1319
- '| 항목 | 값 |',
1543
+ m('wf_preview.tbl_items'),
1320
1544
  '|------|-----|',
1321
- `| **컨테이너** | \`${projectName}-pr-${number}\` |`,
1322
- '| **상태** | 삭제됨 |',
1545
+ m('wf_preview.env_container_num', {projectName, number}),
1546
+ m('wf_preview.env_status_deleted'),
1323
1547
  '',
1324
- '다시 배포하려면: `/wizard server build`',
1548
+ m('wf_preview.redeploy_hint'),
1325
1549
  '',
1326
1550
  '---',
1327
- '*🤖 이 댓글은 PR/Issue Preview 시스템에 의해 자동 생성되었습니다.*'
1551
+ m('wf_preview.footer_pr_issue')
1328
1552
  ].join('\n');
1329
1553
 
1330
1554
  await github.rest.issues.createComment({
@@ -1334,14 +1558,58 @@ jobs:
1334
1558
  body: body
1335
1559
  });
1336
1560
 
1337
- # Job 4: 상태 확인 (PR/Issue 모두 지원)
1561
+ # Job 4: status check (PR and issue)
1338
1562
  check-status:
1339
- name: Preview 상태 확인
1563
+ name: Preview status
1340
1564
  needs: check-command
1341
1565
  if: needs.check-command.outputs.is_valid == 'true' && needs.check-command.outputs.command == 'status'
1342
1566
  runs-on: ubuntu-latest
1343
1567
  steps:
1344
- - name: 컨테이너 상태 확인
1568
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
1569
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
1570
+ - name: Checkout messages
1571
+ uses: actions/checkout@v7
1572
+ with:
1573
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
1574
+ sparse-checkout: |
1575
+ .github/scripts/messages.py
1576
+ version.yml
1577
+ sparse-checkout-cone-mode: false
1578
+
1579
+ - name: Load messages
1580
+ run: |
1581
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
1582
+ mkdir -p "$RUNNER_TEMP/paw"
1583
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
1584
+ {
1585
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
1586
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
1587
+ } >> "$GITHUB_ENV"
1588
+ # The deploy script runs on the remote server, where messages.py does not exist:
1589
+ # ship the messages it needs as a small shell function (m KEY name=value ...)
1590
+ python3 - >> "$GITHUB_ENV" <<'PY'
1591
+ import json, os, shlex, subprocess
1592
+ script = os.path.join(os.environ["RUNNER_TEMP"], "paw", "messages.py")
1593
+ msgs = json.loads(subprocess.check_output(["python3", script, "dump", "wf_preview.r_"], text=True))
1594
+ head = ["m() {", ' case "$1" in']
1595
+ for key, text in msgs.items():
1596
+ head.append(" %s) _t=%s ;;" % (key, shlex.quote(text)))
1597
+ tail = r''' *) _t="$1" ;;
1598
+ esac
1599
+ shift
1600
+ while [ "$#" -gt 0 ]; do
1601
+ _v=$(printf '%s' "${1#*=}" | sed 's/[\\&|]/\\&/g')
1602
+ _t=$(printf '%s' "$_t" | sed "s|{${1%%=*}}|$_v|g")
1603
+ shift
1604
+ done
1605
+ printf '%s\n' "$_t"
1606
+ }'''
1607
+ print("PAW_REMOTE<<PAW_EOF")
1608
+ print("\n".join(head) + "\n" + tail)
1609
+ print("PAW_EOF")
1610
+ PY
1611
+
1612
+ - name: Check container status
1345
1613
  id: status
1346
1614
  uses: appleboy/ssh-action@v1.0.3
1347
1615
  with:
@@ -1351,6 +1619,8 @@ jobs:
1351
1619
  key: ${{ secrets.SSH_KEY }}
1352
1620
  port: ${{ env.SSH_PORT }}
1353
1621
  script: |
1622
+ ${{ env.PAW_REMOTE }}
1623
+
1354
1624
  export PATH=$PATH:/usr/local/bin
1355
1625
 
1356
1626
  NUMBER=${{ github.event.issue.number }}
@@ -1358,19 +1628,19 @@ jobs:
1358
1628
  CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}"
1359
1629
 
1360
1630
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1361
- echo "🔍 Preview 상태 확인"
1631
+ m wf_preview.r_status_head
1362
1632
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1363
1633
 
1364
1634
  if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then
1365
1635
  echo "STATUS=running"
1366
- echo "✅ 컨테이너 실행 중"
1636
+ m wf_preview.r_container_running
1367
1637
  docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}"
1368
1638
  else
1369
1639
  echo "STATUS=not_found"
1370
- echo "❌ 컨테이너 없음"
1640
+ m wf_preview.r_container_absent
1371
1641
  fi
1372
1642
 
1373
- - name: 상태 코멘트 (실행 중)
1643
+ - name: Status check (running)
1374
1644
  uses: appleboy/ssh-action@v1.0.3
1375
1645
  id: check_running
1376
1646
  continue-on-error: true
@@ -1388,10 +1658,13 @@ jobs:
1388
1658
  CONTAINER_NAME="${PROJECT_NAME}-pr-${NUMBER}"
1389
1659
  docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"
1390
1660
 
1391
- - name: 상태 코멘트 작성
1661
+ - name: Post status comment
1392
1662
  uses: actions/github-script@v9
1393
1663
  with:
1394
1664
  script: |
1665
+ const M = JSON.parse(process.env.PAW_MSG);
1666
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1667
+
1395
1668
  const number = context.issue.number;
1396
1669
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
1397
1670
  const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
@@ -1405,36 +1678,36 @@ jobs:
1405
1678
  let body;
1406
1679
  if (isRunning) {
1407
1680
  body = [
1408
- '## ✅ Preview 환경 실행 중',
1681
+ m('wf_preview.title_running'),
1409
1682
  '',
1410
- '| 항목 | 값 |',
1683
+ m('wf_preview.tbl_items'),
1411
1684
  '|------|-----|',
1412
1685
  `| **Preview URL** | ${previewUrl} |`,
1413
- `| **컨테이너** | \`${projectName}-pr-${number}\` |`,
1414
- '| **상태** | 🟢 Running |',
1686
+ m('wf_preview.env_container_num', {projectName, number}),
1687
+ m('wf_preview.env_status_running'),
1415
1688
  '',
1416
- '### 📋 명령어',
1417
- '| 명령어 | 설명 |',
1689
+ m('wf_preview.sec_commands'),
1690
+ m('wf_preview.tbl_commands'),
1418
1691
  '|--------|------|',
1419
- '| `/wizard server build` | 최신 커밋으로 재배포 |',
1420
- '| `/wizard server destroy` | Preview 환경 삭제 |',
1692
+ m('wf_preview.cmd_build'),
1693
+ m('wf_preview.cmd_destroy'),
1421
1694
  '',
1422
1695
  '---',
1423
- `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*`
1696
+ m('wf_preview.footer_context', {contextType})
1424
1697
  ].join('\n');
1425
1698
  } else {
1426
1699
  body = [
1427
- '## ❌ Preview 환경 없음',
1700
+ m('wf_preview.title_absent'),
1428
1701
  '',
1429
- '| 항목 | 값 |',
1702
+ m('wf_preview.tbl_items'),
1430
1703
  '|------|-----|',
1431
- `| **컨테이너** | \`${projectName}-pr-${number}\` |`,
1432
- '| **상태** | 🔴 Not Found |',
1704
+ m('wf_preview.env_container_num', {projectName, number}),
1705
+ m('wf_preview.env_status_absent'),
1433
1706
  '',
1434
- '배포하려면: `/wizard server build`',
1707
+ m('wf_preview.deploy_hint'),
1435
1708
  '',
1436
1709
  '---',
1437
- `*🤖 이 댓글은 ${contextType} Preview 시스템에 의해 자동 생성되었습니다.*`
1710
+ m('wf_preview.footer_context', {contextType})
1438
1711
  ].join('\n');
1439
1712
  }
1440
1713
 
@@ -1445,9 +1718,9 @@ jobs:
1445
1718
  body: body
1446
1719
  });
1447
1720
 
1448
- # Job 5: 커스텀 브랜치 빌드 & 배포
1721
+ # Job 5: custom branch build & deploy
1449
1722
  build-preview-custom-branch:
1450
- name: Preview 빌드 & 배포 (Custom Branch)
1723
+ name: Preview build & deploy (Custom Branch)
1451
1724
  needs: check-command
1452
1725
  if: |
1453
1726
  needs.check-command.outputs.is_valid == 'true' &&
@@ -1455,43 +1728,90 @@ jobs:
1455
1728
  needs.check-command.outputs.is_custom_branch == 'true'
1456
1729
  runs-on: ubuntu-latest
1457
1730
  steps:
1458
- - name: 브랜치 존재 확인 및 정보 추출
1731
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
1732
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
1733
+ - name: Checkout messages
1734
+ uses: actions/checkout@v7
1735
+ with:
1736
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
1737
+ sparse-checkout: |
1738
+ .github/scripts/messages.py
1739
+ version.yml
1740
+ sparse-checkout-cone-mode: false
1741
+
1742
+ - name: Load messages
1743
+ run: |
1744
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
1745
+ mkdir -p "$RUNNER_TEMP/paw"
1746
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
1747
+ {
1748
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
1749
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
1750
+ } >> "$GITHUB_ENV"
1751
+ # The deploy script runs on the remote server, where messages.py does not exist:
1752
+ # ship the messages it needs as a small shell function (m KEY name=value ...)
1753
+ python3 - >> "$GITHUB_ENV" <<'PY'
1754
+ import json, os, shlex, subprocess
1755
+ script = os.path.join(os.environ["RUNNER_TEMP"], "paw", "messages.py")
1756
+ msgs = json.loads(subprocess.check_output(["python3", script, "dump", "wf_preview.r_"], text=True))
1757
+ head = ["m() {", ' case "$1" in']
1758
+ for key, text in msgs.items():
1759
+ head.append(" %s) _t=%s ;;" % (key, shlex.quote(text)))
1760
+ tail = r''' *) _t="$1" ;;
1761
+ esac
1762
+ shift
1763
+ while [ "$#" -gt 0 ]; do
1764
+ _v=$(printf '%s' "${1#*=}" | sed 's/[\\&|]/\\&/g')
1765
+ _t=$(printf '%s' "$_t" | sed "s|{${1%%=*}}|$_v|g")
1766
+ shift
1767
+ done
1768
+ printf '%s\n' "$_t"
1769
+ }'''
1770
+ print("PAW_REMOTE<<PAW_EOF")
1771
+ print("\n".join(head) + "\n" + tail)
1772
+ print("PAW_EOF")
1773
+ PY
1774
+
1775
+ - name: Check branch exists and extract info
1459
1776
  id: branch_info
1460
1777
  uses: actions/github-script@v9
1461
1778
  with:
1462
1779
  script: |
1780
+ const M = JSON.parse(process.env.PAW_MSG);
1781
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1782
+
1463
1783
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1464
1784
  const issueNumber = context.issue.number;
1465
1785
 
1466
- console.log(`🔍 브랜치 확인: ${branchName}`);
1786
+ console.log(m('wf_preview.log_check_branch', {branchName}));
1467
1787
 
1468
- // 브랜치 존재 여부 확인
1788
+ // Check whether the branch exists
1469
1789
  try {
1470
1790
  await github.rest.repos.getBranch({
1471
1791
  owner: context.repo.owner,
1472
1792
  repo: context.repo.repo,
1473
1793
  branch: branchName
1474
1794
  });
1475
- console.log(`✅ 브랜치 존재: ${branchName}`);
1795
+ console.log(m('wf_preview.log_branch_exists', {branchName}));
1476
1796
  } catch (error) {
1477
1797
  if (error.status === 404) {
1478
- console.log(`❌ 브랜치 없음: ${branchName}`);
1798
+ console.log(m('wf_preview.log_branch_missing', {branchName}));
1479
1799
 
1480
- // 에러 댓글 작성
1800
+ // Post the error comment
1481
1801
  const body = [
1482
- '## ❌ 브랜치를 찾을 수 없습니다',
1802
+ m('wf_preview.title_branch_missing'),
1483
1803
  '',
1484
- '| 항목 | 값 |',
1804
+ m('wf_preview.tbl_items'),
1485
1805
  '|------|-----|',
1486
- `| **브랜치** | \`${branchName}\` |`,
1806
+ m('wf_preview.env_branch', {branchName}),
1487
1807
  '',
1488
- '### 💡 확인 사항',
1489
- '1. 브랜치명이 정확한지 확인하세요',
1490
- '2. 브랜치가 push되었는지 확인하세요',
1491
- '3. 브랜치가 삭제되지 않았는지 확인하세요',
1808
+ m('wf_preview.sec_check'),
1809
+ m('wf_preview.chk_name_1'),
1810
+ m('wf_preview.chk_pushed_2'),
1811
+ m('wf_preview.chk_deleted_3'),
1492
1812
  '',
1493
1813
  '---',
1494
- '*🤖 이 댓글은 Preview 시스템에 의해 자동 생성되었습니다.*'
1814
+ m('wf_preview.footer_generic')
1495
1815
  ].join('\n');
1496
1816
 
1497
1817
  await github.rest.issues.createComment({
@@ -1501,22 +1821,22 @@ jobs:
1501
1821
  body: body
1502
1822
  });
1503
1823
 
1504
- core.setFailed('브랜치를 찾을 수 없습니다');
1824
+ core.setFailed(m('wf_preview.fail_branch_missing'));
1505
1825
  return;
1506
1826
  }
1507
1827
  throw error;
1508
1828
  }
1509
1829
 
1510
- // 브랜치명에서 Issue 번호 추출 (#번호 패턴)
1830
+ // Extract the issue number from the branch name (#number pattern)
1511
1831
  const issueMatch = branchName.match(/#(\d+)/);
1512
1832
 
1513
1833
  if (issueMatch) {
1514
- // 번호 추출 성공
1834
+ // Number extracted
1515
1835
  core.setOutput('container_number', issueMatch[1]);
1516
1836
  core.setOutput('use_hash', 'false');
1517
- console.log(`✅ Issue 번호 추출: #${issueMatch[1]}`);
1837
+ console.log(m('wf_preview.log_issue_number', {n: issueMatch[1]}));
1518
1838
  } else {
1519
- // 해시 생성
1839
+ // Generate a hash
1520
1840
  let hash = 0;
1521
1841
  for (let i = 0; i < branchName.length; i++) {
1522
1842
  const char = branchName.charCodeAt(i);
@@ -1526,44 +1846,47 @@ jobs:
1526
1846
  const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0');
1527
1847
  core.setOutput('container_suffix', `custom-${hashStr}`);
1528
1848
  core.setOutput('use_hash', 'true');
1529
- console.log(`✅ 해시 생성: custom-${hashStr}`);
1849
+ console.log(m('wf_preview.log_hash', {hashStr}));
1530
1850
  }
1531
1851
 
1532
1852
  core.setOutput('branch_name', branchName);
1533
1853
  core.setOutput('branch_exists', 'true');
1534
1854
 
1535
- - name: 브랜치의 최신 커밋 SHA 가져오기
1855
+ - name: Get latest commit SHA of the branch
1536
1856
  if: steps.branch_info.outputs.branch_exists == 'true'
1537
1857
  id: get_sha
1538
1858
  run: |
1539
1859
  SHA=$(git ls-remote https://github.com/${{ github.repository }}.git refs/heads/${{ needs.check-command.outputs.custom_branch }} | cut -c1-7)
1540
1860
  echo "sha=$SHA" >> $GITHUB_OUTPUT
1541
1861
 
1542
- - name: 진행 상황 댓글 생성
1862
+ - name: Create progress comment
1543
1863
  if: steps.branch_info.outputs.branch_exists == 'true'
1544
1864
  id: progress
1545
1865
  uses: actions/github-script@v9
1546
1866
  with:
1547
1867
  script: |
1868
+ const M = JSON.parse(process.env.PAW_MSG);
1869
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1870
+
1548
1871
  const issueNumber = context.issue.number;
1549
1872
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1550
1873
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
1551
1874
  const startTime = Date.now();
1552
1875
 
1553
1876
  const body = [
1554
- '## 🚀 Custom Branch Preview 빌드 중...',
1877
+ m('wf_preview.title_custom_building'),
1555
1878
  '',
1556
- `**브랜치**: \`${branchName}\``,
1879
+ m('wf_preview.line_branch', {branchName}),
1557
1880
  '',
1558
- '| 단계 | 상태 | 소요 시간 |',
1881
+ m('wf_preview.tbl_steps'),
1559
1882
  '|------|------|----------|',
1560
- '| 🐳 Docker 이미지 빌드 & Push | ⏳ 진행 중... | - |',
1561
- '| 🚀 서버 배포 & Health Check | ⏸️ 대기 | - |',
1883
+ m('wf_preview.row_docker_progress'),
1884
+ m('wf_preview.row_deploy_wait'),
1562
1885
  '',
1563
- `**[📋 실시간 로그 보기](${runUrl})**`,
1886
+ m('wf_preview.link_logs', {runUrl}),
1564
1887
  '',
1565
1888
  '---',
1566
- '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
1889
+ m('wf_preview.footer_progress')
1567
1890
  ].join('\n');
1568
1891
 
1569
1892
  const { data: comment } = await github.rest.issues.createComment({
@@ -1577,14 +1900,14 @@ jobs:
1577
1900
  core.setOutput('start_time', startTime);
1578
1901
  core.setOutput('docker_start', startTime);
1579
1902
 
1580
- - name: 코드 체크아웃
1903
+ - name: Checkout code
1581
1904
  if: steps.branch_info.outputs.branch_exists == 'true'
1582
1905
  uses: actions/checkout@v7
1583
1906
  with:
1584
1907
  ref: ${{ needs.check-command.outputs.custom_branch }}
1585
1908
 
1586
- # 필수 Secret·Dockerfile이 없으면 docker 로그인 같은 서드파티 오류 대신 빠진 항목을 알려 주고 멈춘다
1587
- - name: 배포 사전 점검 (Secret·Dockerfile)
1909
+ # If a required secret or the Dockerfile is missing, report the missing items and stop instead of surfacing a third-party error such as a docker login failure
1910
+ - name: Deploy pre-check (Secrets and Dockerfile)
1588
1911
  if: steps.branch_info.outputs.branch_exists == 'true'
1589
1912
  working-directory: ${{ env.PROJECT_PATH }}
1590
1913
  env:
@@ -1596,11 +1919,13 @@ jobs:
1596
1919
  SSH_KEY: ${{ secrets.SSH_KEY }}
1597
1920
  DOCKERFILE: ${{ env.DOCKERFILE_PATH }}
1598
1921
  run: |
1922
+ m() { python3 "$RUNNER_TEMP/paw/messages.py" get "$@"; }
1923
+
1599
1924
  MISSING=""
1600
1925
  for NAME in DOCKERHUB_USERNAME DOCKERHUB_TOKEN SERVER_HOST SERVER_USER; do
1601
1926
  [ -n "${!NAME}" ] || MISSING="$MISSING $NAME"
1602
1927
  done
1603
- # SSH_AUTH_METHOD=key면 SSH_KEY, 아니면 SERVER_PASSWORD가 필요하다
1928
+ # SSH_KEY is required when SSH_AUTH_METHOD=key, otherwise SERVER_PASSWORD
1604
1929
  if [ "${SSH_AUTH_METHOD:-password}" = "key" ]; then
1605
1930
  [ -n "$SSH_KEY" ] || MISSING="$MISSING SSH_KEY"
1606
1931
  else
@@ -1608,19 +1933,19 @@ jobs:
1608
1933
  fi
1609
1934
  FAILED=false
1610
1935
  if [ -n "$MISSING" ]; then
1611
- echo "::error title=필수 Secret 누락::등록되지 않은 GitHub Secret:$MISSING — Settings → Secrets and variables → Actions에서 등록하세요"
1936
+ echo "::error title=$(m wf_preview.precheck_secret_title)::$(m wf_preview.precheck_secret_body missing="$MISSING")"
1612
1937
  FAILED=true
1613
1938
  fi
1614
1939
  if [ ! -f "$DOCKERFILE" ]; then
1615
- echo "::error title=Dockerfile 없음::${PROJECT_PATH}/${DOCKERFILE#./} 파일이 없습니다. 이 워크플로우는 Docker 이미지로 배포하므로 Dockerfile이 필요합니다 (서버 배포를 하지 않는다면 이 워크플로우 파일을 삭제하세요)"
1940
+ echo "::error title=$(m wf_preview.precheck_dockerfile_title)::$(m wf_preview.precheck_dockerfile_body path="${PROJECT_PATH}/${DOCKERFILE#./}")"
1616
1941
  FAILED=true
1617
1942
  fi
1618
1943
  if [ "$FAILED" = "true" ]; then
1619
1944
  exit 1
1620
1945
  fi
1621
- echo "✅ 사전 점검 통과: 필수 Secret과 Dockerfile이 모두 있습니다"
1946
+ m wf_preview.precheck_ok
1622
1947
 
1623
- - name: "[필수] .env 파일 생성"
1948
+ - name: "[Required] Create .env file"
1624
1949
  working-directory: ${{ env.PROJECT_PATH }}
1625
1950
  if: steps.branch_info.outputs.branch_exists == 'true'
1626
1951
  run: |
@@ -1628,18 +1953,18 @@ jobs:
1628
1953
  ${{ secrets.ENV_FILE }}
1629
1954
  EOF
1630
1955
 
1631
- - name: Docker 로그인
1956
+ - name: Log in to Docker Hub
1632
1957
  if: steps.branch_info.outputs.branch_exists == 'true'
1633
1958
  uses: docker/login-action@v3
1634
1959
  with:
1635
1960
  username: ${{ secrets.DOCKERHUB_USERNAME }}
1636
1961
  password: ${{ secrets.DOCKERHUB_TOKEN }}
1637
1962
 
1638
- - name: Docker Buildx 설정
1963
+ - name: Set up Docker Buildx
1639
1964
  if: steps.branch_info.outputs.branch_exists == 'true'
1640
1965
  uses: docker/setup-buildx-action@v3
1641
1966
 
1642
- - name: 컨테이너 이름 결정
1967
+ - name: Resolve container name
1643
1968
  if: steps.branch_info.outputs.branch_exists == 'true'
1644
1969
  id: container
1645
1970
  run: |
@@ -1651,7 +1976,7 @@ jobs:
1651
1976
  echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
1652
1977
  echo "name=${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}-${SUFFIX}" >> $GITHUB_OUTPUT
1653
1978
 
1654
- - name: Docker 이미지 빌드 & Push
1979
+ - name: Build & push Docker image
1655
1980
  if: steps.branch_info.outputs.branch_exists == 'true'
1656
1981
  uses: docker/build-push-action@v5
1657
1982
  with:
@@ -1662,12 +1987,15 @@ jobs:
1662
1987
  cache-from: type=gha
1663
1988
  cache-to: type=gha,mode=max
1664
1989
 
1665
- - name: 진행 상황 - Docker 완료
1990
+ - name: Progress - Docker done
1666
1991
  if: steps.branch_info.outputs.branch_exists == 'true'
1667
1992
  id: docker_progress
1668
1993
  uses: actions/github-script@v9
1669
1994
  with:
1670
1995
  script: |
1996
+ const M = JSON.parse(process.env.PAW_MSG);
1997
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1998
+
1671
1999
  const commentId = ${{ steps.progress.outputs.comment_id }};
1672
2000
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1673
2001
  const dockerStart = ${{ steps.progress.outputs.docker_start }};
@@ -1675,23 +2003,23 @@ jobs:
1675
2003
  const dockerElapsed = now - dockerStart;
1676
2004
  const minutes = Math.floor(dockerElapsed / 60000);
1677
2005
  const seconds = Math.floor((dockerElapsed % 60000) / 1000);
1678
- const dockerDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
2006
+ const dockerDuration = minutes > 0 ? m('wf_preview.dur_min_sec', {minutes, seconds}) : m('wf_preview.dur_sec', {seconds});
1679
2007
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
1680
2008
 
1681
2009
  const body = [
1682
- '## 🚀 Custom Branch Preview 빌드 중...',
2010
+ m('wf_preview.title_custom_building'),
1683
2011
  '',
1684
- `**브랜치**: \`${branchName}\``,
2012
+ m('wf_preview.line_branch', {branchName}),
1685
2013
  '',
1686
- '| 단계 | 상태 | 소요 시간 |',
2014
+ m('wf_preview.tbl_steps'),
1687
2015
  '|------|------|----------|',
1688
- `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
1689
- '| 🚀 서버 배포 & Health Check | ⏳ 진행 중... | - |',
2016
+ m('wf_preview.row_docker_done', {dockerDuration}),
2017
+ m('wf_preview.row_deploy_progress'),
1690
2018
  '',
1691
- `**[📋 실시간 로그 보기](${runUrl})**`,
2019
+ m('wf_preview.link_logs', {runUrl}),
1692
2020
  '',
1693
2021
  '---',
1694
- '*🤖 이 댓글은 자동으로 업데이트됩니다.*'
2022
+ m('wf_preview.footer_progress')
1695
2023
  ].join('\n');
1696
2024
 
1697
2025
  await github.rest.issues.updateComment({
@@ -1704,7 +2032,7 @@ jobs:
1704
2032
  core.setOutput('docker_duration', dockerDuration);
1705
2033
  core.setOutput('deploy_start', now);
1706
2034
 
1707
- - name: 서버에 배포
2035
+ - name: Deploy to server
1708
2036
  if: steps.branch_info.outputs.branch_exists == 'true'
1709
2037
  uses: appleboy/ssh-action@v1.0.3
1710
2038
  env:
@@ -1717,19 +2045,21 @@ jobs:
1717
2045
  port: ${{ env.SSH_PORT }}
1718
2046
  envs: SSH_AUTH_METHOD
1719
2047
  script: |
2048
+ ${{ env.PAW_REMOTE }}
2049
+
1720
2050
  set -e
1721
2051
 
1722
2052
  export PATH=$PATH:/usr/local/bin
1723
2053
  export PW="${{ secrets.SERVER_PASSWORD }}"
1724
2054
 
1725
- # SSH 인증 방식에 따른 sudo 추상화
2055
+ # sudo wrapper that depends on the SSH auth method
1726
2056
  SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
1727
2057
  if [ "${SSH_AUTH_METHOD}" = "key" ]; then
1728
2058
  SUDO() { sudo "$@"; }
1729
2059
  else
1730
2060
  SUDO() { echo "$PW" | sudo -S "$@"; }
1731
2061
  fi
1732
- echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
2062
+ m wf_preview.r_ssh_auth ssh_auth_method="${SSH_AUTH_METHOD}"
1733
2063
 
1734
2064
  PROJECT_NAME="${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}"
1735
2065
  CONTAINER_NAME="${{ steps.container.outputs.name }}"
@@ -1740,17 +2070,17 @@ jobs:
1740
2070
  TRAEFIK_NETWORK="${{ env.TRAEFIK_NETWORK }}"
1741
2071
 
1742
2072
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1743
- echo "🚀 Custom Branch Preview 배포 시작"
2073
+ m wf_preview.r_deploy_head_custom
1744
2074
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1745
- echo "📦 프로젝트: ${PROJECT_NAME}"
1746
- echo "📛 컨테이너: ${CONTAINER_NAME}"
1747
- echo "🌐 도메인: ${DOMAIN}"
2075
+ m wf_preview.r_project_name project_name="${PROJECT_NAME}"
2076
+ m wf_preview.r_container_name container_name="${CONTAINER_NAME}"
2077
+ m wf_preview.r_domain domain="${DOMAIN}"
1748
2078
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1749
2079
 
1750
- echo "📥 Docker 이미지 Pull 중..."
2080
+ m wf_preview.r_image_pulling
1751
2081
  SUDO docker pull "${IMAGE}"
1752
2082
 
1753
- echo "🗑️ 기존 컨테이너 정리 중..."
2083
+ m wf_preview.r_existing_cleanup
1754
2084
  SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
1755
2085
 
1756
2086
  VOLUME_OPTS="-v /etc/localtime:/etc/localtime:ro"
@@ -1758,7 +2088,7 @@ jobs:
1758
2088
  VOLUME_OPTS="$VOLUME_OPTS -v ${{ env.PROJECT_TARGET_DIR }}:${{ env.PROJECT_MNT_DIR }}"
1759
2089
  fi
1760
2090
 
1761
- echo "🐳 새 컨테이너 실행 중..."
2091
+ m wf_preview.r_container_starting
1762
2092
  SUDO docker run -d \
1763
2093
  --name "${CONTAINER_NAME}" \
1764
2094
  --network "${TRAEFIK_NETWORK}" \
@@ -1772,7 +2102,7 @@ jobs:
1772
2102
  "${IMAGE}"
1773
2103
 
1774
2104
  echo ""
1775
- echo "⏳ Health Check 시작 (최대 120초 대기)..."
2105
+ m wf_preview.r_hc_start
1776
2106
  HEALTH_PATH="${{ env.HEALTH_CHECK_PATH }}"
1777
2107
  LOG_PATTERN="${{ env.HEALTH_CHECK_LOG_PATTERN }}"
1778
2108
  MAX_RETRIES=24
@@ -1787,9 +2117,9 @@ jobs:
1787
2117
  STATUS=$(SUDO docker inspect --format='{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "not_found")
1788
2118
 
1789
2119
  if [ "$STATUS" = "exited" ]; then
1790
- echo "❌ 컨테이너 비정상 종료!"
2120
+ m wf_preview.r_container_exited
1791
2121
  echo ""
1792
- echo "📋 컨테이너 로그 (최근 100줄):"
2122
+ m wf_preview.r_container_logs
1793
2123
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1794
2124
  SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1795
2125
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -1801,7 +2131,7 @@ jobs:
1801
2131
  HEALTH=$(SUDO docker exec "${CONTAINER_NAME}" curl -sf "http://localhost:${INTERNAL_PORT}${HEALTH_PATH}" 2>/dev/null || echo "")
1802
2132
 
1803
2133
  if [ -n "$HEALTH" ]; then
1804
- echo "✅ 정상 기동 확인! (HTTP 응답: ${HEALTH_PATH})"
2134
+ m wf_preview.r_hc_ok_http health_path="${HEALTH_PATH}"
1805
2135
  HEALTH_CHECK_PASSED=true
1806
2136
  HEALTH_CHECK_METHOD="HTTP"
1807
2137
  break
@@ -1812,7 +2142,7 @@ jobs:
1812
2142
  STARTED=$(SUDO docker logs --tail 50 "${CONTAINER_NAME}" 2>&1 | grep -E "$LOG_PATTERN" || echo "")
1813
2143
 
1814
2144
  if [ -n "$STARTED" ]; then
1815
- echo "✅ 정상 기동 확인! (로그 패턴)"
2145
+ m wf_preview.r_hc_ok_log
1816
2146
  echo " $STARTED"
1817
2147
  HEALTH_CHECK_PASSED=true
1818
2148
  HEALTH_CHECK_METHOD="Log"
@@ -1821,14 +2151,14 @@ jobs:
1821
2151
  fi
1822
2152
  fi
1823
2153
 
1824
- echo "⏳ 대기 중... ($RETRY_COUNT/$MAX_RETRIES) - 상태: $STATUS"
2154
+ m wf_preview.r_hc_waiting retry_count="$RETRY_COUNT" max_retries="$MAX_RETRIES" status="$STATUS"
1825
2155
  done
1826
2156
 
1827
2157
  if [ "$HEALTH_CHECK_PASSED" = "false" ]; then
1828
2158
  echo ""
1829
- echo "❌ Health Check 타임아웃 (120초)"
2159
+ m wf_preview.r_hc_timeout
1830
2160
  echo ""
1831
- echo "📋 컨테이너 로그 (최근 100줄):"
2161
+ m wf_preview.r_container_logs
1832
2162
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1833
2163
  SUDO docker logs --tail 100 "${CONTAINER_NAME}"
1834
2164
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -1836,14 +2166,17 @@ jobs:
1836
2166
  fi
1837
2167
 
1838
2168
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1839
- echo "✅ 배포 및 Health Check 완료! (방식: ${HEALTH_CHECK_METHOD})"
2169
+ m wf_preview.r_hc_done health_check_method="${HEALTH_CHECK_METHOD}"
1840
2170
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1841
2171
 
1842
- - name: 배포 완료 코멘트
2172
+ - name: Deploy complete comment
1843
2173
  if: steps.branch_info.outputs.branch_exists == 'true'
1844
2174
  uses: actions/github-script@v9
1845
2175
  with:
1846
2176
  script: |
2177
+ const M = JSON.parse(process.env.PAW_MSG);
2178
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
2179
+
1847
2180
  const commentId = ${{ steps.progress.outputs.comment_id }};
1848
2181
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1849
2182
  const dockerDuration = '${{ steps.docker_progress.outputs.docker_duration }}';
@@ -1852,7 +2185,7 @@ jobs:
1852
2185
  const deployElapsed = now - deployStart;
1853
2186
  const minutes = Math.floor(deployElapsed / 60000);
1854
2187
  const seconds = Math.floor((deployElapsed % 60000) / 1000);
1855
- const deployDuration = minutes > 0 ? `${minutes}분 ${seconds}초` : `${seconds}초`;
2188
+ const deployDuration = minutes > 0 ? m('wf_preview.dur_min_sec', {minutes, seconds}) : m('wf_preview.dur_sec', {seconds});
1856
2189
 
1857
2190
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
1858
2191
  const domainSuffix = '${{ env.PREVIEW_DOMAIN_SUFFIX }}';
@@ -1863,41 +2196,41 @@ jobs:
1863
2196
  const previewUrl = `http://${domain}:${previewPort}`;
1864
2197
  const sha = '${{ steps.get_sha.outputs.sha }}';
1865
2198
 
1866
- // Preview 환경 테이블 구성
2199
+ // Build the preview environment table
1867
2200
  const envRows = [
1868
2201
  `| **Preview URL** | ${previewUrl} |`,
1869
2202
  ];
1870
2203
  if (apiDocsPath) {
1871
2204
  envRows.push(`| **API Docs** | ${previewUrl}${apiDocsPath} |`);
1872
2205
  }
1873
- envRows.push(`| **컨테이너** | \`${containerName}\` |`);
1874
- envRows.push(`| **브랜치** | \`${branchName}\` |`);
1875
- envRows.push(`| **커밋** | \`${sha}\` |`);
2206
+ envRows.push(m('wf_preview.env_container_name', {containerName}));
2207
+ envRows.push(m('wf_preview.env_branch', {branchName}));
2208
+ envRows.push(m('wf_preview.env_commit', {sha}));
1876
2209
 
1877
2210
  const body = [
1878
- '## ✅ Custom Branch Preview 배포 완료!',
2211
+ m('wf_preview.title_custom_deployed'),
1879
2212
  '',
1880
- `**브랜치**: \`${branchName}\``,
2213
+ m('wf_preview.line_branch', {branchName}),
1881
2214
  '',
1882
- '| 단계 | 상태 | 소요 시간 |',
2215
+ m('wf_preview.tbl_steps'),
1883
2216
  '|------|------|----------|',
1884
- `| 🐳 Docker 이미지 빌드 & Push | ✅ 완료 | ${dockerDuration} |`,
1885
- `| 🚀 서버 배포 & Health Check | ✅ 완료 | ${deployDuration} |`,
2217
+ m('wf_preview.row_docker_done', {dockerDuration}),
2218
+ m('wf_preview.row_deploy_done', {deployDuration}),
1886
2219
  '',
1887
- '### 🌐 Preview 환경',
1888
- '| 항목 | 값 |',
2220
+ m('wf_preview.sec_env'),
2221
+ m('wf_preview.tbl_items'),
1889
2222
  '|------|-----|',
1890
2223
  ...envRows,
1891
2224
  '',
1892
- '### 📋 명령어',
1893
- '| 명령어 | 설명 |',
2225
+ m('wf_preview.sec_commands'),
2226
+ m('wf_preview.tbl_commands'),
1894
2227
  '|--------|------|',
1895
- '| `/wizard server build` | 최신 커밋으로 재배포 |',
1896
- '| `/wizard server destroy` | Preview 환경 삭제 |',
1897
- '| `/wizard server status` | 현재 상태 확인 |',
2228
+ m('wf_preview.cmd_build'),
2229
+ m('wf_preview.cmd_destroy'),
2230
+ m('wf_preview.cmd_status'),
1898
2231
  '',
1899
2232
  '<details>',
1900
- '<summary>🔧 고급 명령어 (다른 Issue/PR에서 제어)</summary>',
2233
+ m('wf_preview.cmd_advanced'),
1901
2234
  '',
1902
2235
  '```',
1903
2236
  `/wizard server build ${branchName}`,
@@ -1907,7 +2240,7 @@ jobs:
1907
2240
  '</details>',
1908
2241
  '',
1909
2242
  '---',
1910
- '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2243
+ m('wf_preview.footer_custom')
1911
2244
  ].join('\n');
1912
2245
 
1913
2246
  await github.rest.issues.updateComment({
@@ -1917,11 +2250,14 @@ jobs:
1917
2250
  body: body
1918
2251
  });
1919
2252
 
1920
- - name: 빌드/배포 실패 시 에러 코멘트
2253
+ - name: Build/deploy failure comment
1921
2254
  if: failure() && steps.branch_info.outputs.branch_exists == 'true'
1922
2255
  uses: actions/github-script@v9
1923
2256
  with:
1924
2257
  script: |
2258
+ const M = JSON.parse(process.env.PAW_MSG);
2259
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
2260
+
1925
2261
  const commentId = ${{ steps.progress.outputs.comment_id || 0 }};
1926
2262
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
1927
2263
  const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
@@ -1932,42 +2268,42 @@ jobs:
1932
2268
  let dockerTime = '-';
1933
2269
 
1934
2270
  if (!commentId) {
1935
- dockerStatus = '⚠️ 초기화 실패';
2271
+ dockerStatus = m('wf_preview.status_init_failed');
1936
2272
  deployStatus = '-';
1937
2273
  } else if (!dockerDuration || dockerDuration === '') {
1938
- dockerStatus = '❌ 실패';
1939
- deployStatus = '⏸️ 대기';
2274
+ dockerStatus = m('wf_preview.status_failed');
2275
+ deployStatus = m('wf_preview.status_waiting');
1940
2276
  } else {
1941
- dockerStatus = '✅ 완료';
1942
- deployStatus = '❌ 실패';
2277
+ dockerStatus = m('wf_preview.status_done');
2278
+ deployStatus = m('wf_preview.status_failed');
1943
2279
  dockerTime = dockerDuration;
1944
2280
  }
1945
2281
 
1946
2282
  const body = [
1947
- '## ❌ Custom Branch Preview 배포 실패!',
2283
+ m('wf_preview.title_custom_failed'),
1948
2284
  '',
1949
- `**브랜치**: \`${branchName}\``,
2285
+ m('wf_preview.line_branch', {branchName}),
1950
2286
  '',
1951
- '| 단계 | 상태 | 소요 시간 |',
2287
+ m('wf_preview.tbl_steps'),
1952
2288
  '|------|------|----------|',
1953
- `| 🐳 Docker 이미지 빌드 & Push | ${dockerStatus} | ${dockerTime} |`,
1954
- `| 🚀 서버 배포 & Health Check | ${deployStatus} | - |`,
2289
+ m('wf_preview.row_docker_status', {dockerStatus, dockerTime}),
2290
+ m('wf_preview.row_deploy_status', {deployStatus}),
1955
2291
  '',
1956
- `**[📋 빌드/배포 로그 확인](${runUrl})**`,
2292
+ m('wf_preview.link_build_logs', {runUrl}),
1957
2293
  '',
1958
- '### 🔍 가능한 원인',
1959
- '- 필수 Secret(DOCKERHUB_*, SERVER_*) 미등록 또는 Dockerfile 없음 → 로그의 "배포 사전 점검" 단계 확인',
1960
- '- Docker 이미지 빌드 실패 (Python 의존성 문제)',
1961
- '- 컨테이너 시작 실패 (FastAPI/Uvicorn 기동 오류)',
1962
- '- Health Check 타임아웃 (120초 내 기동 완료 안됨)',
1963
- '- 환경변수 누락 (.env 파일 설정 확인)',
2294
+ m('wf_preview.sec_causes'),
2295
+ m('wf_preview.hint_secrets'),
2296
+ m('wf_preview.hint_docker_python'),
2297
+ m('wf_preview.hint_start_python'),
2298
+ m('wf_preview.hint_health_timeout'),
2299
+ m('wf_preview.hint_env_missing'),
1964
2300
  '',
1965
- '### 💡 다음 단계',
1966
- '1. 위 링크에서 빌드/배포 로그를 확인하세요',
1967
- `2. 문제를 수정한 후 다시 시도하세요: \`/wizard server build ${branchName}\``,
2301
+ m('wf_preview.sec_next'),
2302
+ m('wf_preview.next_logs'),
2303
+ m('wf_preview.next_retry_branch', {branchName}),
1968
2304
  '',
1969
2305
  '---',
1970
- '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2306
+ m('wf_preview.footer_custom')
1971
2307
  ].join('\n');
1972
2308
 
1973
2309
  if (commentId) {
@@ -1986,9 +2322,9 @@ jobs:
1986
2322
  });
1987
2323
  }
1988
2324
 
1989
- # Job 6: 커스텀 브랜치 Preview 삭제
2325
+ # Job 6: destroy custom branch preview
1990
2326
  destroy-preview-custom-branch:
1991
- name: Preview 삭제 (Custom Branch)
2327
+ name: Destroy preview (Custom Branch)
1992
2328
  needs: check-command
1993
2329
  if: |
1994
2330
  needs.check-command.outputs.is_valid == 'true' &&
@@ -1996,23 +2332,70 @@ jobs:
1996
2332
  needs.check-command.outputs.is_custom_branch == 'true'
1997
2333
  runs-on: ubuntu-latest
1998
2334
  steps:
1999
- - name: 브랜치명에서 컨테이너 정보 추출
2335
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
2336
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
2337
+ - name: Checkout messages
2338
+ uses: actions/checkout@v7
2339
+ with:
2340
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
2341
+ sparse-checkout: |
2342
+ .github/scripts/messages.py
2343
+ version.yml
2344
+ sparse-checkout-cone-mode: false
2345
+
2346
+ - name: Load messages
2347
+ run: |
2348
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
2349
+ mkdir -p "$RUNNER_TEMP/paw"
2350
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
2351
+ {
2352
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
2353
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
2354
+ } >> "$GITHUB_ENV"
2355
+ # The deploy script runs on the remote server, where messages.py does not exist:
2356
+ # ship the messages it needs as a small shell function (m KEY name=value ...)
2357
+ python3 - >> "$GITHUB_ENV" <<'PY'
2358
+ import json, os, shlex, subprocess
2359
+ script = os.path.join(os.environ["RUNNER_TEMP"], "paw", "messages.py")
2360
+ msgs = json.loads(subprocess.check_output(["python3", script, "dump", "wf_preview.r_"], text=True))
2361
+ head = ["m() {", ' case "$1" in']
2362
+ for key, text in msgs.items():
2363
+ head.append(" %s) _t=%s ;;" % (key, shlex.quote(text)))
2364
+ tail = r''' *) _t="$1" ;;
2365
+ esac
2366
+ shift
2367
+ while [ "$#" -gt 0 ]; do
2368
+ _v=$(printf '%s' "${1#*=}" | sed 's/[\\&|]/\\&/g')
2369
+ _t=$(printf '%s' "$_t" | sed "s|{${1%%=*}}|$_v|g")
2370
+ shift
2371
+ done
2372
+ printf '%s\n' "$_t"
2373
+ }'''
2374
+ print("PAW_REMOTE<<PAW_EOF")
2375
+ print("\n".join(head) + "\n" + tail)
2376
+ print("PAW_EOF")
2377
+ PY
2378
+
2379
+ - name: Extract container info from branch name
2000
2380
  id: container_info
2001
2381
  uses: actions/github-script@v9
2002
2382
  with:
2003
2383
  script: |
2384
+ const M = JSON.parse(process.env.PAW_MSG);
2385
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
2386
+
2004
2387
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
2005
2388
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
2006
2389
 
2007
- // 브랜치명에서 Issue 번호 추출 (#번호 패턴)
2390
+ // Extract the issue number from the branch name (#number pattern)
2008
2391
  const issueMatch = branchName.match(/#(\d+)/);
2009
2392
 
2010
2393
  let containerName;
2011
2394
  if (issueMatch) {
2012
2395
  containerName = `${projectName}-pr-${issueMatch[1]}`;
2013
- console.log(`✅ Issue 번호 추출: #${issueMatch[1]}`);
2396
+ console.log(m('wf_preview.log_issue_number', {n: issueMatch[1]}));
2014
2397
  } else {
2015
- // 해시 생성
2398
+ // Generate a hash
2016
2399
  let hash = 0;
2017
2400
  for (let i = 0; i < branchName.length; i++) {
2018
2401
  const char = branchName.charCodeAt(i);
@@ -2021,13 +2404,13 @@ jobs:
2021
2404
  }
2022
2405
  const hashStr = Math.abs(hash).toString(16).slice(-6).padStart(6, '0');
2023
2406
  containerName = `${projectName}-custom-${hashStr}`;
2024
- console.log(`✅ 해시 생성: custom-${hashStr}`);
2407
+ console.log(m('wf_preview.log_hash', {hashStr}));
2025
2408
  }
2026
2409
 
2027
2410
  core.setOutput('container_name', containerName);
2028
2411
  core.setOutput('branch_name', branchName);
2029
2412
 
2030
- - name: 컨테이너 & 이미지 삭제
2413
+ - name: Remove container & image
2031
2414
  uses: appleboy/ssh-action@v1.0.3
2032
2415
  env:
2033
2416
  SSH_AUTH_METHOD: ${{ env.SSH_AUTH_METHOD }}
@@ -2039,60 +2422,65 @@ jobs:
2039
2422
  port: ${{ env.SSH_PORT }}
2040
2423
  envs: SSH_AUTH_METHOD
2041
2424
  script: |
2425
+ ${{ env.PAW_REMOTE }}
2426
+
2042
2427
  export PATH=$PATH:/usr/local/bin
2043
2428
  export PW="${{ secrets.SERVER_PASSWORD }}"
2044
2429
 
2045
- # SSH 인증 방식에 따른 sudo 추상화
2430
+ # sudo wrapper that depends on the SSH auth method
2046
2431
  SSH_AUTH_METHOD="${SSH_AUTH_METHOD:-password}"
2047
2432
  if [ "${SSH_AUTH_METHOD}" = "key" ]; then
2048
2433
  SUDO() { sudo "$@"; }
2049
2434
  else
2050
2435
  SUDO() { echo "$PW" | sudo -S "$@"; }
2051
2436
  fi
2052
- echo "🔐 SSH 인증 방식: ${SSH_AUTH_METHOD}"
2437
+ m wf_preview.r_ssh_auth ssh_auth_method="${SSH_AUTH_METHOD}"
2053
2438
 
2054
2439
  CONTAINER_NAME="${{ steps.container_info.outputs.container_name }}"
2055
2440
  PROJECT_NAME="${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}"
2056
2441
 
2057
- # 컨테이너명에서 suffix 추출
2442
+ # Extract the suffix from the container name
2058
2443
  SUFFIX="${CONTAINER_NAME#${PROJECT_NAME}-}"
2059
2444
  IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${PROJECT_NAME}:${SUFFIX}"
2060
2445
 
2061
2446
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2062
- echo "🗑️ Custom Branch Preview 삭제"
2447
+ m wf_preview.r_destroy_head_custom
2063
2448
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2064
- echo "📦 프로젝트: ${PROJECT_NAME}"
2065
- echo "📛 컨테이너: ${CONTAINER_NAME}"
2449
+ m wf_preview.r_project_name project_name="${PROJECT_NAME}"
2450
+ m wf_preview.r_container_name container_name="${CONTAINER_NAME}"
2066
2451
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2067
2452
 
2068
- echo "🐳 컨테이너 삭제 중..."
2453
+ m wf_preview.r_container_removing
2069
2454
  SUDO docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
2070
2455
 
2071
- echo "🖼️ 이미지 삭제 중..."
2456
+ m wf_preview.r_image_removing
2072
2457
  SUDO docker rmi "${IMAGE}" 2>/dev/null || true
2073
2458
 
2074
- echo "✅ 삭제 완료!"
2459
+ m wf_preview.r_removed
2075
2460
 
2076
- - name: 삭제 완료 코멘트
2461
+ - name: Destroy complete comment
2077
2462
  uses: actions/github-script@v9
2078
2463
  with:
2079
2464
  script: |
2465
+ const M = JSON.parse(process.env.PAW_MSG);
2466
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
2467
+
2080
2468
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
2081
2469
  const containerName = '${{ steps.container_info.outputs.container_name }}';
2082
2470
 
2083
2471
  const body = [
2084
- '## 🗑️ Custom Branch Preview 환경 삭제 완료!',
2472
+ m('wf_preview.title_custom_destroyed'),
2085
2473
  '',
2086
- '| 항목 | 값 |',
2474
+ m('wf_preview.tbl_items'),
2087
2475
  '|------|-----|',
2088
- `| **브랜치** | \`${branchName}\` |`,
2089
- `| **컨테이너** | \`${containerName}\` |`,
2090
- '| **상태** | 삭제됨 |',
2476
+ m('wf_preview.env_branch', {branchName}),
2477
+ m('wf_preview.env_container_name', {containerName}),
2478
+ m('wf_preview.env_status_deleted'),
2091
2479
  '',
2092
- `다시 배포하려면: \`/wizard server build ${branchName}\``,
2480
+ m('wf_preview.redeploy_hint_branch', {branchName}),
2093
2481
  '',
2094
2482
  '---',
2095
- '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2483
+ m('wf_preview.footer_custom')
2096
2484
  ].join('\n');
2097
2485
 
2098
2486
  await github.rest.issues.createComment({
@@ -2102,9 +2490,9 @@ jobs:
2102
2490
  body: body
2103
2491
  });
2104
2492
 
2105
- # Job 7: 커스텀 브랜치 Preview 상태 확인
2493
+ # Job 7: custom branch preview status
2106
2494
  check-status-custom-branch:
2107
- name: Preview 상태 확인 (Custom Branch)
2495
+ name: Preview status (Custom Branch)
2108
2496
  needs: check-command
2109
2497
  if: |
2110
2498
  needs.check-command.outputs.is_valid == 'true' &&
@@ -2112,7 +2500,51 @@ jobs:
2112
2500
  needs.check-command.outputs.is_custom_branch == 'true'
2113
2501
  runs-on: ubuntu-latest
2114
2502
  steps:
2115
- - name: 브랜치명에서 컨테이너 정보 추출
2503
+ # Messages come from the same revision as this workflow (the PR merge commit on pull_request, otherwise the
2504
+ # default branch) so they exist even before the default branch has them, and before the PR branch is checked out
2505
+ - name: Checkout messages
2506
+ uses: actions/checkout@v7
2507
+ with:
2508
+ ref: ${{ github.event_name == 'pull_request' && github.sha || github.event.repository.default_branch }}
2509
+ sparse-checkout: |
2510
+ .github/scripts/messages.py
2511
+ version.yml
2512
+ sparse-checkout-cone-mode: false
2513
+
2514
+ - name: Load messages
2515
+ run: |
2516
+ # Keep a copy outside the workspace: checking out the PR branch later replaces its files
2517
+ mkdir -p "$RUNNER_TEMP/paw"
2518
+ cp .github/scripts/messages.py "$RUNNER_TEMP/paw/messages.py"
2519
+ {
2520
+ echo "PROJECT_AUTO_WIZARD_LANG=$(python3 .github/scripts/messages.py lang)"
2521
+ echo "PAW_MSG=$(python3 "$RUNNER_TEMP/paw/messages.py" dump wf_preview.)"
2522
+ } >> "$GITHUB_ENV"
2523
+ # The deploy script runs on the remote server, where messages.py does not exist:
2524
+ # ship the messages it needs as a small shell function (m KEY name=value ...)
2525
+ python3 - >> "$GITHUB_ENV" <<'PY'
2526
+ import json, os, shlex, subprocess
2527
+ script = os.path.join(os.environ["RUNNER_TEMP"], "paw", "messages.py")
2528
+ msgs = json.loads(subprocess.check_output(["python3", script, "dump", "wf_preview.r_"], text=True))
2529
+ head = ["m() {", ' case "$1" in']
2530
+ for key, text in msgs.items():
2531
+ head.append(" %s) _t=%s ;;" % (key, shlex.quote(text)))
2532
+ tail = r''' *) _t="$1" ;;
2533
+ esac
2534
+ shift
2535
+ while [ "$#" -gt 0 ]; do
2536
+ _v=$(printf '%s' "${1#*=}" | sed 's/[\\&|]/\\&/g')
2537
+ _t=$(printf '%s' "$_t" | sed "s|{${1%%=*}}|$_v|g")
2538
+ shift
2539
+ done
2540
+ printf '%s\n' "$_t"
2541
+ }'''
2542
+ print("PAW_REMOTE<<PAW_EOF")
2543
+ print("\n".join(head) + "\n" + tail)
2544
+ print("PAW_EOF")
2545
+ PY
2546
+
2547
+ - name: Extract container info from branch name
2116
2548
  id: container_info
2117
2549
  uses: actions/github-script@v9
2118
2550
  with:
@@ -2120,14 +2552,14 @@ jobs:
2120
2552
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
2121
2553
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
2122
2554
 
2123
- // 브랜치명에서 Issue 번호 추출 (#번호 패턴)
2555
+ // Extract the issue number from the branch name (#number pattern)
2124
2556
  const issueMatch = branchName.match(/#(\d+)/);
2125
2557
 
2126
2558
  let containerName;
2127
2559
  if (issueMatch) {
2128
2560
  containerName = `${projectName}-pr-${issueMatch[1]}`;
2129
2561
  } else {
2130
- // 해시 생성
2562
+ // Generate a hash
2131
2563
  let hash = 0;
2132
2564
  for (let i = 0; i < branchName.length; i++) {
2133
2565
  const char = branchName.charCodeAt(i);
@@ -2141,7 +2573,7 @@ jobs:
2141
2573
  core.setOutput('container_name', containerName);
2142
2574
  core.setOutput('branch_name', branchName);
2143
2575
 
2144
- - name: 컨테이너 상태 확인
2576
+ - name: Check container status
2145
2577
  uses: appleboy/ssh-action@v1.0.3
2146
2578
  id: check_running
2147
2579
  continue-on-error: true
@@ -2152,28 +2584,33 @@ jobs:
2152
2584
  key: ${{ secrets.SSH_KEY }}
2153
2585
  port: ${{ env.SSH_PORT }}
2154
2586
  script: |
2587
+ ${{ env.PAW_REMOTE }}
2588
+
2155
2589
  export PATH=$PATH:/usr/local/bin
2156
2590
 
2157
2591
  CONTAINER_NAME="${{ steps.container_info.outputs.container_name }}"
2158
2592
 
2159
2593
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2160
- echo "🔍 Custom Branch Preview 상태 확인"
2594
+ m wf_preview.r_status_head_custom
2161
2595
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
2162
2596
 
2163
2597
  if docker ps --filter "name=${CONTAINER_NAME}" --format "{{.Names}}" | grep -q "${CONTAINER_NAME}"; then
2164
2598
  echo "STATUS=running"
2165
- echo "✅ 컨테이너 실행 중"
2599
+ m wf_preview.r_container_running
2166
2600
  docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}"
2167
2601
  else
2168
2602
  echo "STATUS=not_found"
2169
- echo "❌ 컨테이너 없음"
2603
+ m wf_preview.r_container_absent
2170
2604
  exit 1
2171
2605
  fi
2172
2606
 
2173
- - name: 상태 코멘트 작성
2607
+ - name: Post status comment
2174
2608
  uses: actions/github-script@v9
2175
2609
  with:
2176
2610
  script: |
2611
+ const M = JSON.parse(process.env.PAW_MSG);
2612
+ const m = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
2613
+
2177
2614
  const branchName = '${{ needs.check-command.outputs.custom_branch }}';
2178
2615
  const containerName = '${{ steps.container_info.outputs.container_name }}';
2179
2616
  const projectName = '${{ env.PROJECT_PATH != '.' && format('{0}-python', env.PROJECT_NAME) || env.PROJECT_NAME }}';
@@ -2186,38 +2623,38 @@ jobs:
2186
2623
  let body;
2187
2624
  if (isRunning) {
2188
2625
  body = [
2189
- '## ✅ Custom Branch Preview 환경 실행 중',
2626
+ m('wf_preview.title_custom_running'),
2190
2627
  '',
2191
- '| 항목 | 값 |',
2628
+ m('wf_preview.tbl_items'),
2192
2629
  '|------|-----|',
2193
- `| **브랜치** | \`${branchName}\` |`,
2630
+ m('wf_preview.env_branch', {branchName}),
2194
2631
  `| **Preview URL** | ${previewUrl} |`,
2195
- `| **컨테이너** | \`${containerName}\` |`,
2196
- '| **상태** | 🟢 Running |',
2632
+ m('wf_preview.env_container_name', {containerName}),
2633
+ m('wf_preview.env_status_running'),
2197
2634
  '',
2198
- '### 📋 명령어',
2199
- '| 명령어 | 설명 |',
2635
+ m('wf_preview.sec_commands'),
2636
+ m('wf_preview.tbl_commands'),
2200
2637
  '|--------|------|',
2201
- `| \`/wizard server build ${branchName}\` | 최신 커밋으로 재배포 |`,
2202
- `| \`/wizard server destroy ${branchName}\` | Preview 환경 삭제 |`,
2638
+ m('wf_preview.cmd_build_branch', {branchName}),
2639
+ m('wf_preview.cmd_destroy_branch', {branchName}),
2203
2640
  '',
2204
2641
  '---',
2205
- '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2642
+ m('wf_preview.footer_custom')
2206
2643
  ].join('\n');
2207
2644
  } else {
2208
2645
  body = [
2209
- '## ❌ Custom Branch Preview 환경 없음',
2646
+ m('wf_preview.title_custom_absent'),
2210
2647
  '',
2211
- '| 항목 | 값 |',
2648
+ m('wf_preview.tbl_items'),
2212
2649
  '|------|-----|',
2213
- `| **브랜치** | \`${branchName}\` |`,
2214
- `| **컨테이너** | \`${containerName}\` |`,
2215
- '| **상태** | 🔴 Not Found |',
2650
+ m('wf_preview.env_branch', {branchName}),
2651
+ m('wf_preview.env_container_name', {containerName}),
2652
+ m('wf_preview.env_status_absent'),
2216
2653
  '',
2217
- `배포하려면: \`/wizard server build ${branchName}\``,
2654
+ m('wf_preview.deploy_hint_branch', {branchName}),
2218
2655
  '',
2219
2656
  '---',
2220
- '*🤖 이 댓글은 Custom Branch Preview 시스템에 의해 자동 생성되었습니다.*'
2657
+ m('wf_preview.footer_custom')
2221
2658
  ].join('\n');
2222
2659
  }
2223
2660