project-auto-wizard 0.15.0 → 0.15.1

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 (59) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +1 -1
  5. package/package.json +1 -1
  6. package/payload/config/breaking-changes.json +31 -5
  7. package/payload/config/wizard-prompts.yml +0 -4
  8. package/payload/scripts/messages.py +156 -26
  9. package/payload/scripts/version_manager.py +69 -40
  10. package/payload/version.yml.template +2 -2
  11. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +3 -0
  12. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +15 -1
  13. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +61 -12
  14. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +46 -10
  15. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +40 -11
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +24 -8
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +29 -10
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +57 -23
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +155 -72
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +49 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +162 -62
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +184 -87
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +38 -15
  24. package/payload/workflows/go/PROJECT-GO-CI.yaml +14 -5
  25. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +375 -120
  26. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +9 -4
  27. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +12 -4
  28. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +375 -120
  29. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +9 -4
  30. package/payload/workflows/react/PROJECT-REACT-CI.yaml +38 -9
  31. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +19 -5
  32. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +71 -35
  33. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +9 -6
  34. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +7 -4
  35. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +425 -144
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +7 -4
  37. package/src/cli/args.js +17 -7
  38. package/src/commands/doctor.js +11 -0
  39. package/src/commands/dry-run.js +9 -1
  40. package/src/commands/full.js +5 -2
  41. package/src/commands/interactive.js +4 -1
  42. package/src/commands/status.js +14 -1
  43. package/src/core/copy/simple.js +31 -0
  44. package/src/core/copy/workflows.js +2 -2
  45. package/src/core/deploy-style.js +1 -1
  46. package/src/core/detect.js +3 -2
  47. package/src/core/types.js +15 -8
  48. package/src/core/version-yml.js +52 -6
  49. package/src/i18n/catalog/en/cli.js +1 -0
  50. package/src/i18n/catalog/en/commands.js +8 -0
  51. package/src/i18n/catalog/en/core2.js +3 -0
  52. package/src/i18n/catalog/ko/cli.js +1 -0
  53. package/src/i18n/catalog/ko/commands.js +8 -0
  54. package/src/i18n/catalog/ko/core2.js +3 -0
  55. package/src/index.js +4 -1
  56. package/src/ui/env-plan.js +1 -1
  57. package/src/ui/prompts.js +7 -3
  58. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +0 -241
  59. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +0 -330
@@ -95,16 +95,33 @@ def read_file(path):
95
95
 
96
96
  def write_file(path, text):
97
97
  """Write text preserving the dominant line ending of the existing file
98
- on disk (LF stays LF, CRLF stays CRLF — never platform-dependent)."""
98
+ on disk (LF stays LF, CRLF stays CRLF — never platform-dependent).
99
+ Returns False (and leaves the file untouched) when the content is already identical."""
99
100
  p = Path(path)
100
101
  eol = "\n"
102
+ previous = None
101
103
  if p.is_file():
102
104
  with open(p, "r", encoding="utf-8", newline="") as f:
103
- eol = _detect_eol(f.read())
105
+ previous = f.read()
106
+ eol = _detect_eol(previous)
104
107
  if eol != "\n":
105
108
  text = text.replace("\n", eol)
109
+ if previous == text:
110
+ return False
106
111
  with open(p, "w", encoding="utf-8", newline="") as f:
107
112
  f.write(text)
113
+ return True
114
+
115
+
116
+ # Files whose content actually changed in this process. Lets the sync check tell "reconciled" from "already in sync".
117
+ _CHANGED_FILES = []
118
+
119
+
120
+ def write_synced(path, text):
121
+ """write_file() for version files: logs `updated:` only when the content really changed."""
122
+ if write_file(path, text):
123
+ _CHANGED_FILES.append(str(path))
124
+ log(t("version_manager.updated", path=path))
108
125
 
109
126
 
110
127
  # ===================================================================
@@ -176,6 +193,21 @@ def get_current_version():
176
193
  return read_scalar_key("version", "0.0.0")
177
194
 
178
195
 
196
+ # Names that used to be separate types. A version.yml written before the merge may still carry them,
197
+ # and the release workflow can run before the wizard rewrites that file, so they are read as the new name.
198
+ TYPE_ALIASES = {"next": "react"}
199
+
200
+
201
+ def _canonical_types(types):
202
+ """Map aliased type names to their current name, dropping duplicates while keeping order."""
203
+ out = []
204
+ for t in types:
205
+ t = TYPE_ALIASES.get(t, t)
206
+ if t not in out:
207
+ out.append(t)
208
+ return out
209
+
210
+
179
211
  def get_project_types_csv():
180
212
  """Return project_types as a list. Supports both:
