project-auto-wizard 0.12.1 → 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 +143 -48
  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 -10
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +166 -41
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -7
  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,14 +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 }}
72
- COPILOT_MODEL: ${{ vars.COPILOT_MODEL }}
75
+ GH_TOKEN: ${{ github.token }}
73
76
  run: |
74
77
  PR_NUMBER=${{ github.event.pull_request.number }}
75
- VERSION=$(python3 .github/scripts/version_manager.py get 2>/dev/null | tail -n 1)
76
- VERSION=${VERSION:-unreleased}
77
78
 
78
79
  git fetch origin {{MAIN_BRANCH}}
79
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
80
84
  git diff --stat "origin/{{MAIN_BRANCH}}...HEAD" > _full_diff_stat.txt
81
85
  if [ "$(wc -l < _full_diff_stat.txt)" -gt 49 ]; then
82
86
  { head -49 _full_diff_stat.txt; tail -1 _full_diff_stat.txt; } > diff_stat.txt
@@ -85,6 +89,21 @@ jobs:
85
89
  fi
86
90
  rm -f _full_diff_stat.txt
87
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
+
88
107
  SUMMARY_RESULT=$(python3 .github/scripts/changelog_manager.py ai-summary \
89
108
  --commits-file commits.txt \
90
109
  --version "$VERSION" \
@@ -92,23 +111,33 @@ jobs:
92
111
  --pr-title "$PR_TITLE" \
93
112
  --diff-stat-file diff_stat.txt | tail -n 1)
94
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"
95
118
 
119
+ # push마다 댓글이 쌓이지 않도록 이 봇이 남긴 요약 댓글을 표식으로 찾아 최신 내용으로 교체한다
120
+ MARKER="<!-- project-auto-wizard:pr-summary -->"
96
121
  {
122
+ echo "$MARKER"
97
123
  echo "📋 **PR Summary (project-auto-wizard)**"
98
124
  echo ""
99
125
  cat summary.md
100
126
  echo ""
101
- echo "<sub>engine: ${ENGINE}</sub>"
127
+ echo "<sub>engine: ${ENGINE_LINE}</sub>"
102
128
  } > comment_body.md
103
129
 
104
130
  python3 -c 'import json; print(json.dumps({"body": open("comment_body.md", encoding="utf-8").read()}))' > comment_payload.json
105
131
 
106
- curl -sf -H "Authorization: token ${{ github.token }}" \
107
- -H "Content-Type: application/json" \
108
- -X POST \
109
- -d @comment_payload.json \
110
- "https://api.github.com/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" > /dev/null \
111
- || 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
112
141
 
113
142
  - name: Link related issue via branch name
114
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
@@ -34,13 +39,15 @@
34
39
  # {{MAIN_BRANCH}}-push workflows (e.g. RELEASE-PUBLISH) for that merge.
35
40
  # - secrets.AI_API_KEY (+ vars.AI_API_BASE_URL / vars.AI_MODEL, all three
36
41
  # required together): preferred summary engine. Falls back to Copilot CLI
37
- # (only when version.yml copilot_ai is true; optional vars.COPILOT_MODEL),
42
+ # (only when version.yml copilot_ai is true; always uses auto model selection),
38
43
  # then to rule-based.
39
44
  #
40
45
  # Without WORKFLOW_PAT, a wait-for-merge-and-trigger-release job
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
@@ -163,30 +206,47 @@ jobs:
163
206
  AI_MODEL: ${{ vars.AI_MODEL }}
164
207
  GITHUB_TOKEN: ${{ github.token }}
165
208
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
166
- COPILOT_MODEL: ${{ vars.COPILOT_MODEL }}
167
209
  run: |
168
- # Idempotency: if the PR head is already this workflow's own
169
- # version-confirm commit (re-run / synchronize retrigger), do not
170
- # bump a second time — reuse the confirmed version.
210
+ # 멱등: 이 PR에서 이미 확정한(아직 발행 전) 버전을 다시 올리지 않는다.
211
+ # HEAD 커밋 메시지만 보면 확정 커밋 위에 커밋이 더 쌓일 때({{DEVELOP_BRANCH}} 추가 push,
212
+ # {{MAIN_BRANCH}} 역병합) 가드가 풀려 발행되지 않은 버전이 생기므로 버전과 태그로 판정한다.
171
213
  HEAD_MSG=$(git log -1 --pretty=%s)
172
- if printf '%s\n' "$HEAD_MSG" | grep -q "^chore(version): confirm v.* \[skip ci\]"; then
173
- NEW_VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
174
- 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)
175
221
  else
176
- if [ "${{ steps.semver_options.outputs.semver_auto }}" = "true" ]; then
177
- 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)"
178
233
  else
179
- BUMP="patch"
234
+ NEW_VERSION="$HEAD_VERSION"
235
+ echo "version already confirmed and not yet released — reusing $NEW_VERSION ({{MAIN_BRANCH}} is $MAIN_VERSION)"
180
236
  fi
181
- 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
182
242
  NEW_VERSION=$(python3 .github/scripts/version_manager.py increment --bump "$BUMP" | tail -n 1)
