project-auto-wizard 0.15.0 → 0.16.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 (65) 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 +162 -26
  9. package/payload/scripts/version_manager.py +69 -40
  10. package/payload/version.yml.template +6 -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 +64 -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 +32 -22
  38. package/src/commands/doctor.js +20 -0
  39. package/src/commands/dry-run.js +9 -1
  40. package/src/commands/full.js +9 -4
  41. package/src/commands/interactive.js +29 -30
  42. package/src/commands/status.js +19 -5
  43. package/src/context.js +4 -2
  44. package/src/core/copy/simple.js +31 -0
  45. package/src/core/copy/workflows.js +2 -2
  46. package/src/core/deploy-style.js +1 -1
  47. package/src/core/detect.js +3 -2
  48. package/src/core/options.js +88 -0
  49. package/src/core/release-options.js +6 -23
  50. package/src/core/types.js +15 -8
  51. package/src/core/version-yml.js +68 -26
  52. package/src/i18n/catalog/en/cli.js +3 -1
  53. package/src/i18n/catalog/en/commands.js +15 -0
  54. package/src/i18n/catalog/en/core2.js +3 -0
  55. package/src/i18n/catalog/en/ui.js +1 -0
  56. package/src/i18n/catalog/ko/cli.js +3 -1
  57. package/src/i18n/catalog/ko/commands.js +15 -0
  58. package/src/i18n/catalog/ko/core2.js +3 -0
  59. package/src/i18n/catalog/ko/ui.js +1 -0
  60. package/src/index.js +7 -3
  61. package/src/ui/env-plan.js +1 -1
  62. package/src/ui/prompts.js +9 -5
  63. package/src/ui/status-cards.js +2 -2
  64. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +0 -241
  65. 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
 
@@ -9,8 +9,11 @@
9
9
  # - copilot_ai: opt-in (default false) — true lets the release/PR summary
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
+ # - release_automerge: default true — false stops the release PR (develop -> main)
13
+ # from being merged automatically. Merge it yourself with a merge commit
14
+ # (not squash or rebase); the workflow renames the PR so the release still publishes.
12
15
  # ===
13
- # Supported project types: spring, flutter, next, react, react-native,
16
+ # Supported project types: spring, flutter, react, react-native,
14
17
  # react-native-expo, node, python, basic, go
15
18
  # Multi-type projects: list every type in the project_types array
16
19
  # -> the first entry is the primary type (project_types is the single source of truth)
@@ -20,7 +23,7 @@
20
23
  # Synced files per type:
21
24
  # - spring: build.gradle / build.gradle.kts / pom.xml
22
25
  # - flutter: pubspec.yaml
23
- # - next/react/node: package.json
26
+ # - react/node: package.json
24
27
  # - react-native: Info.plist + build.gradle
25
28
  # - react-native-expo: app.json
26
29
  # - python: pyproject.toml / setup.py
@@ -53,6 +56,7 @@ metadata:
53
56
  options:
54
57
  semver_auto: {{OPT_SEMVER_AUTO}}
55
58
  copilot_ai: {{OPT_COPILOT_AI}}
59
+ release_automerge: {{OPT_RELEASE_AUTOMERGE}}
56
60
  deploy_style: "{{OPT_DEPLOY_STYLE}}" # simple | nginx | traefik | none (server deploy workflows)
57
61
  {{TYPE_OPTIONS}}
58
62
  {{DEPLOY}}
@@ -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
 
@@ -29,6 +29,10 @@
29
29
  # direct merge), retrying with backoff while GitHub catches up with the
30
30
  # freshly pushed head; an already-merged PR counts as success
31
31
  #
32
+ # With version.yml release_automerge: false, step 5 and the wait job are
33
+ # skipped: the PR is renamed to the release-confirm subject and one guidance
34
+ # comment asks the maintainer to merge it with a merge commit (not squash or rebase).
35
+ #
32
36
  # The merge-commit subject carries the release-confirm pattern
33
37
  # "chore(release):" — keep in sync with PROJECT-COMMON-VERSION-CONTROL
34
38
  # and PROJECT-COMMON-RELEASE-PUBLISH.
@@ -105,6 +109,7 @@ jobs:
105
109
  GH_TOKEN: ${{ github.token }}