181
213
  project_types: ["a", "b"]
@@ -196,7 +228,7 @@ def get_project_types_csv():
196
228
  inner = m.group(1)
197
229
  items = re.findall(r'"([^"]*)"|\'([^\']*)\'', inner)
198
230
  types = [a or b for a, b in items]
199
- return [t for t in types if t]
231
+ return _canonical_types([t for t in types if t])
200
232
 
201
233
  # Block list form:
202
234
  # project_types:
@@ -207,7 +239,7 @@ def get_project_types_csv():
207
239
  block = m.group(1)
208
240
  # trailing comments are allowed on list items too
209
241
  types = re.findall(r'-[ \t]*["\']?([^"\'#\n]+?)["\']?[ \t]*(?:#.*)?$', block, re.MULTILINE)
210
- return [t.strip() for t in types if t.strip()]
242
+ return _canonical_types([t.strip() for t in types if t.strip()])
211
243
 
212
244
  return []
213
245
 
@@ -221,16 +253,19 @@ def get_type_path(project_type, project_types_list=None):
221
253
  if not m:
222
254
  return "."
223
255
  block = m.group(1)
224
- km = re.search(
225
- r'^[ \t]+["\']?' + re.escape(project_type)
226
- + r'["\']?:[ \t]*["\']?([^"\'#\n]+?)["\']?[ \t]*(?:#.*)?$',
227
- block,
228
- re.MULTILINE,
229
- )
230
- if km:
231
- val = km.group(1).strip()
232
- if val and val != "null":
233
- return val
256
+ # The current name first, then any older name that maps to it (e.g. next -> react).
257
+ names = [project_type] + [old for old, new in TYPE_ALIASES.items() if new == project_type]
258
+ for name in names:
259
+ km = re.search(
260
+ r'^[ \t]+["\']?' + re.escape(name)
261
+ + r'["\']?:[ \t]*["\']?([^"\'#\n]+?)["\']?[ \t]*(?:#.*)?$',
262
+ block,
263
+ re.MULTILINE,
264
+ )
265
+ if km:
266
+ val = km.group(1).strip()
267
+ if val and val != "null":
268
+ return val
234
269
  return "."
235
270
 
236
271
 
@@ -419,8 +454,7 @@ def sync_maven(path_dir, new_version):
419
454
  return True
420
455
  new_full = _keep_snapshot(old_version, new_version)
421
456
  new_text, _ = _pom_replace(text, ["version"], new_full)
422
- write_file(root_pom, new_text)
423
- log(t("version_manager.updated", path=root_pom))
457
+ write_synced(root_pom, new_text)
424
458
 
425
459
  root_artifact = _pom_text(text, ["artifactId"])
426
460
  for child in sorted(Path(path_dir).glob("*/pom.xml")):
@@ -432,8 +466,7 @@ def sync_maven(path_dir, new_version):
432
466
  ctext, _ = _pom_replace(ctext, ["parent", "version"], new_full)
433
467
  if _pom_text(ctext, ["version"]) == old_version:
434
468
  ctext, _ = _pom_replace(ctext, ["version"], new_full)
435
- write_file(child, ctext)
436
- log(t("version_manager.updated", path=child))
469
+ write_synced(child, ctext)
437
470
  return True
438
471
 
439
472
 
@@ -524,8 +557,7 @@ def sync_spring(path_dir, new_version):
524
557
  if not matches:
525
558
  log(t("version_manager.warn_spring_no_gradle_line", file=gradle_file))
526
559
  continue
527
- write_file(gradle_file, new_text)
528
- log(t("version_manager.updated", path=gradle_file))
560
+ write_synced(gradle_file, new_text)
529
561
 
530
562
 
531
563
  def _pubspec_build_number(path_dir):
@@ -566,8 +598,7 @@ def sync_flutter(path_dir, new_version, version_code):
566
598
  new_text = pattern.sub(r'\1 ' + full_version, text, count=1)
