project-auto-wizard 0.12.2 → 0.13.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 (86) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +7 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +397 -97
  12. package/payload/version.yml.template +4 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +17 -10
  43. package/src/commands/dry-run.js +54 -1
  44. package/src/commands/full.js +127 -50
  45. package/src/commands/interactive-flutter.js +1 -1
  46. package/src/commands/interactive.js +124 -56
  47. package/src/commands/purge.js +59 -17
  48. package/src/commands/status.js +16 -1
  49. package/src/commands/uninstall.js +58 -13
  50. package/src/context.js +5 -5
  51. package/src/core/baseline.js +20 -5
  52. package/src/core/branches.js +19 -1
  53. package/src/core/branding.js +1 -1
  54. package/src/core/breaking-check.js +14 -19
  55. package/src/core/breaking.js +7 -3
  56. package/src/core/copy/gitignore.js +20 -12
  57. package/src/core/copy/readme.js +24 -5
  58. package/src/core/copy/simple.js +52 -13
  59. package/src/core/copy/workflows.js +90 -47
  60. package/src/core/deploy-style.js +83 -11
  61. package/src/core/detect-fs.js +127 -24
  62. package/src/core/detect.js +131 -47
  63. package/src/core/errors.js +3 -0
  64. package/src/core/flutter-options.js +4 -3
  65. package/src/core/fsutil.js +23 -6
  66. package/src/core/logger.js +55 -19
  67. package/src/core/paths-resolve.js +50 -47
  68. package/src/core/paths.js +26 -1
  69. package/src/core/release-options.js +23 -0
  70. package/src/core/removal-plan.js +75 -19
  71. package/src/core/types.js +113 -0
  72. package/src/core/verify.js +85 -19
  73. package/src/core/version-yml.js +42 -8
  74. package/src/core/wizard-env.js +21 -14
  75. package/src/core/wizard-labels.js +34 -25
  76. package/src/index.js +122 -49
  77. package/src/ui/ansi.js +3 -1
  78. package/src/ui/banner.js +12 -8
  79. package/src/ui/env-plan.js +68 -44
  80. package/src/ui/prompts.js +22 -12
  81. package/src/ui/readline-engine.js +109 -22
  82. package/src/ui/status-cards.js +10 -5
  83. package/src/ui/summary.js +71 -33
  84. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  85. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  86. package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
@@ -6,6 +6,9 @@
6
6
  # same engine chain as the release changelog (user API key -> Copilot CLI,
7
7
  # opt-in via version.yml copilot_ai -> rule-based fallback).
8
8
  # The prompt includes commit subjects plus a capped `git diff --stat`.
9
+ # The summary header shows the version this PR is expected to ship in
10
+ # (current version + the bump the post-merge workflow will apply), not
11
+ # the version that is already released.
9
12
  #
10
13
  # On release PRs (head branch == {{DEVELOP_BRANCH}}) this job is skipped.
11
14
  # PROJECT-COMMON-AUTO-CHANGELOG-CONTROL owns that case: it confirms
@@ -69,13 +72,15 @@ jobs:
69
72
  AI_MODEL: ${{ vars.AI_MODEL }}
70
73
  GITHUB_TOKEN: ${{ github.token }}
71
74
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
75
+ GH_TOKEN: ${{ github.token }}
72
76
  run: |
73
77
  PR_NUMBER=${{ github.event.pull_request.number }}
74
- VERSION=$(python3 .github/scripts/version_manager.py get 2>/dev/null | tail -n 1)
75
- VERSION=${VERSION:-unreleased}
76
78
 
77
79
  git fetch origin {{MAIN_BRANCH}}
78
80
  git log --pretty=%s "origin/{{MAIN_BRANCH}}..HEAD" > commits.txt
81
+ # 본문 푸터 BREAKING CHANGE:는 제목 목록 뒤에 한 줄씩 덧붙인다 — 스크립트는 한 줄을
82
+ # 한 항목으로 읽으므로 본문 전체(%B)를 넣으면 설명 줄까지 변경 항목으로 잡힌다.
83
+ git log --pretty=%b "origin/{{MAIN_BRANCH}}..HEAD" | grep -E '^BREAKING[ -]CHANGE[[:space:]]*:' >> commits.txt || true
79
84
  git diff --stat "origin/{{MAIN_BRANCH}}...HEAD" > _full_diff_stat.txt