106
110
  GH_REPO: ${{ github.repository }}
107
111
  run: |
112
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
108
113
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
109
114
  PR_NUMBER=${{ github.event.pull_request.number }}
110
115
  # A push to {{DEVELOP_BRANCH}} mid-pipeline queues a run that later reprocesses the
@@ -126,6 +131,9 @@ jobs:
126
131
  needs: precheck
127
132
  if: needs.precheck.outputs.open == 'true'
128
133
  runs-on: ubuntu-latest
134
+ outputs:
135
+ # Read by wait-for-merge-and-trigger-release: with automerge off there is no merge for it to wait for
136
+ automerge: ${{ steps.automerge_option.outputs.release_automerge }}
129
137
  steps:
130
138
  - name: Checkout PR head
131
139
  uses: actions/checkout@v7
@@ -142,6 +150,7 @@ jobs:
142
150
  - name: Read semver_auto option from version.yml
143
151
  id: semver_options
144
152
  run: |
153
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
145
154
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
146
155
  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
156
  echo "semver_auto=$SEMVER_AUTO" >> $GITHUB_OUTPUT
@@ -150,6 +159,7 @@ jobs:
150
159
  - name: Read copilot_ai option from version.yml
151
160
  id: copilot_options
152
161
  run: |
162
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
153
163
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
154
164
  # scoped to the metadata: -> template: -> options: chain so an
155
165
  # unrelated "copilot_ai:" key can never hijack the value
@@ -157,6 +167,15 @@ jobs:
157
167
  echo "copilot_ai=$COPILOT_AI" >> $GITHUB_OUTPUT
158
168
  echo "$(m wf_common.copilot_ai_option copilot_ai="$COPILOT_AI")"
159
169
 
170
+ - name: Read release_automerge option from version.yml
171
+ id: automerge_option
172
+ run: |
173
+ # Same metadata: -> template: -> options: scope as copilot_ai. A missing key means ON:
174
+ # installs from before the option existed already automerge, and that must not change.
175
+ AUTOMERGE=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?^\s+release_automerge:\s*\"?(true|false)", t, re.S|re.M); print(m.group(1) if m else "true")' 2>/dev/null || echo "true")
176
+ echo "release_automerge=$AUTOMERGE" >> $GITHUB_OUTPUT
177
+ echo "release_automerge option: $AUTOMERGE"
178
+
160
179
  - name: Install Copilot CLI
161
180
  if: steps.copilot_options.outputs.copilot_ai == 'true'
162
181
  continue-on-error: true
@@ -165,6 +184,7 @@ jobs:
165
184
  - name: Collect commits since last release
166
185
  id: collect
167
186
  run: |
187
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
168
188
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
169
189
  git fetch origin {{MAIN_BRANCH}}
170
190
  git log --pretty=%s "origin/{{MAIN_BRANCH}}..HEAD" > commits.txt
@@ -187,6 +207,7 @@ jobs:
187
207
  GH_TOKEN: ${{ github.token }}
188
208
  GITHUB_TOKEN: ${{ github.token }}
189
209
  run: |
210
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
190
211
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
191
212
  git log --pretty=%H "origin/{{MAIN_BRANCH}}..HEAD" > commit_shas.txt
192
213
 
@@ -225,6 +246,7 @@ jobs:
225
246
  GITHUB_TOKEN: ${{ github.token }}
226
247
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
227
248
  run: |
249
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
228
250
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
229
251
  # Idempotency: never raise a version this PR already confirmed (not yet published).
230
252
  # Looking only at the HEAD commit message lets the guard slip when more commits
@@ -284,6 +306,7 @@ jobs:
284
306
  GITHUB_TOKEN: ${{ github.token }}
285
307
  COPILOT_AI: ${{ steps.copilot_options.outputs.copilot_ai }}
286
308
  run: |
309
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
287
310
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
288
311
  VERSION=$(python3 .github/scripts/version_manager.py get | tail -n 1)
289
312
 
@@ -312,6 +335,7 @@ jobs:
312
335
  FALLBACK_REASON: ${{ steps.summary.outputs.fallback_reason }}
313
336
  GH_TOKEN: ${{ github.token }}
314
337
  run: |