567
599
  else:
568
600
  new_text = text.rstrip("\n") + f"\nversion: {full_version}\n"
569
- write_file(target, new_text)
570
- log(t("version_manager.updated", path=target))
601
+ write_synced(target, new_text)
571
602
 
572
603
 
573
604
  def _json_indent(text):
@@ -594,8 +625,7 @@ def sync_json_version(target, new_version, key_path):
594
625
  for k in key_path[:-1]:
595
626
  node = node.setdefault(k, {})
596
627
  node[key_path[-1]] = new_version
597
- write_file(target, json.dumps(data, indent=_json_indent(raw), ensure_ascii=False) + "\n")
598
- log(t("version_manager.updated", path=target))
628
+ write_synced(target, json.dumps(data, indent=_json_indent(raw), ensure_ascii=False) + "\n")
599
629
 
600
630
 
601
631
  _TOML_HEADER_RE = re.compile(r'^[ \t]*\[+[ \t]*([^\]\n]+?)[ \t]*\]+[ \t]*(?:#.*)?$', re.MULTILINE)
@@ -645,8 +675,7 @@ def sync_python(path_dir, new_version):
645
675
  return
646
676
  for target, (start, end) in spans:
647
677
  text = read_file(target)
648
- write_file(target, text[:start] + new_version + text[end:])
649
- log(t("version_manager.updated", path=target))
678
+ write_synced(target, text[:start] + new_version + text[end:])
650
679
 
651
680
 
652
681
  _PLIST_VERSION_RE = re.compile(r'(<key>CFBundleShortVersionString</key>\s*<string>)([^<]*)(</string>)')
@@ -676,8 +705,7 @@ def sync_react_native(path_dir, new_version):
676
705
  log(t("version_manager.skipped_plist", file=plist_file))
677
706
  continue
678
707
  new_text = _PLIST_VERSION_RE.sub(lambda mm: mm.group(1) + new_version + mm.group(3), text)
679
- write_file(plist_file, new_text)
680
- log(t("version_manager.updated", path=plist_file))
708
+ write_synced(plist_file, new_text)
681
709
  found_plist = True
682
710
  else:
683
711
  log(t("version_manager.warn_rn_ios_missing", dir=ios_dir))
@@ -686,8 +714,7 @@ def sync_react_native(path_dir, new_version):
686
714
  if gradle_file.is_file():
687
715
  text = read_file(gradle_file)
688
716
  new_text = re.sub(r'versionName\s+"[^"]*"', f'versionName "{new_version}"', text)
689
- write_file(gradle_file, new_text)
690
- log(t("version_manager.updated", path=gradle_file))
717
+ write_synced(gradle_file, new_text)
691
718
  else:
692
719
  log(t("version_manager.warn_rn_gradle_missing", file=gradle_file))
693
720
 