80
85
  if [ "$(wc -l < _full_diff_stat.txt)" -gt 49 ]; then
81
86
  { head -49 _full_diff_stat.txt; tail -1 _full_diff_stat.txt; } > diff_stat.txt
@@ -84,6 +89,21 @@ jobs:
84
89
  fi
85
90
  rm -f _full_diff_stat.txt
86
91
 
92
+ # 헤더에는 이미 발행된 현재 버전이 아니라 이 PR이 병합되면 붙을 예상 버전을 보인다.
93
+ # 병합 뒤 버전을 올리는 쪽과 같은 규칙: trunk-based는 RELEASE-PUBLISH가 semver_auto면
94
+ # 커밋으로 승격 폭을 정하고, pr-flow에서 {{MAIN_BRANCH}}로 바로 들어가는 PR은
95
+ # VERSION-CONTROL 안전망이 patch를 올린다. 예상치라 AI 보조 판정 없이 규칙만 쓴다.
96
+ CURRENT_VERSION=$(python3 .github/scripts/version_manager.py get 2>/dev/null | tail -n 1)
97
+ MODE=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?branches:.*?mode:\s*\"?([A-Za-z-]+)", t, re.S); print(m.group(1) if m else "pr-flow")' 2>/dev/null || echo "pr-flow")
98
+ SEMVER_AUTO=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?semver_auto:\s*\"?(true|false)", t, re.S); print(m.group(1) if m else "false")' 2>/dev/null || echo "false")
99
+ BUMP="patch"
100
+ if [ "$MODE" = "trunk-based" ] && [ "$SEMVER_AUTO" = "true" ]; then
101
+ BUMP=$(AI_API_KEY="" COPILOT_AI="false" python3 .github/scripts/changelog_manager.py classify-bump --commits-file commits.txt 2>/dev/null | tail -n 1)
102
+ fi
103
+ VERSION=$(python3 -c 'import sys; sys.path.insert(0, ".github/scripts"); from version_manager import increment_version; print(increment_version(sys.argv[1], sys.argv[2]))' "$CURRENT_VERSION" "${BUMP:-patch}" 2>/dev/null || echo "")
104
+ VERSION=${VERSION:-Unreleased}
105
+ echo "expected next version: $VERSION (current: ${CURRENT_VERSION:-none}, mode: $MODE, bump: ${BUMP:-patch})"
106
+
87
107
  SUMMARY_RESULT=$(python3 .github/scripts/changelog_manager.py ai-summary \
88
108
  --commits-file commits.txt \
89
109
  --version "$VERSION" \
@@ -91,23 +111,33 @@ jobs:
91
111
  --pr-title "$PR_TITLE" \
92
112
  --diff-stat-file diff_stat.txt | tail -n 1)
93
113
  ENGINE=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("engine") or "unknown")' "$SUMMARY_RESULT" 2>/dev/null || echo "unknown")
114
+ # fallback이면 사유(스크립트가 ::warning::으로도 남김)를 engine 줄에 붙인다 — 잡 로그를 열지 않아도 원인이 보이게
115
+ FALLBACK_REASON=$(python3 -c 'import html,json,sys; print(html.escape((json.loads(sys.argv[1]).get("fallback_reason") or "")[:200]))' "$SUMMARY_RESULT" 2>/dev/null || echo "")
116
+ ENGINE_LINE="$ENGINE${FALLBACK_REASON:+ ($FALLBACK_REASON)}"
117
+ echo "engine: $ENGINE_LINE" >> "$GITHUB_STEP_SUMMARY"
94
118
 
119
+ # push마다 댓글이 쌓이지 않도록 이 봇이 남긴 요약 댓글을 표식으로 찾아 최신 내용으로 교체한다
120
+ MARKER="<!-- project-auto-wizard:pr-summary -->"
95
121
  {
122
+ echo "$MARKER"
96
123
  echo "📋 **PR Summary (project-auto-wizard)**"
97
124
  echo ""
98
125
  cat summary.md
99
126
  echo ""
100
- echo "<sub>engine: ${ENGINE}</sub>"
127
+ echo "<sub>engine: ${ENGINE_LINE}</sub>"
101
128
  } > comment_body.md
102
129
 