338
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
315
339
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
316
340
  PR_NUMBER=${{ github.event.pull_request.number }}
317
341
  # 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 +385,12 @@ jobs:
361
385
  - name: Commit release docs to the PR head branch
362
386
  env:
363
387
  GH_TOKEN: ${{ github.token }}
388
+ # Passed through env so shell metacharacters in the branch name are never parsed as commands
389
+ PAW_HEAD_REF: ${{ github.event.pull_request.head.ref }}
364
390
  run: |
391
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
365
392
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
366
- HEAD_BRANCH="${{ github.event.pull_request.head.ref }}"
393
+ HEAD_BRANCH="$PAW_HEAD_REF"
367
394
  VERSION="${{ steps.bump.outputs.new_version }}"
368
395
 
369
396
  # Working files must not land in the repo
@@ -420,12 +447,44 @@ jobs:
420
447
  fi
421
448
  fi
422
449
 
450
+ - name: Prepare manual release merge (release_automerge=false)
451
+ if: steps.automerge_option.outputs.release_automerge == 'false'
452
+ env:
453
+ GH_TOKEN: ${{ secrets.WORKFLOW_PAT || github.token }}
454
+ run: |
455
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
456
+ m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
457
+ PR_NUMBER=${{ github.event.pull_request.number }}
458
+ VERSION="${{ steps.bump.outputs.new_version }}"
459
+
460
+ # The PR title carries the release-confirm pattern, so a human merge commit (its message includes the PR title)
461
+ # still passes the RELEASE-PUBLISH gate. A failed rename only warns: the run must not fail here.
462
+ SUBJECT="chore(release): v${VERSION} (PR #${PR_NUMBER})"
463
+ if gh pr edit "$PR_NUMBER" --title "$SUBJECT"; then
464
+ echo "$(m wf_changelog.automerge_off subject="$SUBJECT")"
465
+ else
466
+ echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
467
+ fi
468
+
469
+ # One guidance comment per PR: re-runs (synchronize) find it by its marker and leave it alone.
470
+ MARKER='<!-- release-automerge-off -->'
471
+ EXISTING=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
472
+ --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null | head -1 || true)
473
+ if [ -z "$EXISTING" ]; then
474
+ BODY="$MARKER
475
+ $(m wf_changelog.automerge_off_comment)"
476
+ gh pr comment "$PR_NUMBER" --body "$BODY" \
477
+ || echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
478
+ fi
479
+
423
480
  - name: Enable automerge
481
+ if: steps.automerge_option.outputs.release_automerge != 'false'
424
482
  env:
425
483
  # With a PAT the merge push triggers downstream workflows
426
484
  # (RELEASE-PUBLISH etc.); GITHUB_TOKEN merges do not.
427
485
  GH_TOKEN: ${{ secrets.WORKFLOW_PAT || github.token }}
428
486
  run: |
487
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
429
488
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
430
489
  PR_NUMBER=${{ github.event.pull_request.number }}
431
490
  VERSION="${{ steps.bump.outputs.new_version }}"
@@ -491,6 +550,8 @@ jobs:
491
550
  wait-for-merge-and-trigger-release:
492
551
  name: Wait for merge and trigger RELEASE-PUBLISH (works without WORKFLOW_PAT)
493
552
  needs: changelog-and-merge
553
+ # Nothing merges by itself when release_automerge is false, so there is nothing to wait for.
554
+ if: needs.changelog-and-merge.outputs.automerge != 'false'
494
555
  runs-on: ubuntu-latest
495
556
  timeout-minutes: 35
496
557
  permissions:
@@ -514,6 +575,7 @@ jobs:
514
575
  GH_TOKEN: ${{ github.token }}
515
576
  GH_REPO: ${{ github.repository }}
516
577
  run: |
578
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
517
579
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
518
580
  PR_NUMBER=${{ github.event.pull_request.number }}
519
581
  MAX_WAIT=1800
@@ -545,6 +607,7 @@ jobs:
545
607
  GH_TOKEN: ${{ github.token }}
546
608
  GH_REPO: ${{ github.repository }}
547
609
  run: |
610
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
548
611
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
549
612
  gh workflow run PROJECT-COMMON-RELEASE-PUBLISH.yaml --ref {{MAIN_BRANCH}}
550
613
  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