project-auto-wizard 0.2.0 → 0.3.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 (43) hide show
  1. package/README.md +15 -4
  2. package/package.json +1 -1
  3. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  4. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  5. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
  6. package/payload/scripts/issue_helper.py +334 -0
  7. package/payload/scripts/truncate_release_notes.py +89 -0
  8. package/payload/version.yml.template +1 -0
  9. package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +47 -0
  10. package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +4 -4
  11. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +1 -1
  12. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +1 -1
  13. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +1 -1
  14. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +6 -6
  15. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +1 -1
  16. package/payload/workflows/spring/{PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml → nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml} +8 -2
  17. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +8 -6
  18. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +2 -2
  19. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +8 -8
  20. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +2 -2
  21. package/src/cli/args.js +9 -0
  22. package/src/cli/help.js +2 -1
  23. package/src/commands/dry-run.js +7 -16
  24. package/src/commands/full.js +75 -16
  25. package/src/commands/interactive.js +47 -6
  26. package/src/commands/uninstall.js +3 -1
  27. package/src/core/copy/simple.js +2 -2
  28. package/src/core/copy/workflows.js +32 -29
  29. package/src/core/deploy-style.js +90 -0
  30. package/src/core/detect-fs.js +36 -7
  31. package/src/core/detect.js +68 -3
  32. package/src/core/install-log.js +182 -0
  33. package/src/core/options-ask.js +5 -2
  34. package/src/core/paths-resolve.js +4 -8
  35. package/src/core/removal-plan.js +1 -1
  36. package/src/core/verify.js +86 -0
  37. package/src/core/version-yml.js +31 -4
  38. package/src/core/wizard-env.js +6 -3
  39. package/src/index.js +15 -2
  40. package/src/ui/env-plan.js +63 -33
  41. package/src/ui/prompts.js +41 -2
  42. package/src/ui/status-cards.js +7 -3
  43. package/src/ui/summary.js +56 -6