103
130
  python3 -c 'import json; print(json.dumps({"body": open("comment_body.md", encoding="utf-8").read()}))' > comment_payload.json
104
131
 
105
- curl -sf -H "Authorization: token ${{ github.token }}" \
106
- -H "Content-Type: application/json" \
107
- -X POST \
108
- -d @comment_payload.json \
109
- "https://api.github.com/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" > /dev/null \
110
- || echo "comment post failed — continuing (this workflow never blocks the PR)"
132
+ COMMENT_ID=$(gh api --paginate "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
133
+ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$MARKER\"))) | .id" 2>/dev/null | tail -n 1 || true)
134
+ if [ -n "$COMMENT_ID" ]; then
135
+ gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" --input comment_payload.json > /dev/null \
136
+ || echo "comment update failed — continuing (this workflow never blocks the PR)"
137
+ else
138
+ gh api -X POST "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" --input comment_payload.json > /dev/null \
139
+ || echo "comment post failed — continuing (this workflow never blocks the PR)"
140
+ fi
111
141
 
112
142
  - name: Link related issue via branch name
113
143
  continue-on-error: true
@@ -2,11 +2,13 @@
2
2
  # PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml
3
3
  # Release PR pipeline: version confirm + AI changelog + automerge
4
4
  #
5
- # Runs on release PRs ({{DEVELOP_BRANCH}} -> {{MAIN_BRANCH}}) and:
5
+ # Runs on release PRs ({{DEVELOP_BRANCH}} -> {{MAIN_BRANCH}}) that are still
6
+ # open when the run starts (a run queued behind the one that merged the PR
7
+ # exits in the precheck job), and:
6
8
  # 1. Confirms the release version and syncs every version file:
7
9
  # - If version.yml metadata.template.options.semver_auto is true
8
- # (default): classifies commits (feat -> minor, `!` marker -> major,
9
- # else patch; AI-assisted patch->minor upgrade for unclassified
10
+ # (default): classifies commits (feat -> minor, `!` marker or a
11
+ # `BREAKING CHANGE:` body footer -> major, else patch; AI-assisted patch->minor upgrade for unclassified
10
12
  # commits) and bumps accordingly.
11
13
  # - Otherwise: always patch+1 (legacy behavior).
12
14
  # 2. Generates the release summary locally with changelog_manager.py
@@ -15,14 +17,17 @@
15
17
  # - The AI prompt includes commit subjects plus a capped
16
18
  # `git diff --stat` (file-level change summary, no diff body)
17
19
  # for richer context without token-cost blowup.
18
- # 3. Posts the same summary as a PR comment (this replaces
20
+ # 3. Posts the same summary as a PR comment — re-runs edit that comment
21
+ # instead of adding another (this replaces
19
22
  # PROJECT-COMMON-AI-PR-SUMMARY for release PRs, which skips them —
20
23
  # that workflow would otherwise post the pre-bump version, since it
21
24
  # runs before this job's version-confirm commit lands and that
22
25
  # commit's GITHUB_TOKEN push doesn't retrigger pull_request events)
23
26
  # 4. Updates CHANGELOG.json / CHANGELOG.md and commits to the PR head
24
27
  # branch with [skip ci]
25
- # 5. Enables automerge (gh pr merge --auto --merge)
28
+ # 5. Enables automerge (gh pr merge --auto --merge, falling back to a
29
+ # direct merge), retrying with backoff while GitHub catches up with the
30
+ # freshly pushed head; an already-merged PR counts as success
26
31
  #
27
32
  # The merge-commit subject carries the release-confirm pattern
28
33
  # "chore(release):" — keep in sync with PROJECT-COMMON-VERSION-CONTROL
@@ -41,6 +46,8 @@
41
46
  # polls until the automerge actually lands, then wakes RELEASE-PUBLISH via
42
47
  # workflow_dispatch — GITHUB_TOKEN can always trigger that event, unlike
43
48
  # the push/release events blocked by GitHub's anti-recursion policy.
49
+ # RELEASE-PUBLISH then dispatches the {{MAIN_BRANCH}} deploy workflows the
50
+ # same way, so a PAT-less install still deploys every release.
44
51
 
45
52
  name: PROJECT-AUTO-CHANGELOG-CONTROL
46
53
 
@@ -61,9 +68,13 @@ permissions:
61
68
  pull-requests: write
