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.
- package/README.md +15 -4
- package/package.json +1 -1
- package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
- package/payload/scripts/issue_helper.py +334 -0
- package/payload/scripts/truncate_release_notes.py +89 -0
- package/payload/version.yml.template +1 -0
- package/payload/workflows/common/PROJECT-COMMON-ISSUE-HELPER.yaml +47 -0
- package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +4 -4
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +1 -1
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +1 -1
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +1 -1
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +6 -6
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +1 -1
- package/payload/workflows/spring/{PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml → nexus/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml} +8 -2
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +8 -6
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +2 -2
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +8 -8
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +2 -2
- package/src/cli/args.js +9 -0
- package/src/cli/help.js +2 -1
- package/src/commands/dry-run.js +7 -16
- package/src/commands/full.js +75 -16
- package/src/commands/interactive.js +47 -6
- package/src/commands/uninstall.js +3 -1
- package/src/core/copy/simple.js +2 -2
- package/src/core/copy/workflows.js +32 -29
- package/src/core/deploy-style.js +90 -0
- package/src/core/detect-fs.js +36 -7
- package/src/core/detect.js +68 -3
- package/src/core/install-log.js +182 -0
- package/src/core/options-ask.js +5 -2
- package/src/core/paths-resolve.js +4 -8
- package/src/core/removal-plan.js +1 -1
- package/src/core/verify.js +86 -0
- package/src/core/version-yml.js +31 -4
- package/src/core/wizard-env.js +6 -3
- package/src/index.js +15 -2
- package/src/ui/env-plan.js +63 -33
- package/src/ui/prompts.js +41 -2
- package/src/ui/status-cards.js +7 -3
- package/src/ui/summary.js +56 -6
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ npx project-auto-wizard
|
|
|
17
17
|
[](package.json)
|
|
18
18
|
|
|
19
19
|
<!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
|
|
20
|
-
## 최신 버전 : v0.1
|
|
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**:
|
|
71
|
-
-
|
|
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
|
-
--
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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())
|
|
@@ -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: "
|
|
78
|
+
# 🔧 프로젝트 설정 - 마법사가 설치 시 채웁니다
|
|
79
|
+
PROJECT_NAME: "__PROJECT_NAME__" # @wizard ask:@repo
|
|
80
80
|
ROLE: "backend"
|
|
81
|
-
SERVER_BASE_PATH: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
28
|
+
# - 도메인: {PROJECT_NAME}-pr-{번호}.pr.example.com
|
|
29
29
|
#
|
|
30
30
|
# 📋 사전 요구사항:
|
|
31
31
|
# - Traefik 컨테이너 실행 중 (traefik-network)
|
|
32
|
-
# - 와일드카드 DNS 설정: *.pr.
|
|
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: "
|
|
50
|
-
PROJECT_MNT_DIR: "
|
|
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.
|
|
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:
|
|
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: "
|
|
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
|
|
60
|
+
- name: Set up JDK
|
|
55
61
|
uses: actions/setup-java@v5
|
|
56
62
|
with:
|
|
57
|
-
java-version:
|
|
63
|
+
java-version: ${{ env.JAVA_VERSION }}
|
|
58
64
|
distribution: 'temurin'
|
|
59
65
|
|
|
60
66
|
- name: Setup Gradle
|