183
- if [ -z "$NEW_VERSION" ]; then
184
- echo "version bump failed"
185
- exit 1
186
- fi
187
243
  python3 .github/scripts/version_manager.py sync
188
244
  echo "release version confirmed: $NEW_VERSION ($BUMP)"
189
245
  fi
246
+ if [ -z "$NEW_VERSION" ]; then
247
+ echo "version bump failed"
248
+ exit 1
249
+ fi
190
250
  echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
191
251
 
192
252
  - name: Generate summary with the AI engine chain
@@ -200,7 +260,6 @@ jobs:
200
260
  AI_MODEL: ${{ vars.AI_MODEL }}
201
261
  GITHUB_TOKEN: ${{ github.token }}
202
262
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
203
- COPILOT_MODEL: ${{ vars.COPILOT_MODEL }}
204
263
  run: |
205
264
  VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
206
265
 
@@ -211,8 +270,13 @@ jobs:
211
270
  --pr-title "$PR_TITLE" \
212
271
  --diff-stat-file diff_stat.txt | tail -n 1)
213
272
  ENGINE=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("engine") or "unknown")' "$SUMMARY_RESULT" 2>/dev/null || echo "unknown")
214
- 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"
215
278
  echo "engine=$ENGINE" >> $GITHUB_OUTPUT
279
+ echo "fallback_reason=$FALLBACK_REASON" >> $GITHUB_OUTPUT
216
280
 
217
281
  # update-from-summary reads ./pr_body.md
218
282
  cp summary.md pr_body.md
@@ -221,25 +285,35 @@ jobs:
221
285
  continue-on-error: true
222
286
  env:
223
287
  ENGINE: ${{ steps.summary.outputs.engine }}
288
+ FALLBACK_REASON: ${{ steps.summary.outputs.fallback_reason }}
289
+ GH_TOKEN: ${{ github.token }}
224
290
  run: |
225
291
  PR_NUMBER=${{ github.event.pull_request.number }}
292
+ # 재실행·재동기화 때 댓글이 쌓이지 않도록 이 봇이 남긴 요약 댓글을 표식으로 찾아 교체한다
293
+ MARKER="<!-- project-auto-wizard:pr-summary -->"
226
294
 
227
295
  {
296
+ echo "$MARKER"
228
297
  echo "📋 **PR Summary (project-auto-wizard)**"
229
298
  echo ""
230
299
  cat summary.md
231
300
  echo ""
232
- echo "<sub>engine: ${ENGINE:-unknown}</sub>"
301
+ echo "<sub>engine: ${ENGINE:-unknown}${FALLBACK_REASON:+ ($FALLBACK_REASON)}</sub>"
233
302
  } > comment_body.md
234
303
 
235
304
  python3 -c 'import json; print(json.dumps({"body": open("comment_body.md", encoding="utf-8").read()}))' > comment_payload.json
236
305
 
237
- curl -sf -H "Authorization: token ${{ github.token }}" \
238
- -H "Content-Type: application/json" \
239
- -X POST \
240
- -d @comment_payload.json \
241
- "https://api.github.com/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" > /dev/null \
242
- || 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
243
317
 
244
318
  rm -f comment_body.md comment_payload.json
245
319
 
@@ -260,6 +334,8 @@ jobs:
260
334
  python3 .github/scripts/changelog_manager.py generate-md
261
335
 
262
336
  - name: Commit release docs to the PR head branch
337
+ env:
338
+ GH_TOKEN: ${{ github.token }}
263
339
  run: |
264
340
  HEAD_BRANCH="${{ github.event.pull_request.head.ref }}"
265
341
  VERSION="${{ steps.bump.outputs.new_version }}"
@@ -267,7 +343,19 @@ jobs:
267
343
  # Working files must not land in the repo
268
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
269
345
 
270
- 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 --
271
359
 
272
360
  if git diff --staged --quiet; then
273
361
  echo "no changes to commit"
@@ -317,18 +405,55 @@ jobs:
317
405
  # Merge-commit subject carries the release-confirm pattern —
318
406
  # VERSION-CONTROL skips on it and RELEASE-PUBLISH proceeds on it.
319
407
  SUBJECT="chore(release): v${VERSION} (PR #${PR_NUMBER})"
408
+ ERR_FILE="${RUNNER_TEMP:-/tmp}/merge_err.txt"
320
409
 
321
- if gh pr merge "$PR_NUMBER" --auto --merge --subject "$SUBJECT"; then
322
- echo "automerge enabled: $SUBJECT"
323
- elif gh pr merge "$PR_NUMBER" --merge --subject "$SUBJECT"; then
324
- echo "automerge unavailable — merged directly: $SUBJECT"
325
- else
326
- # 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")
327
449
  ALLOW_MERGE_COMMIT=$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.allow_merge_commit' 2>/dev/null || echo "unknown")
328
- 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
329
454
  echo "::error::이 레포는 merge commit이 비활성화되어 있습니다 — Settings에서 'Allow merge commits'를 켜세요; 릴리스 감지는 merge commit subject를 사용합니다"
330
455
  else
331
- 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"
332
457
  fi
333
458
  exit 1
334
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