62
69
  copilot-requests: write
63
70
 
71
+ env:
72
+ # 스크립트 import가 .github/scripts/__pycache__를 만들어 봇 커밋에 섞이지 않게 한다
73
+ PYTHONDONTWRITEBYTECODE: "1"
74
+
64
75
  jobs:
65
- changelog-and-merge:
66
- name: Version confirm + changelog + automerge
76
+ precheck:
77
+ name: Skip if the release PR is no longer open
67
78
  # Guard: only release PRs whose head is the develop branch of THIS
68
79
  # repository enter the pipeline (protects against accidental feature
69
80
  # PRs opened against production and against fork PRs with a spoofed
@@ -73,6 +84,35 @@ jobs:
73
84
  github.event.pull_request.head.repo.full_name == github.repository &&
74
85
  github.event.pull_request.head.ref == '{{DEVELOP_BRANCH}}'
75
86
  runs-on: ubuntu-latest
87
+ permissions:
88
+ pull-requests: read
89
+ outputs:
90
+ open: ${{ steps.state.outputs.open }}
91
+ steps:
92
+ - name: Read the current PR state
93
+ id: state
94
+ env:
95
+ GH_TOKEN: ${{ github.token }}
96
+ GH_REPO: ${{ github.repository }}
97
+ run: |
98
+ PR_NUMBER=${{ github.event.pull_request.number }}
99
+ # 파이프라인 도중 {{DEVELOP_BRANCH}}에 push하면 그 실행은 대기열에서 기다렸다가 이미 병합된
100
+ # PR을 다시 처리한다 — 빈 요약 댓글, 중복 CHANGELOG 항목, {{DEVELOP_BRANCH}}에만 남는 확정 커밋이 생긴다.
101
+ # 이벤트 시점이 아니라 지금 상태를 본다. 조회에 실패하면 릴리스를 막지 않고 진행한다.
102
+ STATE=$(gh pr view "$PR_NUMBER" --json state -q .state 2>/dev/null || echo "UNKNOWN")
103
+ if [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; then
104
+ echo "open=false" >> "$GITHUB_OUTPUT"
105
+ echo "::notice::PR #$PR_NUMBER is already $STATE — skipping the release pipeline"
106
+ else
107
+ echo "open=true" >> "$GITHUB_OUTPUT"
108
+ echo "PR #$PR_NUMBER state: $STATE"
109
+ fi
110
+
111
+ changelog-and-merge:
112
+ name: Version confirm + changelog + automerge
113
+ needs: precheck
114
+ if: needs.precheck.outputs.open == 'true'
115
+ runs-on: ubuntu-latest
76
116
  steps:
77
117
  - name: Checkout PR head
78
118
  uses: actions/checkout@v7
@@ -112,6 +152,9 @@ jobs:
112
152
  run: |
113
153
  git fetch origin {{MAIN_BRANCH}}
114
154
  git log --pretty=%s "origin/{{MAIN_BRANCH}}..HEAD" > commits.txt
155
+ # 본문 푸터 BREAKING CHANGE:는 제목 목록 뒤에 한 줄씩 덧붙인다 — 스크립트는 한 줄을
156
+ # 한 항목으로 읽으므로 본문 전체(%B)를 넣으면 설명 줄까지 변경 항목으로 잡힌다.
157
+ git log --pretty=%b "origin/{{MAIN_BRANCH}}..HEAD" | grep -E '^BREAKING[ -]CHANGE[[:space:]]*:' >> commits.txt || true
115
158
  git diff --stat "origin/{{MAIN_BRANCH}}...HEAD" > _full_diff_stat.txt
116
159
  if [ "$(wc -l < _full_diff_stat.txt)" -gt 49 ]; then
117
160
  { head -49 _full_diff_stat.txt; tail -1 _full_diff_stat.txt; } > diff_stat.txt
@@ -164,28 +207,46 @@ jobs:
164
207
  GITHUB_TOKEN: ${{ github.token }}
165
208
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
166
209
  run: |
167
- # Idempotency: if the PR head is already this workflow's own
168
- # version-confirm commit (re-run / synchronize retrigger), do not
169
- # bump a second time — reuse the confirmed version.
210
+ # 멱등: 이 PR에서 이미 확정한(아직 발행 전) 버전을 다시 올리지 않는다.
211
+ # HEAD 커밋 메시지만 보면 확정 커밋 위에 커밋이 더 쌓일 때({{DEVELOP_BRANCH}} 추가 push,
212
+ # {{MAIN_BRANCH}} 역병합) 가드가 풀려 발행되지 않은 버전이 생기므로 버전과 태그로 판정한다.
170
213
  HEAD_MSG=$(git log -1 --pretty=%s)
171
- if printf '%s\n' "$HEAD_MSG" | grep -q "^chore(version): confirm v.* \[skip ci\]"; then
172
- NEW_VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
173
- echo "version already confirmed by a previous run — reusing $NEW_VERSION (no re-increment)"
214
+ HEAD_VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
215
+ MAIN_VERSION=$(git show "origin/{{MAIN_BRANCH}}:version.yml" 2>/dev/null | grep -m1 '^version:' | sed 's/version:[ \t]*"\{0,1\}\([0-9][0-9.]*\)"\{0,1\}.*/\1/' || true)
216
+ version_gt() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ]; }
217
+ is_tagged() { git ls-remote --exit-code --tags origin "refs/tags/v$1" >/dev/null 2>&1; }
218
+
219
+ if [ "${{ steps.semver_options.outputs.semver_auto }}" = "true" ]; then
220
+ BUMP=$(python3 .github/scripts/changelog_manager.py classify-bump --commits-file commits.txt | tail -n 1)
174
221
  else