@@ -859,7 +886,6 @@ TYPE_HANDLERS = {
859
886
  # Only types that need a build number compute version_code (this has a pubspec-adjusting side effect).
860
887
  "flutter": TypeHandler(read=_read_flutter, sync=lambda d, v, code: sync_flutter(d, v, code())),
861
888
  "react": _PACKAGE_JSON,
862
- "next": _PACKAGE_JSON,
863
889
  "node": _PACKAGE_JSON,
864
890
  "python": TypeHandler(read=_read_python, sync=lambda d, v, _code: sync_python(d, v)),
865
891
  "react-native": TypeHandler(read=_read_react_native, sync=lambda d, v, _code: sync_react_native(d, v)),
@@ -896,11 +922,12 @@ def sync_versions():
896
922
  primary_type = types[0]
897
923
  project_version = get_project_file_version(primary_type)
898
924
 
899
- log(t("version_manager.sync_check"))
900
- log(t("version_manager.sync_yml_version", version=yml_version))
901
- log(t("version_manager.sync_project_version", version=project_version))
902
-
903
925
  if yml_version != project_version:
926
+ # The detail lines are only useful when the two disagree; an in-sync check prints a single line
927
+ # (this check runs several times per job, so a multi-line block each time is just noise).
928
+ log(t("version_manager.sync_check"))
929
+ log(t("version_manager.sync_yml_version", version=yml_version))
930
+ log(t("version_manager.sync_project_version", version=project_version))
904
931
  if validate_version(yml_version) and validate_version(project_version):
905
932
  higher = get_higher_version(yml_version, project_version)
906
933
  log(t("version_manager.sync_mismatch", version=higher))
@@ -913,11 +940,13 @@ def sync_versions():
913
940
  log(t("version_manager.warn_format_invalid"))
914
941
  return yml_version
915
942
  else:
916
- types = get_project_types_csv()
917
- if types:
918
- log(t("version_manager.sync_multi", version=yml_version))
919
- sync_all_project_files(yml_version)
920
- log(t("version_manager.sync_ok", version=yml_version))
943
+ # The primary type matches, but the other types' files may still lag behind; reconcile them quietly.
944
+ changed_before = len(_CHANGED_FILES)
945
+ sync_all_project_files(yml_version)
946
+ if len(_CHANGED_FILES) > changed_before:
947
+ log(t("version_manager.sync_reconciled", version=yml_version))
948
+ else:
949
+ log(t("version_manager.sync_ok", version=yml_version))
921
950
  return yml_version
922
951
 
923
952
 
@@ -10,7 +10,7 @@
10
10
  # workflows call GitHub Copilot CLI, which consumes GitHub Copilot AI Credits.
11
11
  # Falls back to rule-based summaries whenever Copilot is off or unavailable.
12
12
  # ===
13
- # Supported project types: spring, flutter, next, react, react-native,
13
+ # Supported project types: spring, flutter, react, react-native,
14
14
  # react-native-expo, node, python, basic, go
15
15
  # Multi-type projects: list every type in the project_types array
16
16
  # -> the first entry is the primary type (project_types is the single source of truth)
@@ -20,7 +20,7 @@
20
20
  # Synced files per type:
21
21
  # - spring: build.gradle / build.gradle.kts / pom.xml
22
22
  # - flutter: pubspec.yaml
23
- # - next/react/node: package.json
23
+ # - react/node: package.json
24
24
  # - react-native: Info.plist + build.gradle
25
25
  # - react-native-expo: app.json
26
26
  # - python: pyproject.toml / setup.py
@@ -53,6 +53,7 @@ jobs:
53
53
  - name: Read copilot_ai option from version.yml
54
54
  id: copilot_options
55
55
  run: |
56
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
56
57
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
57
58
  # scoped to the metadata: -> template: -> options: chain so an
58
59
  # unrelated "copilot_ai:" key can never hijack the value
@@ -75,6 +76,7 @@ jobs:
75
76
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
76
77
  GH_TOKEN: ${{ github.token }}
77
78
  run: |
79
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
78
80
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
79
81
  PR_NUMBER=${{ github.event.pull_request.number }}
80
82
 
@@ -154,6 +156,7 @@ jobs:
154
156
  # AUTO-CHANGELOG-CONTROL).
155
157
  HEAD_REF: ${{ github.event.pull_request.head.ref }}
156
158
  run: |
159
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
157
160
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
158
161
  ISSUE_NUM=$(python3 .github/scripts/issue_helper.py extract-branch-issue "$HEAD_REF")
159
162
 
@@ -105,6 +105,7 @@ jobs:
105
105
  GH_TOKEN: ${{ github.token }}
106
106
  GH_REPO: ${{ github.repository }}
107
107
  run: |
108
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
108
109
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
109
110
  PR_NUMBER=${{ github.event.pull_request.number }}
110
111
  # A push to {{DEVELOP_BRANCH}} mid-pipeline queues a run that later reprocesses the
@@ -142,6 +143,7 @@ jobs:
142
143
  - name: Read semver_auto option from version.yml
143
144
  id: semver_options
144
145
  run: |
146
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
145
147
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
146
148
  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")
147
149
  echo "semver_auto=$SEMVER_AUTO" >> $GITHUB_OUTPUT
@@ -150,6 +152,7 @@ jobs:
150
152
  - name: Read copilot_ai option from version.yml
151
153
  id: copilot_options
152
154
  run: |
155
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
153
156
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
154
157
  # scoped to the metadata: -> template: -> options: chain so an
155
158
  # unrelated "copilot_ai:" key can never hijack the value
@@ -165,6 +168,7 @@ jobs:
165
168
  - name: Collect commits since last release
166
169
  id: collect
167
170
  run: |
171
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
168
172
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
169
173
  git fetch origin {{MAIN_BRANCH}}
170
174
  git log --pretty=%s "origin/{{MAIN_BRANCH}}..HEAD" > commits.txt
@@ -187,6 +191,7 @@ jobs:
187
191
  GH_TOKEN: ${{ github.token }}
188
192
  GITHUB_TOKEN: ${{ github.token }}
189
193
  run: |
194
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
190
195
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
191
196
  git log --pretty=%H "origin/{{MAIN_BRANCH}}..HEAD" > commit_shas.txt
192
197
 
@@ -225,6 +230,7 @@ jobs:
225
230
  GITHUB_TOKEN: ${{ github.token }}
226
231
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
227
232
  run: |
233
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
228
234
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
229
235
  # Idempotency: never raise a version this PR already confirmed (not yet published).
230
236
  # Looking only at the HEAD commit message lets the guard slip when more commits
@@ -284,6 +290,7 @@ jobs:
284
290
  GITHUB_TOKEN: ${{ github.token }}
285
291
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
286
292
  run: |
293
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
287
294
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
288
295
  VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
289
296
 
@@ -312,6 +319,7 @@ jobs:
312
319
  FALLBACK_REASON: ${{ steps.summary.outputs.fallback_reason }}
313
320
  GH_TOKEN: ${{ github.token }}
314
321
  run: |
322
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
315
323
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
316
324
  PR_NUMBER=${{ github.event.pull_request.number }}
317
325
  # Find this bot's earlier summary comment by its marker and replace it so re-runs and re-syncs do not pile up comments
@@ -361,9 +369,12 @@ jobs:
361
369
  - name: Commit release docs to the PR head branch
362
370
  env:
363
371
  GH_TOKEN: ${{ github.token }}
372
+ # Passed through env so shell metacharacters in the branch name are never parsed as commands
373
+ PAW_HEAD_REF: ${{ github.event.pull_request.head.ref }}
364
374
  run: |
375
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
365
376
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
366
- HEAD_BRANCH="${{ github.event.pull_request.head.ref }}"
377
+ HEAD_BRANCH="$PAW_HEAD_REF"
367
378
  VERSION="${{ steps.bump.outputs.new_version }}"
368
379
 
369
380
  # Working files must not land in the repo
@@ -426,6 +437,7 @@ jobs:
426
437
  # (RELEASE-PUBLISH etc.); GITHUB_TOKEN merges do not.
427
438
  GH_TOKEN: ${{ secrets.WORKFLOW_PAT || github.token }}
428
439
  run: |
440
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
429
441
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
430
442
  PR_NUMBER=${{ github.event.pull_request.number }}
431
443
  VERSION="${{ steps.bump.outputs.new_version }}"
@@ -514,6 +526,7 @@ jobs:
514
526
  GH_TOKEN: ${{ github.token }}
515
527
  GH_REPO: ${{ github.repository }}
516
528
  run: |
529
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
517
530
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
518
531
  PR_NUMBER=${{ github.event.pull_request.number }}
519
532
  MAX_WAIT=1800
@@ -545,6 +558,7 @@ jobs:
545
558
  GH_TOKEN: ${{ github.token }}
546
559
  GH_REPO: ${{ github.repository }}
547
560
  run: |
561
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
548
562
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
549
563
  gh workflow run PROJECT-COMMON-RELEASE-PUBLISH.yaml --ref {{MAIN_BRANCH}}
550
564
  echo "$(m wf_changelog.release_publish_triggered)"
@@ -39,6 +39,15 @@
39
39
  # README always ends on the released version. Run it manually
40
40
  # (workflow_dispatch) to catch up at any other time.
41
41
  #
42
+ # trunk-based mode: RELEASE-PUBLISH bumps the version, rewrites the README
43
+ # line in its own commit and then dispatches this workflow, so the push
44
+ # run is skipped there. Two workflows committing to the same README line
45
+ # at the same time is what made releases fail on a rebase conflict.
46
+ #
47
+ # Push conflicts: if the README on {{MAIN_BRANCH}} moved in the meantime
48
+ # (a release commit rewrote the same line), a run that shows the same
49
+ # version, or whose version is already stale, is skipped instead of failing.
50
+ #
42
51
  # The version line under the AUTO-VERSION-SECTION marker is already
43
52
  # rewritten inside the release commit itself (version_manager.py
44
53
  # increment/set), so the release tag snapshot shows the released
@@ -83,9 +92,29 @@ jobs:
83
92
  git config --local user.email "action@github.com"
84
93
  git config --local user.name "GitHub Action"
85
94
 
95
+ - name: Detect branch mode
96
+ id: mode
97
+ run: |
98
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
99
+ m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
100
+ # scoped to the metadata: -> template: -> branches: chain so an
101
+ # unrelated top-level "mode:" key can never hijack the value
102
+ 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")
103
+ # A push in trunk-based mode is a release: RELEASE-PUBLISH rewrites the
104
+ # README itself and dispatches this workflow afterwards. Running on the
105
+ # push too would race it on the same README line.
106
+ if [ "$MODE" = "trunk-based" ] && [ "${{ github.event_name }}" = "push" ]; then
107
+ echo "skip=true" >> "$GITHUB_OUTPUT"
108
+ echo "$(m wf_readme.trunk_skip)"
109
+ else
110
+ echo "skip=false" >> "$GITHUB_OUTPUT"
111
+ fi
112
+
86
113
  - name: Check README.md exists
87
114
  id: precheck
115
+ if: steps.mode.outputs.skip != 'true'
88
116
  run: |
117
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
89
118
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
90
119
  if [ -f README.md ]; then
91
120
  echo "readme_exists=true" >> $GITHUB_OUTPUT
@@ -98,6 +127,7 @@ jobs:
98
127
  id: version_info
99
128
  if: steps.precheck.outputs.readme_exists == 'true'
100
129
  run: |
130
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
101
131
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
102
132
  LATEST_VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
103
133
  # Guard: an empty version must never reach the README rewrite —
@@ -114,6 +144,7 @@ jobs:
114
144
  - name: Update version line in README.md
115
145
  if: steps.precheck.outputs.readme_exists == 'true'
116
146
  run: |
147
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
117
148
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
118
149
  LATEST_VERSION="${{ steps.version_info.outputs.latest_version }}"
119
150
  RELEASE_DATE="${{ steps.version_info.outputs.release_date }}"
@@ -258,6 +289,7 @@ jobs:
258
289
  - name: Commit and push changes
259
290
  if: steps.precheck.outputs.readme_exists == 'true'
260
291
  run: |
292
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
261
293
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
262
294
  git add README.md
263
295
 
@@ -268,8 +300,9 @@ jobs:
268
300
 
269
301
  git commit -m "docs(readme): update version to v$VERSION [skip ci]"
270
302
 
271
- # Race-condition guard: pull-rebase then push (up to 3 retries)
272
- MAX_RETRIES=3
303
+ # >>> push-with-retry
304
+ # Race-condition guard: fetch -> rebase -> push, retried
305
+ MAX_RETRIES=5
273
306
  RETRY_COUNT=0
274
307
  PUSH_SUCCESS=false
275
308
 
@@ -281,21 +314,37 @@ jobs:
281
314
  PUSH_SUCCESS=true
282
315
  echo "$(m wf_readme.pushed)"
283
316
  break
284
- else
285
- echo "$(m wf_common.push_failed_syncing)"
286
-
287
- if git pull --rebase origin {{MAIN_BRANCH}}; then
288
- echo "$(m wf_common.rebase_ok)"
289
- else
290
- echo "$(m wf_common.rebase_failed)"
291
- git rebase --abort 2>/dev/null || true
292
- exit 1
293
- fi
294
317
  fi
318
+
319
+ echo "$(m wf_common.push_failed_syncing)"
320
+ git fetch origin {{MAIN_BRANCH}}
321
+ if git rebase origin/{{MAIN_BRANCH}}; then
322
+ echo "$(m wf_common.rebase_ok)"
323
+ continue
324
+ fi
325
+ git rebase --abort 2>/dev/null || true
326
+
327
+ # The same README line was rewritten on the remote (a release commit).
328
+ # Nothing is lost when it already shows this version or is ahead of it.
329
+ REMOTE_VERSION=$(git show origin/{{MAIN_BRANCH}}:version.yml | grep -m1 '^version:' | sed 's/version:[ \t]*"\{0,1\}\([0-9][0-9.]*\)"\{0,1\}.*/\1/')
330
+ if [ -n "$REMOTE_VERSION" ] && [ "$REMOTE_VERSION" != "$VERSION" ]; then
331
+ echo "$(m wf_readme.conflict_skip_stale version="$VERSION" remote_version="$REMOTE_VERSION")"
332
+ PUSH_SUCCESS=true
333
+ break
334
+ fi
335
+ if git show origin/{{MAIN_BRANCH}}:README.md | grep -qF "v${VERSION}"; then
336
+ echo "$(m wf_readme.conflict_skip_same version="$VERSION")"
337
+ PUSH_SUCCESS=true
338
+ break
339
+ fi
340
+
341
+ echo "$(m wf_common.rebase_failed)"
342
+ exit 1
295
343
  done
296
344
 
297
345
  if [ "$PUSH_SUCCESS" = false ]; then
298
346
  echo "$(m wf_common.push_gave_up max_retries="$MAX_RETRIES")"
299
347
  exit 1
300
348
  fi
349
+ # <<< push-with-retry
301
350
  fi
@@ -35,6 +35,13 @@
35
35
  # trigger and workflow_dispatch is dispatched too. A PAT or human merge
36
36
  # already fired the push event, so those are left alone (no double deploy).
37
37
  #
38
+ # The trunk-based docs commit is pushed with fetch -> rebase -> push retries.
39
+ # If README.md conflicts with a concurrent README commit (a date refresh),
40
+ # the remote README is kept and the released version line is reapplied, so
41
+ # a README conflict never stops the tag and the Release from being created.
42
+ # README-VERSION-UPDATE skips its own push run in trunk-based mode; this
43
+ # workflow dispatches it after publishing instead.
44
+ #
38
45
  # Release-confirm pattern: "chore(release):" — keep in sync with
39
46
  # PROJECT-COMMON-AUTO-CHANGELOG-CONTROL and PROJECT-COMMON-VERSION-CONTROL.
40
47
  #
@@ -94,6 +101,7 @@ jobs:
94
101
  - name: Detect branch mode
95
102
  id: mode
96
103
  run: |
104
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
97
105
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
98
106
  # scoped to the metadata: -> template: -> branches: chain so an
99
107
  # unrelated top-level "mode:" key can never hijack the value
@@ -104,6 +112,7 @@ jobs:
104
112
  - name: Release gate
105
113
  id: gate
106
114
  run: |
115
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
107
116
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
108
117
  MODE="${{ steps.mode.outputs.mode }}"
109
118
  EVENT="${{ github.event_name }}"
@@ -167,6 +176,7 @@ jobs:
167
176
  steps.mode.outputs.mode == 'trunk-based' &&
168
177
  github.event_name == 'push'
169
178
  run: |
179
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
170
180
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
171
181
  # scoped to the metadata: -> template: -> options: chain so an
172
182
  # unrelated "semver_auto:" key can never hijack the value
@@ -181,6 +191,7 @@ jobs:
181
191
  steps.mode.outputs.mode == 'trunk-based' &&
182
192
  github.event_name == 'push'
183
193
  run: |
194
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
184
195
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
185
196
  # scoped to the metadata: -> template: -> options: chain so an
186
197
  # unrelated "copilot_ai:" key can never hijack the value
@@ -209,6 +220,7 @@ jobs:
209
220
  GITHUB_TOKEN: ${{ github.token }}
210
221
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
211
222
  run: |
223
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
212
224
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
213
225
  # Idempotency: on a re-run of a failed job the checked-out HEAD may
214
226
  # already be this workflow's own docs commit — never bump twice.
@@ -297,8 +309,12 @@ jobs:
297
309
  else
298
310
  git commit -m "chore(version): release v${NEW_VERSION} docs [skip ci]"
299
311
 
300
- # Race-condition guard: pull-rebase then push (up to 3 retries)
301
- MAX_RETRIES=3
312
+ # >>> push-with-retry
313
+ # Race-condition guard: fetch -> rebase -> push, retried. A concurrent
314
+ # README-only commit (date refresh) touches the same version line and
315
+ # conflicts on rebase; the remote README is kept and this run's version
316
+ # line is reapplied, so a README conflict never blocks the release.
317
+ MAX_RETRIES=5
302
318
  RETRY_COUNT=0
303
319
  PUSH_SUCCESS=false
304
320
 
@@ -310,22 +326,36 @@ jobs:
310
326
  PUSH_SUCCESS=true
311
327
  echo "$(m wf_release.docs_pushed)"
312
328
  break
313
- else
314
- echo "$(m wf_common.push_failed_syncing)"
315
- if git pull --rebase origin {{MAIN_BRANCH}}; then
316
- echo "$(m wf_common.rebase_ok)"
317
- else
318
- echo "$(m wf_common.rebase_failed)"
319
- git rebase --abort 2>/dev/null || true
320
- exit 1
329
+ fi
330
+
331
+ echo "$(m wf_common.push_failed_syncing)"
332
+ git fetch origin {{MAIN_BRANCH}}
333
+ if git rebase origin/{{MAIN_BRANCH}}; then
334
+ echo "$(m wf_common.rebase_ok)"
335
+ continue
336
+ fi
337
+
338
+ # Only README.md conflicting: "ours" is the remote side during a rebase
339
+ if [ "$(git diff --name-only --diff-filter=U)" = "README.md" ]; then
340
+ git checkout --ours -- README.md
341
+ python3 -c 'import sys; sys.path.insert(0, ".github/scripts"); import version_manager as vm; vm.update_readme_version(vm.get_current_version())' || true
342
+ git add README.md
343
+ if GIT_EDITOR=true git rebase --continue; then
344
+ echo "$(m wf_common.readme_conflict_resolved)"
345
+ continue
321
346
  fi
322
347
  fi
348
+
349
+ echo "$(m wf_common.rebase_failed)"
350
+ git rebase --abort 2>/dev/null || true
351
+ exit 1
323
352
  done
324
353
 
325
354
  if [ "$PUSH_SUCCESS" = false ]; then
326
355
  echo "$(m wf_common.push_gave_up max_retries="$MAX_RETRIES")"
327
356
  exit 1
328
357
  fi
358
+ # <<< push-with-retry
329
359
  fi
330
360
 
331
361
  - name: Resolve version + idempotency guard
@@ -334,6 +364,7 @@ jobs:
334
364
  env:
335
365
  GH_TOKEN: ${{ github.token }}
336
366
  run: |
367
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
337
368
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
338
369
  VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
339
370
  if [ -z "$VERSION" ]; then
@@ -365,6 +396,7 @@ jobs:
365
396
  - name: Create and push tag
366
397
  if: steps.gate.outputs.proceed == 'true' && steps.version.outputs.tag_exists != 'true'
367
398
  run: |
399
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
368
400
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
369
401
  VERSION="${{ steps.version.outputs.version }}"
370
402
  git tag "v$VERSION"
@@ -376,6 +408,7 @@ jobs:
376
408
  env:
377
409
  GH_TOKEN: ${{ github.token }}
378
410
  run: |
411
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
379
412
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
380
413
  VERSION="${{ steps.version.outputs.version }}"
381
414
 
@@ -415,6 +448,7 @@ jobs:
415
448
  # default token.
416
449
  GH_TOKEN: ${{ secrets.WORKFLOW_PAT || github.token }}
417
450
  run: |
451
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
418
452
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
419
453
  VERSION="${{ steps.version.outputs.version }}"
420
454
  gh release create "v$VERSION" --title "v$VERSION" --notes-file notes.md
@@ -430,6 +464,7 @@ jobs:
430
464
  env:
431
465
  GH_TOKEN: ${{ github.token }}
432
466
  run: |
467
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
433
468
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
434
469
  if gh workflow run PROJECT-COMMON-README-VERSION-UPDATE.yaml --ref {{MAIN_BRANCH}}; then
435
470
  echo "$(m wf_release.readme_dispatch_ok)"
@@ -457,6 +492,7 @@ jobs:
457
492
  RELEASE_VERSION: ${{ steps.version.outputs.version }}
458
493
  MAIN_BRANCH: "{{MAIN_BRANCH}}"
459
494
  run: |
495
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
460
496
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
461
497
  # Merge commit of this version's release PR - releases that are not merges
462
498
  # (safety-net bump, manual republish) were already deployed by a human push,