package/README.md CHANGED
@@ -17,7 +17,7 @@ npx project-auto-wizard
17
17
  [![node](https://img.shields.io/badge/node-%3E%3D20.12-brightgreen)](package.json)
18
18
 
19
19
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
20
- ## 최신 버전 : v0.1.34 (2026-08-10)
20
+ ## 최신 버전 : v0.2.1 (2026-08-10)
21
21
 
22
22
  [전체 버전 기록 보기](CHANGELOG.md)
23
23
 
@@ -67,11 +67,21 @@ flutter.APP_ARTIFACT_NAME:
67
67
  `spring`/`flutter`는 아래처럼 단일 CI 이상으로 깊게 구성되어 있습니다:
68
68
 
69
69
  - **flutter**: Android(Firebase/Playstore/Selfhosted/TestAPK 배포), iOS(TestFlight/Test-TestFlight), CI, Lab 트리거까지 8종
70
- - **spring**: 단일 서버 배포(SIMPLE-CICD, **기본 활성**) + 무중단 배포 2종(Nginx/Traefik, opt-in) + PR 프리뷰 + GitHub Packages publish(항상 설치) + Nexus publish(`--nexus` opt-in)
71
- - 무중단 배포 워크플로우는 파일은 설치되지만 `push` 트리거가 기본적으로 비활성(`workflow_dispatch`만 활성)입니다. 기본 배포는 SIMPLE-CICD가 담당하며, 무중단 배포로 전환하려면 해당 워크플로우 YAML의 `push.branches` 주석을 해제하고 SIMPLE-CICD의 `push` 트리거는 주석 처리해야 합니다(각 YAML 상단 주석에 안내되어 있습니다).
70
+ - **spring**: 서버 배포 1종(단일 서버 / 무중단 Nginx / 무중단 Traefik 중 택1) + PR 프리뷰 + 라이브러리 publish 2종(Nexus·GitHub Packages, `--nexus` opt-in)
71
+ - 서버 배포 워크플로우는 **서로 대체재**라 하나만 설치합니다. 대화형에서 고르면 그것만 깔리고 **`push` 트리거까지 켜진 채로** 설치됩니다. 비대화형은 `--deploy-style simple|nginx|traefik` (기본: `simple`).
72
+ - 고른 방식은 `version.yml`에 기록되므로 다시 실행해도 묻지 않습니다. 방식을 바꾸면 **이전 워크플로우를 마법사가 정리합니다** — 손대지 않은 파일은 삭제하고, 수정한 파일은 `.bak`으로 옮겨 내용을 보존합니다. 남겨두면 배포가 두 번 돕니다.
73
+ - PR 프리뷰는 배포 방식과 무관한 별개 축이라 선택과 관계없이 함께 설치됩니다.
72
74
  - **react/next**: CI와 CI+CD 분리 구성
73
75
  - **python**: CI / PR 프리뷰 / SimpleCICD
74
76
 
77
+ ### 설치 기록 (`.github/.wizard/logs/`)
78
+
79
+ 설치가 끝나면 그 실행에서 무엇을 어떤 값으로 설치했는지 `.github/.wizard/logs/<시각>-install.md`에 한 건 남깁니다. 감지된 타입과 그 근거 파일, 버전, 브랜치, 선택 워크플로우, **환경설정 질문별 답변(기본값 그대로인지 여부 포함)**, 설치된 파일, 값이 채워지지 않은 항목, 등록해야 하는 GitHub Secret이 담깁니다.
80
+
81
+ 상단은 기계가 읽는 YAML front matter, 아래는 사람이 읽는 마크다운입니다. 배포가 예상과 다르게 동작할 때 "설치할 때 뭘로 답했더라"를 여기서 확인할 수 있고, 레포에 커밋되므로 AI 에이전트나 다른 팀원도 참고할 수 있습니다. 로그 기록에 실패해도 설치 자체는 정상 완료됩니다.
82
+
83
+ `.github/.wizard/`에는 업데이트 3-way 판정에 쓰는 `baseline.json`도 함께 들어 있어, 완전 삭제 시 워크플로우와 함께 제거됩니다.
84
+
75
85
  ### 완전 삭제(`--mode uninstall`)
76
86
 
77
87
  `npx project-auto-wizard --mode uninstall`은 마법사가 설치한 것을 제거합니다 — 워크플로우·스크립트는 물론, README.md의 `AUTO-VERSION-SECTION` 버전 섹션과 `.gitignore`에 자동 추가된 항목, `version.yml`까지 선택적으로 제거할 수 있습니다.
@@ -133,7 +143,8 @@ npx project-auto-wizard [옵션]
133
143
  --paths "t=p,..." 모노레포 타입별 경로
134
144
  --main-branch B 릴리스 브랜치 (기본: 감지된 default branch)
135
145
  --develop-branch B 개발 브랜치 (기본: develop)
136
- --nexus Nexus 라이브러리 publish 워크플로우 포함
146
+ --deploy-style S 서버 배포 방식: simple | nginx | traefik (기본: simple)
147
+ --nexus 라이브러리 publish 워크플로우 포함 (Nexus + GitHub Packages)
137
148
  --secret-backup Secret 서버 백업 워크플로우 포함
138
149
  --semver-auto 커밋 타입 기반 자동 major/minor/patch 승격 (기본: 사용함, --no-semver-auto로 끔)
139
150
  --dry-run 실제 파일 변경 없이 무엇이 바뀔지만 미리 보여줌
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-auto-wizard",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "One command DevOps: npx wizard that installs GitHub-native AI Release Automation into any project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ issue_helper.py — normalizes a GitHub issue's title/URL into a branch name
4
+ and commit message, posts/updates a comment on the issue with both, and
5
+ optionally creates the branch (stdlib only).
6
+
7
+ This script is copied into user repos (.github/scripts/) by
8
+ project-auto-wizard and is triggered by the `issues: [opened, edited]`
9
+ event via PROJECT-COMMON-ISSUE-HELPER.yaml.
10
+
11
+ Python rewrite of Chuseok22/github-issue-helper (same author, a repo
12
+ without a LICENSE file) — ported to remove the external Action dependency
13
+ (project-auto-wizard#68) and drop the Chuseok22-branded comment output.
14
+
15
+ Usage:
16
+ issue_helper.py run
17
+
18
+ Reads GITHUB_EVENT_PATH (issues event JSON), GITHUB_TOKEN,
19
+ GITHUB_REPOSITORY (all auto-provided by the Actions runner except
20
+ GITHUB_TOKEN, which the workflow wires from secrets.GITHUB_TOKEN), and
21
+ ISSUE_HELPER_* env vars set by the workflow (branch_prefix,
22
+ max_branch_length, commit_template, create_branch, base_branch,
23
+ comment_marker).
24
+
25
+ Contract:
26
+ - Exit 0 on success, including the no-op case (event isn't a
27
+ relevant issues action).
28
+ - Exit 1 on a configuration/runtime failure (missing token, GitHub
29
+ API error, etc.) — mirrors the original Action's core.setFailed.
30
+ - Exit 2 on argument-parsing errors (argparse default).
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import unicodedata
39
+ import urllib.error
40
+ import urllib.request
41
+ from datetime import datetime, timedelta, timezone
42
+
43
+ KST = timezone(timedelta(hours=9))
44
+ COMMENT_MARKER_DEFAULT = "<!-- project-auto-wizard issue helper -->"
45
+ API_BASE = "https://api.github.com"
46
+
47
+
48
+ def log(message):
49
+ print(message, file=sys.stderr)
50
+
51
+
52
+ # ===================================================================
53
+ # 정규화 (순수 함수 — GitHub API 호출 없음)
54
+ # ===================================================================
55
+
56
+ def extract_issue_number(issue_url):
57
+ trimmed = issue_url.strip().rstrip("/")
58
+ parts = trimmed.split("/")
59
+ return parts[-1] if parts and parts[-1] else ""
60
+
61
+
62
+ _TAG_RE = re.compile(r"\[.*?]")
63
+ _VARIATION_SELECTOR = "️"
64
+ _ZWJ = "‍"
65
+
66
+
67
+ def _is_removable_char(ch):
68
+ # 원본(TS) 정규식 \p{So}|\p{C}|️|‍ 이식.
69
+ # Python re는 \p{...}를 지원하지 않으므로 unicodedata.category()로 대체:
70
+ # "So"(Symbol, other) 또는 "C"로 시작(Cc/Cf/Co/Cs/Cn)하면 제거 대상.
71
+ # 변형 선택자(U+FE0F, 카테고리 Mn)는 위 두 조건에 안 걸리므로 명시적으로 추가.
72
+ if ch in (_VARIATION_SELECTOR, _ZWJ):
73
+ return True
74
+ category = unicodedata.category(ch)
75
+ return category == "So" or category.startswith("C")
76
+
77
+
78
+ def extract_issue_title(raw_title):
79
+ title = _TAG_RE.sub("", raw_title).strip()
80
+ title = "".join(ch for ch in title if not _is_removable_char(ch)).strip()
81
+ return title if title else raw_title.strip()
82
+
83
+
84
+ def format_date_yyyymmdd(dt):
85
+ return dt.strftime("%Y%m%d")
86
+
87
+
88
+ _NON_ALNUM_KO_RE = re.compile(r"[^a-zA-Z0-9가-힣]")
89
+ _MULTI_UNDERSCORE_RE = re.compile(r"_+")
90
+
91
+
92
+ def normalize_title(title):
93
+ normalized = _NON_ALNUM_KO_RE.sub("_", title)
94
+ normalized = _MULTI_UNDERSCORE_RE.sub("_", normalized)
95
+ return normalized.strip("_")
96
+
97
+
98
+ def create_branch_name(issue_title, issue_number, date_yyyymmdd, branch_prefix, max_branch_length):
99
+ normalized_title = normalize_title(issue_title)
100
+ base = f"{date_yyyymmdd}_#{issue_number}_{normalized_title}"
101
+ limited_base = base[:max_branch_length] if max_branch_length > 0 else base
102
+ return f"{branch_prefix}{limited_base}"
103
+
104
+
105
+ def render_commit_message(template, issue_title, issue_url, issue_number, branch_name, date_yyyymmdd):
106
+ result = template
107
+ result = result.replace("${issueTitle}", issue_title)
108
+ result = result.replace("${issueUrl}", issue_url)
109
+ result = result.replace("${issueNumber}", issue_number)
110
+ result = result.replace("${branchName}", branch_name)
111
+ result = result.replace("${date}", date_yyyymmdd)
112
+ return result.strip()
113
+
114
+
115
+ def normalize_all(title, issue_url, issue_number, date_yyyymmdd, branch_prefix, max_branch_length, commit_template):
116
+ normalized_title = normalize_title(title)
117
+ branch_name = create_branch_name(title, issue_number, date_yyyymmdd, branch_prefix, max_branch_length)
118
+ commit_message = render_commit_message(
119
+ commit_template, normalized_title, issue_url, issue_number, branch_name, date_yyyymmdd,
120
+ )
121
+ return branch_name, commit_message
122
+
123
+
124
+ # ===================================================================
125
+ # GitHub REST API (urllib.request + GITHUB_TOKEN, 서드파티 의존 없음)
126
+ # ===================================================================
127
+
128
+ def _headers(token, has_body):
129
+ headers = {
130
+ "Authorization": f"Bearer {token}",
131
+ "Accept": "application/vnd.github+json",
132
+ "X-GitHub-Api-Version": "2022-11-28",
133
+ "User-Agent": "project-auto-wizard-issue-helper",
134
+ }
135
+ if has_body:
136
+ headers["Content-Type"] = "application/json"
137
+ return headers
138
+
139
+
140
+ def _api_request(method, url, token, body=None):
141
+ data = json.dumps(body).encode("utf-8") if body is not None else None
142
+ req = urllib.request.Request(url, data=data, method=method, headers=_headers(token, data is not None))
143
+ try:
144
+ with urllib.request.urlopen(req, timeout=30) as resp:
145
+ raw = resp.read()
146
+ return resp.status, (json.loads(raw) if raw else None), resp.headers.get("Link")
147
+ except urllib.error.HTTPError as e:
148
+ raw = e.read()
149
+ try:
150
+ parsed = json.loads(raw) if raw else None
151
+ except json.JSONDecodeError:
152
+ parsed = None
153
+ return e.code, parsed, None
154
+
155
+
156
+ def _find_next_link(link_header):
157
+ if not link_header:
158
+ return None
159
+ for part in link_header.split(","):
160
+ segment = part.strip()
161
+ if 'rel="next"' in segment and "<" in segment and ">" in segment:
162
+ return segment[segment.index("<") + 1:segment.index(">")]
163
+ return None
164
+
165
+
166
+ def list_comments(owner, repo, issue_number, token):
167
+ comments = []
168
+ url = f"{API_BASE}/repos/{owner}/{repo}/issues/{issue_number}/comments?per_page=100"
169
+ while url:
170
+ status, page, link = _api_request("GET", url, token)
171
+ if status >= 400:
172
+ raise RuntimeError(f"이슈 코멘트 조회 실패({status}): {issue_number}")
173
+ comments.extend(page or [])
174
+ url = _find_next_link(link)
175
+ return comments
176
+
177
+
178
+ def upsert_comment(owner, repo, issue_number, token, marker, body):
179
+ comments = list_comments(owner, repo, issue_number, token)
180
+ existing = next((c for c in comments if marker in (c.get("body") or "")), None)
181
+ if existing:
182
+ status, _, _ = _api_request(
183
+ "PATCH", f"{API_BASE}/repos/{owner}/{repo}/issues/comments/{existing['id']}", token, {"body": body},
184
+ )
185
+ if status >= 400:
186
+ raise RuntimeError(f"코멘트 갱신 실패({status})")
187
+ log("기존 코멘트를 갱신했습니다.")
188
+ else:
189
+ status, _, _ = _api_request(
190
+ "POST", f"{API_BASE}/repos/{owner}/{repo}/issues/{issue_number}/comments", token, {"body": body},
191
+ )
192
+ if status >= 400:
193
+ raise RuntimeError(f"코멘트 작성 실패({status})")
194
+ log("새 코멘트를 작성했습니다.")
195
+
196
+
197
+ def create_branch_if_needed(owner, repo, branch_name, base_branch, create_branch, token):
198
+ if not create_branch:
199
+ return
200
+
201
+ base = base_branch
202
+ if not base:
203
+ status, repo_data, _ = _api_request("GET", f"{API_BASE}/repos/{owner}/{repo}", token)
204
+ if status >= 400:
205
+ raise RuntimeError(f"레포 정보 조회 실패({status})")
206
+ base = repo_data["default_branch"]
207
+
208
+ status, ref_data, _ = _api_request("GET", f"{API_BASE}/repos/{owner}/{repo}/git/ref/heads/{base}", token)
209
+ if status >= 400:
210
+ raise RuntimeError(f"base 브랜치 ref 조회 실패({base}, {status})")
211
+ sha = ref_data["object"]["sha"]
212
+
213
+ status, _, _ = _api_request(
214
+ "POST", f"{API_BASE}/repos/{owner}/{repo}/git/refs", token,
215
+ {"ref": f"refs/heads/{branch_name}", "sha": sha},
216
+ )
217
+ if status == 422:
218
+ log(f"브랜치가 이미 존재함, 건너뜀: {branch_name}")
219
+ return
220
+ if status >= 400:
221
+ raise RuntimeError(f"브랜치 생성 실패({branch_name}, {status})")
222
+ log(f"브랜치 생성됨: {branch_name}")
223
+
224
+
225
+ # ===================================================================
226
+ # 이벤트 처리 / CLI
227
+ # ===================================================================
228
+
229
+ def _bool_env(name, default):
230
+ val = os.environ.get(name)
231
+ if val is None or val.strip() == "":
232
+ return default
233
+ return val.strip().lower() == "true"
234
+
235
+
236
+ def cmd_run():
237
+ event_path = os.environ.get("GITHUB_EVENT_PATH")
238
+ if not event_path:
239
+ log("ERROR: GITHUB_EVENT_PATH가 없습니다.")
240
+ return 1
241
+ try:
242
+ with open(event_path, "r", encoding="utf-8") as f:
243
+ payload = json.load(f)
244
+ except (OSError, json.JSONDecodeError) as e:
245
+ log(f"ERROR: 이벤트 페이로드를 읽을 수 없습니다: {e}")
246
+ return 1
247
+
248
+ action = payload.get("action")
249
+ issue = payload.get("issue") or {}
250
+ changes = payload.get("changes") or {}
251
+ is_opened = action == "opened"
252
+ is_edited_with_title = action == "edited" and "title" in changes
253
+
254
+ if not is_opened and not is_edited_with_title:
255
+ log("열림 또는 제목 변경 이벤트가 아님 → 종료")
256
+ return 0
257
+
258
+ repo_full = os.environ.get("GITHUB_REPOSITORY", "")
259
+ if "/" not in repo_full or repo_full.count("/") != 1 or not all(repo_full.split("/")):
260
+ log(f"ERROR: GITHUB_REPOSITORY 형식이 올바르지 않습니다: {repo_full!r}")
261
+ return 1
262
+ owner, repo = repo_full.split("/", 1)
263
+
264
+ token = os.environ.get("GITHUB_TOKEN", "").strip()
265
+ if not token:
266
+ log("ERROR: GITHUB_TOKEN이 없습니다.")
267
+ return 1
268
+
269
+ raw_title = issue.get("title", "")
270
+ issue_url = issue.get("html_url", "")
271
+ issue_number_str = extract_issue_number(issue_url) or str(issue.get("number", ""))
272
+
273
+ date_yyyymmdd = format_date_yyyymmdd(datetime.now(KST))
274
+
275
+ comment_marker = os.environ.get("ISSUE_HELPER_COMMENT_MARKER") or COMMENT_MARKER_DEFAULT
276
+ branch_prefix = os.environ.get("ISSUE_HELPER_BRANCH_PREFIX", "")
277
+ max_branch_length = int(os.environ.get("ISSUE_HELPER_MAX_BRANCH_LENGTH") or "120")
278
+ commit_template = (
279
+ os.environ.get("ISSUE_HELPER_COMMIT_TEMPLATE")
280
+ or "${issueTitle} : feat : {변경 사항에 대한 설명} ${issueUrl}"
281
+ )
282
+ create_branch = _bool_env("ISSUE_HELPER_CREATE_BRANCH", False)
283
+ base_branch = os.environ.get("ISSUE_HELPER_BASE_BRANCH", "").strip()
284
+
285
+ title = extract_issue_title(raw_title)
286
+ branch_name, commit_message = normalize_all(
287
+ title, issue_url, issue_number_str, date_yyyymmdd, branch_prefix, max_branch_length, commit_template,
288
+ )
289
+
290
+ body = (
291
+ f"{comment_marker}\n"
292
+ "## Issue Helper\n"
293
+ "### 브랜치명\n"
294
+ "```\n"
295
+ f"{branch_name}\n"
296
+ "```\n\n"
297
+ "### 커밋 메시지\n"
298
+ "```\n"
299
+ f"{commit_message}\n"
300
+ "```"
301
+ )
302
+
303
+ upsert_comment(owner, repo, issue["number"], token, comment_marker, body)
304
+
305
+ github_output = os.environ.get("GITHUB_OUTPUT")
306
+ if github_output:
307
+ with open(github_output, "a", encoding="utf-8") as f:
308
+ f.write(f"branchName={branch_name}\n")
309
+ f.write(f"commitMessage={commit_message}\n")
310
+
311
+ create_branch_if_needed(owner, repo, branch_name, base_branch, create_branch, token)
312
+ return 0
313
+
314
+
315
+ def build_parser():
316
+ parser = argparse.ArgumentParser(prog="issue_helper.py")
317
+ parser.add_argument("command", choices=["run"])
318
+ return parser
319
+
320
+
321
+ def main(argv=None):
322
+ parser = build_parser()
323
+ args = parser.parse_args(argv)
324
+ if args.command == "run":
325
+ try:
326
+ return cmd_run()
327
+ except Exception as e:
328
+ log(f"실행 실패: {e}")
329
+ return 1
330
+ return 2
331
+
332
+
333
+ if __name__ == "__main__":
334
+ sys.exit(main())
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ truncate_release_notes.py — truncate a release notes file in place to fit a
4
+ store-imposed length limit (stdlib only).
5
+
6
+ This script is copied into user repos (.github/scripts/) by project-auto-wizard
7
+ and is called by the Flutter deploy workflows right before store upload
8
+ (Google Play, Firebase App Distribution, App Store Connect) — each of which
9
+ enforces its own release-notes length limit.
10
+
11
+ Usage:
12
+ truncate_release_notes.py <file> <limit> <char|byte>
13
+
14
+ Behavior:
15
+ - Missing file: no-op, exit 0 (a missing notes file is not a reason to
16
+ block deployment).
17
+ - File within limit: left unchanged, exit 0.
18
+ - File over limit: truncated in place to `limit`, no marker text added.
19
+ char mode counts Unicode code points (Google Play); byte mode counts
20
+ UTF-8 bytes and never splits a multi-byte character (Firebase / App
21
+ Store Connect).
22
+ - Bytes in the input that are not valid UTF-8 are replaced, never fatal.
23
+
24
+ Contract:
25
+ - Exit 0 on success (including the no-op cases above).
26
+ - Exit 1 if `limit` is not a positive integer (caller bug).
27
+ - Exit 2 on argument-parsing errors (argparse default — wrong arg count,
28
+ non-integer limit, unknown mode).
29
+ """
30
+
31
+ import argparse
32
+ import sys
33
+
34
+
35
+ def log(message):
36
+ print(message, file=sys.stderr)
37
+
38
+
39
+ def truncate_text(text, limit, mode):
40
+ if mode == "char":
41
+ return text[:limit]
42
+ return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore")
43
+
44
+
45
+ def run(path, limit, mode):
46
+ try:
47
+ with open(path, "r", encoding="utf-8", newline="", errors="replace") as f:
48
+ text = f.read()
49
+ except FileNotFoundError:
50
+ log(f"릴리즈 노트 파일 없음, 건너뜀: {path}")
51
+ return 0
52
+
53
+ length = len(text) if mode == "char" else len(text.encode("utf-8"))
54
+ if length <= limit:
55
+ log(f"한도 이내 ({length}/{limit} {mode}), 자르지 않음: {path}")
56
+ return 0
57
+
58
+ truncated = truncate_text(text, limit, mode)
59
+ with open(path, "w", encoding="utf-8", newline="") as f:
60
+ f.write(truncated)
61
+
62
+ log(f"한도 초과 ({length} -> {limit} {mode} 이하로 절단): {path}")
63
+ return 0
64
+
65
+
66
+ def build_parser():
67
+ parser = argparse.ArgumentParser(prog="truncate_release_notes.py")
68
+ parser.add_argument("file")
69
+ parser.add_argument("limit", type=int)
70
+ parser.add_argument("mode", choices=["char", "byte"])
71
+ return parser
72
+
73
+
74
+ def main(argv=None):
75
+ sys.stdout.reconfigure(errors="replace")
76
+ sys.stderr.reconfigure(errors="replace")
77
+
78
+ parser = build_parser()
79
+ args = parser.parse_args(argv)
80
+
81
+ if args.limit <= 0:
82
+ log(f"ERROR: limit은 양수여야 함: {args.limit}")
83
+ return 1
84
+
85
+ return run(args.file, args.limit, args.mode)
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
@@ -46,4 +46,5 @@ metadata:
46
46
  nexus: {{OPT_NEXUS}}
47
47
  secret_backup: {{OPT_SECRET_BACKUP}}
48
48
  semver_auto: {{OPT_SEMVER_AUTO}}
49
+ deploy_style: "{{OPT_DEPLOY_STYLE}}" # simple | nginx | traefik (서버 배포 워크플로우)
49
50
  {{DEPLOY}}
@@ -0,0 +1,47 @@
1
+ # project-auto-wizard:managed-workflow
2
+ # ===================================================================
3
+ # PROJECT-COMMON-ISSUE-HELPER.yaml
4
+ # 이슈 생성/제목 수정 시 브랜치명·커밋 메시지를 댓글로 안내
5
+ # ===================================================================
6
+ #
7
+ # 이슈가 열리거나(opened) 제목이 수정되면(edited + 제목 변경),
8
+ # 정규화된 브랜치명과 커밋 메시지를 이슈 댓글로 생성/갱신한다.
9
+ # ISSUE_HELPER_CREATE_BRANCH=true로 바꾸면 브랜치도 함께 생성한다
10
+ # (기본값은 false — 이슈를 열 때마다 브랜치가 자동 생성되는 것을
11
+ # 막기 위해 옵트인으로 전환됨, project-auto-wizard#68).
12
+ #
13
+ # 로직 본체는 .github/scripts/issue_helper.py (Chuseok22/github-issue-helper의
14
+ # Python 재작성, 동일 작성자 — project-auto-wizard#68).
15
+ # ===================================================================
16
+
17
+ name: PROJECT-ISSUE-HELPER
18
+
19
+ on:
20
+ issues:
21
+ types: [opened, edited]
22
+
23
+ # ISSUE_HELPER_CREATE_BRANCH를 "true"로 켜서 브랜치 자동 생성을 쓰려면
24
+ # git/refs API 쓰기 권한이 필요하므로 contents를 write로 올려야 한다.
25
+ permissions:
26
+ issues: write
27
+ contents: read
28
+
29
+ jobs:
30
+ issue-helper:
31
+ name: 브랜치명 & 커밋 메시지 안내
32
+ if: github.event.action == 'opened' || (github.event.action == 'edited' && github.event.changes.title)
33
+ runs-on: ubuntu-latest
34
+ env:
35
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36
+ ISSUE_HELPER_BRANCH_PREFIX: ""
37
+ ISSUE_HELPER_MAX_BRANCH_LENGTH: "100"
38
+ ISSUE_HELPER_COMMIT_TEMPLATE: "${issueTitle} : feat : {변경 사항에 대한 설명} ${issueUrl}"
39
+ ISSUE_HELPER_CREATE_BRANCH: "false"
40
+ ISSUE_HELPER_BASE_BRANCH: "{{MAIN_BRANCH}}"
41
+ steps:
42
+ - name: Checkout repository
43
+ uses: actions/checkout@v7
44
+
45
+ - name: 브랜치명 & 커밋 메시지 생성
46
+ id: issue-helper
47
+ run: python3 .github/scripts/issue_helper.py run
@@ -75,11 +75,11 @@ concurrency:
75
75
  # 환경 변수 설정
76
76
  # ===================================================================
77
77
  env:
78
- # 🔧 프로젝트 설정 - 프로젝트에 맞게 수정하세요
79
- PROJECT_NAME: "my-project"
78
+ # 🔧 프로젝트 설정 - 마법사가 설치 시 채웁니다
79
+ PROJECT_NAME: "__PROJECT_NAME__" # @wizard ask:@repo
80
80
  ROLE: "backend"
81
- SERVER_BASE_PATH: "/volume1/projects"
82
- SSH_PORT: "2022"
81
+ SERVER_BASE_PATH: "__SERVER_BASE_PATH__" # @wizard ask:/volume1/projects
82
+ SSH_PORT: "__SSH_PORT__" # @wizard ask:2022
83
83
 
84
84
  jobs:
85
85
  upload-secret-files:
@@ -564,7 +564,7 @@ jobs:
564
564
  run: |
565
565
  if [ -f "final_release_notes.txt" ]; then
566
566
  # Firebase App Distribution release notes 길이 제한 안전망 (4000자)
567
- bash ./.github/scripts/truncate_release_notes.sh final_release_notes.txt 4000 char
567
+ python3 ./.github/scripts/truncate_release_notes.py final_release_notes.txt 4000 char
568
568
  fi
569
569
 
570
570
  # Firebase App Distribution에 AAB 업로드
@@ -665,7 +665,7 @@ jobs:
665
665
  if [ -f "$GITHUB_WORKSPACE/final_release_notes.txt" ]; then
666
666
  # Google Play release notes 한도(500 글자)에 맞춰 절단 (마진 두어 480자)
667
667
  # final_release_notes.txt·truncate 스크립트는 레포 루트 기준 — working-directory가 FLUTTER_PROJECT_DIR이므로 절대경로 사용
668
- bash "$GITHUB_WORKSPACE/.github/scripts/truncate_release_notes.sh" "$GITHUB_WORKSPACE/final_release_notes.txt" 480 char
668
+ python3 "$GITHUB_WORKSPACE/.github/scripts/truncate_release_notes.py" "$GITHUB_WORKSPACE/final_release_notes.txt" 480 char
669
669
  cp "$GITHUB_WORKSPACE/final_release_notes.txt" "$CHANGELOG_DIR/${VERSION_CODE}.txt"
670
670
  echo "✅ Changelog 준비 완료 (버전 코드: $VERSION_CODE)"
671
671
  else
@@ -445,7 +445,7 @@ jobs:
445
445
  # Release notes 준비 (final_release_notes.txt·truncate 스크립트는 레포 루트 기준 — working-directory가 FLUTTER_PROJECT_DIR이므로 절대경로 사용)
446
446
  if [ -f "$GITHUB_WORKSPACE/final_release_notes.txt" ]; then
447
447
  # App Store Connect "What to Test" 한도(4000 바이트)에 맞춰 절단 (마진 두어 3800byte)
448
- bash "$GITHUB_WORKSPACE/.github/scripts/truncate_release_notes.sh" "$GITHUB_WORKSPACE/final_release_notes.txt" 3800 byte
448
+ python3 "$GITHUB_WORKSPACE/.github/scripts/truncate_release_notes.py" "$GITHUB_WORKSPACE/final_release_notes.txt" 3800 byte
449
449
  RELEASE_NOTES=$(cat "$GITHUB_WORKSPACE/final_release_notes.txt")
450
450
  echo "📝 Release Notes:"
451
451
  echo "$RELEASE_NOTES"
@@ -25,11 +25,11 @@
25
25
  #
26
26
  # 📊 리소스 네이밍:
27
27
  # - 컨테이너: {PROJECT_NAME}-pr-{번호}
28
- # - 도메인: {PROJECT_NAME}-pr-{번호}.pr.suhsaechan.kr
28
+ # - 도메인: {PROJECT_NAME}-pr-{번호}.pr.example.com
29
29
  #
30
30
  # 📋 사전 요구사항:
31
31
  # - Traefik 컨테이너 실행 중 (traefik-network)
32
- # - 와일드카드 DNS 설정: *.pr.suhsaechan.kr → 서버
32
+ # - 와일드카드 DNS 설정: *.pr.example.com → 서버
33
33
  # ===================================================================
34
34
 
35
35
  name: PROJECT-PYTHON-PR-PREVIEW
@@ -46,12 +46,12 @@ env:
46
46
  INTERNAL_PORT: '8000'
47
47
 
48
48
  # 🗂️ 볼륨 마운트 설정 (프로젝트별 수정 필요, 빈값이면 마운트 안함)
49
- PROJECT_TARGET_DIR: "/volume1/projects/프로젝트명/ai" # 서버의 데이터 디렉토리
50
- PROJECT_MNT_DIR: "/mnt/프로젝트명" # 컨테이너 내부 마운트 경로
49
+ PROJECT_TARGET_DIR: "__PROJECT_TARGET_DIR__" # @wizard ask:/volume1/projects/__PROJECT_NAME__
50
+ PROJECT_MNT_DIR: "__PROJECT_MNT_DIR__" # @wizard ask:/mnt/__PROJECT_NAME__
51
51
 
52
52
  # Traefik & Preview 도메인 설정 (환경 구축 후 수정 금지)
53
53
  TRAEFIK_NETWORK: traefik-network
54
- PREVIEW_DOMAIN_SUFFIX: pr.suhsaechan.kr
54
+ PREVIEW_DOMAIN_SUFFIX: "__PREVIEW_DOMAIN_SUFFIX__" # @wizard ask:pr.example.com
55
55
  PREVIEW_PORT: '8079'
56
56
 
57
57
  # 🔐 SSH 인증 방식: "password"(Synology·일반 서버) | "key"(AWS EC2·.pem)
@@ -60,7 +60,7 @@ env:
60
60
  SSH_AUTH_METHOD: "password" # @wizard ask:password
61
61
 
62
62
  # SSH 포트 (서버 포트 환경 구축 후 수정 금지)
63
- SSH_PORT: '2022'
63
+ SSH_PORT: "__SSH_PORT__" # @wizard ask:2022
64
64
 
65
65
  # Issue Helper 댓글 마커 (프로젝트별 수정 필요)
66
66
  # Issue 댓글에서 브랜치명을 추출할 때 사용
@@ -87,7 +87,7 @@ env:
87
87
  SSH_AUTH_METHOD: "password" # @wizard ask:password
88
88
 
89
89
  # 🔌 SSH 연결 설정
90
- SSH_PORT: "2022" # SSH 포트 (Synology 기본: 2022)
90
+ SSH_PORT: "__SSH_PORT__" # @wizard ask:2022
91
91
  SSH_COMMAND_TIMEOUT: "600s" # SSH 명령 실행 타임아웃
92
92
  SSH_CONNECTION_TIMEOUT: "60s" # SSH 연결 타임아웃
93
93
 
@@ -45,16 +45,22 @@ permissions:
45
45
  contents: read
46
46
  packages: write
47
47
 
48
+ # ===================================================================
49
+ # 환경 변수 설정
50
+ # ===================================================================
51
+ env:
52
+ JAVA_VERSION: "__JAVA_VERSION__" # @wizard ask:@jdk
53
+
48
54
  jobs:
49
55
  publish:
50
56
  runs-on: ubuntu-latest
51
57
  steps:
52
58
  - uses: actions/checkout@v7
53
59
 
54
- - name: Set up JDK 17
60
+ - name: Set up JDK
55
61
  uses: actions/setup-java@v5
56
62
  with:
57
- java-version: '17'
63
+ java-version: ${{ env.JAVA_VERSION }}
58
64
  distribution: 'temurin'
59
65
 
60
66
  - name: Setup Gradle