175
- if [ "${{ steps.semver_options.outputs.semver_auto }}" = "true" ]; then
176
- BUMP=$(python3 .github/scripts/changelog_manager.py classify-bump --commits-file commits.txt | tail -n 1)
222
+ BUMP="patch"
223
+ fi
224
+ echo "bump level: $BUMP"
225
+
226
+ if [ -n "$MAIN_VERSION" ] && [ -n "$HEAD_VERSION" ] && version_gt "$HEAD_VERSION" "$MAIN_VERSION" && ! is_tagged "$HEAD_VERSION"; then
227
+ # 이미 확정된 버전 — {{MAIN_BRANCH}} 버전 기준으로 다시 계산해 더 높아질 때만(예: 확정 뒤 feat 추가) 올린다.
228
+ # 버전을 건너뛰지 않고, AI 보조 판정이 재실행마다 달라도 낮아지지 않는다.
229
+ TARGET=$(python3 -c 'import sys; sys.path.insert(0, ".github/scripts"); from version_manager import increment_version; print(increment_version(sys.argv[1], sys.argv[2]))' "$MAIN_VERSION" "$BUMP")
230
+ if version_gt "$TARGET" "$HEAD_VERSION"; then
231
+ NEW_VERSION=$(python3 .github/scripts/version_manager.py set "$TARGET" | tail -n 1)
232
+ echo "confirmed version raised: $HEAD_VERSION -> $NEW_VERSION ($BUMP from {{MAIN_BRANCH}} $MAIN_VERSION)"
177
233
  else
178
- BUMP="patch"
234
+ NEW_VERSION="$HEAD_VERSION"
235
+ echo "version already confirmed and not yet released — reusing $NEW_VERSION ({{MAIN_BRANCH}} is $MAIN_VERSION)"
179
236
  fi
180
- echo "bump level: $BUMP"
237
+ elif [ -z "$MAIN_VERSION" ] && printf '%s\n' "$HEAD_MSG" | grep -q "^chore(version): confirm v.* \[skip ci\]"; then
238
+ # {{MAIN_BRANCH}}에 아직 version.yml이 없는 첫 릴리스의 재실행
239
+ NEW_VERSION="$HEAD_VERSION"
240
+ echo "version already confirmed by a previous run — reusing $NEW_VERSION (no re-increment)"
241
+ else
181
242
  NEW_VERSION=$(python3 .github/scripts/version_manager.py increment --bump "$BUMP" | tail -n 1)
182
- if [ -z "$NEW_VERSION" ]; then
183
- echo "version bump failed"
184
- exit 1
185
- fi
186
243
  python3 .github/scripts/version_manager.py sync
187
244
  echo "release version confirmed: $NEW_VERSION ($BUMP)"
188
245
  fi
246
+ if [ -z "$NEW_VERSION" ]; then
247
+ echo "version bump failed"
248
+ exit 1
249
+ fi
189
250
  echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
190
251
 
191
252
  - name: Generate summary with the AI engine chain
@@ -209,8 +270,13 @@ jobs:
209
270
  --pr-title "$PR_TITLE" \
210
271
  --diff-stat-file diff_stat.txt | tail -n 1)
211
272
  ENGINE=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("engine") or "unknown")' "$SUMMARY_RESULT" 2>/dev/null || echo "unknown")
212
- echo "summary engine: $ENGINE"
273
+ # fallback이면 사유(스크립트가 ::warning::으로도 남김)를 engine 줄에 붙인다 — 잡 로그를 열지 않아도 원인이 보이게
274
+ FALLBACK_REASON=$(python3 -c 'import html,json,sys; print(html.escape((json.loads(sys.argv[1]).get("fallback_reason") or "")[:200]))' "$SUMMARY_RESULT" 2>/dev/null || echo "")
275
+ ENGINE_LINE="$ENGINE${FALLBACK_REASON:+ ($FALLBACK_REASON)}"
276
+ echo "summary engine: $ENGINE_LINE"
277
+ echo "engine: $ENGINE_LINE" >> "$GITHUB_STEP_SUMMARY"
213
278
  echo "engine=$ENGINE" >> $GITHUB_OUTPUT
279
+ echo "fallback_reason=$FALLBACK_REASON" >> $GITHUB_OUTPUT
214
280
 
215
281
  # update-from-summary reads ./pr_body.md
216
282
  cp summary.md pr_body.md
@@ -219,25 +285,35 @@ jobs:
219
285
  continue-on-error: true
220
286
  env:
221
287
  ENGINE: ${{ steps.summary.outputs.engine }}
288
+ FALLBACK_REASON: ${{ steps.summary.outputs.fallback_reason }}
289
+ GH_TOKEN: ${{ github.token }}
222
290
  run: |
223
291
  PR_NUMBER=${{ github.event.pull_request.number }}
292
+ # 재실행·재동기화 때 댓글이 쌓이지 않도록 이 봇이 남긴 요약 댓글을 표식으로 찾아 교체한다
293
+ MARKER="<!-- project-auto-wizard:pr-summary -->"
224
294
 
225
295
  {
296
+ echo "$MARKER"
226
297
  echo "📋 **PR Summary (project-auto-wizard)**"
227
298
  echo ""
228
299
  cat summary.md
229
300
  echo ""
230
- echo "<sub>engine: ${ENGINE:-unknown}</sub>"
301
+ echo "<sub>engine: ${ENGINE:-unknown}${FALLBACK_REASON:+ ($FALLBACK_REASON)}</sub>"
231
302
  } > comment_body.md
232
303
 
233
304
  python3 -c 'import json; print(json.dumps({"body": open("comment_body.md", encoding="utf-8").read()}))' > comment_payload.json
234
305
 
235
- curl -sf -H "Authorization: token ${{ github.token }}" \
236
- -H "Content-Type: application/json" \
237
- -X POST \
238
- -d @comment_payload.json \
239
- "https://api.github.com/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" > /dev/null \
240
- || echo "comment post failed — continuing (this workflow never blocks the release)"
306
+ COMMENT_ID=$(gh api --paginate "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
307
+ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$MARKER\"))) | .id" 2>/dev/null | tail -n 1 || true)
308
+ if [ -n "$COMMENT_ID" ]; then
309
+ gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" --input comment_payload.json > /dev/null \
310
+ && echo "summary comment updated (#${COMMENT_ID})" \
311
+ || echo "comment update failed — continuing (this workflow never blocks the release)"
312
+ else
313
+ gh api -X POST "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" --input comment_payload.json > /dev/null \
314
+ && echo "summary comment posted" \
315
+ || echo "comment post failed — continuing (this workflow never blocks the release)"
316
+ fi
241
317
 
242
318
  rm -f comment_body.md comment_payload.json
243
319
 
@@ -258,6 +334,8 @@ jobs:
258
334
  python3 .github/scripts/changelog_manager.py generate-md
259
335
 
260
336
  - name: Commit release docs to the PR head branch
337
+ env:
338
+ GH_TOKEN: ${{ github.token }}
261
339
  run: |
262
340
  HEAD_BRANCH="${{ github.event.pull_request.head.ref }}"
263
341
  VERSION="${{ steps.bump.outputs.new_version }}"
@@ -265,7 +343,19 @@ jobs:
265
343
  # Working files must not land in the repo
266
344
  rm -f pr_body.md summary.md commits.txt diff_stat.txt comment_body.md comment_payload.json commit_shas.txt merged_prs.json
267
345
 
268
- git add -A
346
+ # "Re-run failed jobs"는 precheck 결과를 재사용한다 — 그사이 PR이 병합·종료됐다면
347
+ # 확정 커밋을 올리지 않는다 (병합 뒤 {{DEVELOP_BRANCH}}에만 남는 커밋이 된다).
348
+ PR_STATE=$(gh pr view "${{ github.event.pull_request.number }}" --json state -q .state 2>/dev/null || echo "UNKNOWN")
349
+ if [ "$PR_STATE" = "MERGED" ] || [ "$PR_STATE" = "CLOSED" ]; then
350
+ echo "::notice::PR is already $PR_STATE — not pushing release docs"
351
+ exit 0
352
+ fi
353
+
354
+ # -A는 러너가 만든 임시 파일·캐시까지 사용자 레포에 커밋한다.
355
+ # 버전 파일은 이미 추적 중이라 -u로 충분하고, 첫 릴리스에 새로 생기는
356
+ # CHANGELOG만 따로 올린다 (.gitignore에 있으면 건너뛴다).
357
+ git add -u
358
+ git ls-files -z --others --exclude-standard -- CHANGELOG.json CHANGELOG.md | xargs -0 -r git add --
269
359
 
270
360
  if git diff --staged --quiet; then
271
361
  echo "no changes to commit"
@@ -315,18 +405,55 @@ jobs:
315
405
  # Merge-commit subject carries the release-confirm pattern —
316
406
  # VERSION-CONTROL skips on it and RELEASE-PUBLISH proceeds on it.
317
407
  SUBJECT="chore(release): v${VERSION} (PR #${PR_NUMBER})"
408
+ ERR_FILE="${RUNNER_TEMP:-/tmp}/merge_err.txt"
318
409
 
319
- if gh pr merge "$PR_NUMBER" --auto --merge --subject "$SUBJECT"; then
320
- echo "automerge enabled: $SUBJECT"
321
- elif gh pr merge "$PR_NUMBER" --merge --subject "$SUBJECT"; then
322
- echo "automerge unavailable — merged directly: $SUBJECT"
323
- else
324
- # Both attempts failed — diagnose instead of pretending success.
410
+ # 확정 커밋 push 직후에는 GitHub가 PR head 갱신을 아직 반영하지 못해 "Head branch is
411
+ # out of date"·"not mergeable"로 간헐 실패한다. 간격을 늘려 가며 다시 시도한다.
412
+ MERGED=false
413
+ MAX_ATTEMPTS=4
414
+ for ATTEMPT in $(seq 1 $MAX_ATTEMPTS); do
415
+ if [ "$(gh pr view "$PR_NUMBER" --json state -q .state 2>/dev/null)" = "MERGED" ]; then
416
+ echo "PR #$PR_NUMBER is already merged"
417
+ MERGED=true
418
+ break
419
+ fi
420
+ if gh pr merge "$PR_NUMBER" --auto --merge --subject "$SUBJECT" 2> "$ERR_FILE"; then
421
+ echo "automerge enabled: $SUBJECT"
422
+ MERGED=true
423
+ break
424
+ fi
425
+ cat "$ERR_FILE" >&2
426
+ if gh pr merge "$PR_NUMBER" --merge --subject "$SUBJECT" 2> "$ERR_FILE"; then
427
+ echo "automerge unavailable — merged directly: $SUBJECT"
428
+ MERGED=true
429
+ break
430
+ fi
431
+ cat "$ERR_FILE" >&2
432
+ [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ] && break
433
+ # 충돌은 기다려도 풀리지 않는다
434
+ [ "$(gh pr view "$PR_NUMBER" --json mergeable -q .mergeable 2>/dev/null)" = "CONFLICTING" ] && break
435
+ if grep -qi "out of date" "$ERR_FILE"; then
436
+ # 브랜치 보호의 "최신 상태 요구"로 실제로 뒤처졌다면 base를 head에 병합해 따라잡는다
437
+ # (단순 반영 지연이면 "새 커밋 없음"으로 거절되고 그대로 재시도한다)
438
+ gh api -X PUT "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/update-branch" > /dev/null 2>&1 \
439
+ && echo "head branch updated from base" || true
440
+ fi
441
+ WAIT=$((ATTEMPT * 10))
442
+ echo "merge attempt $ATTEMPT/$MAX_ATTEMPTS failed — retrying in ${WAIT}s"
443
+ sleep "$WAIT"
444
+ done
445
+
446
+ if [ "$MERGED" != "true" ]; then
447
+ # All attempts failed — diagnose instead of pretending success.
448
+ MERGEABLE=$(gh pr view "$PR_NUMBER" --json mergeable -q .mergeable 2>/dev/null || echo "unknown")
325
449
  ALLOW_MERGE_COMMIT=$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.allow_merge_commit' 2>/dev/null || echo "unknown")
326
- if [ "$ALLOW_MERGE_COMMIT" = "false" ]; then
450
+ if [ "$MERGEABLE" = "CONFLICTING" ]; then
451
+ # 주로 {{MAIN_BRANCH}} 직접 push 뒤 안전망 버전 bump가 {{DEVELOP_BRANCH}}에 반영되지 않았을 때다
452
+ echo "::error::릴리스 PR이 {{MAIN_BRANCH}}와 충돌합니다 (버전 파일·CHANGELOG 등) — {{MAIN_BRANCH}}에만 있는 커밋을 {{DEVELOP_BRANCH}}에 병합한 뒤 push하세요: git checkout {{DEVELOP_BRANCH}} && git merge origin/{{MAIN_BRANCH}} (버전 줄 충돌은 어느 쪽을 택해도 다음 실행이 알맞게 다시 확정합니다)"
453
+ elif [ "$ALLOW_MERGE_COMMIT" = "false" ]; then
327
454
  echo "::error::이 레포는 merge commit이 비활성화되어 있습니다 — Settings에서 'Allow merge commits'를 켜세요; 릴리스 감지는 merge commit subject를 사용합니다"
328
455
  else
329
- echo "::error::PR merge failed (allow_merge_commit=$ALLOW_MERGE_COMMIT) — check branch protection rules, required checks, and token permissions"
456
+ echo "::error::PR merge failed after $MAX_ATTEMPTS attempts (allow_merge_commit=$ALLOW_MERGE_COMMIT) — check branch protection rules, required checks, and token permissions"
330
457
  fi
331
458
  exit 1
332
459
  fi
@@ -33,17 +33,26 @@
33
33
  # * true: v1.0.0 (2025-08-16)
34
34
  # * false: v1.0.0
35
35
  #
36
- # Note (safety-net path): on a direct push to {{MAIN_BRANCH}} this
37
- # workflow reads the pre-bump version — PROJECT-VERSION-CONTROL bumps
38
- # afterwards with a [skip ci] commit that does not re-trigger this
39
- # workflow, so the README stays one version behind until the next
40
- # release/push. Run this workflow manually (workflow_dispatch) to catch up.
36
+ # Note: pushes made with GITHUB_TOKEN (a release PR merged without
37
+ # WORKFLOW_PAT, the [skip ci] version commits of the safety net and of
38
+ # trunk-based releases) never trigger this workflow. RELEASE-PUBLISH
39
+ # therefore dispatches it right after publishing a release, so the
40
+ # README always ends on the released version. Run it manually
41
+ # (workflow_dispatch) to catch up at any other time.
42
+ #
43
+ # The version line under the AUTO-VERSION-SECTION marker is already
44
+ # rewritten inside the release commit itself (version_manager.py
45
+ # increment/set), so the release tag snapshot shows the released
46
+ # version. This workflow still inserts a missing line/marker and
47
+ # refreshes the date.
41
48
 
42
49
  name: README VERSION UPDATE
43
50
 
44
51
  env:
45
52
  # Whether to show the date next to the version (true/false)
46
53
  SHOW_DATE: true
54
+ # 스크립트 import가 .github/scripts/__pycache__를 만들어 봇 커밋에 섞이지 않게 한다
55
+ PYTHONDONTWRITEBYTECODE: "1"
47
56
 
48
57
  concurrency:
49
58
  group: readme-version-update