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.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/payload/config/breaking-changes.json +31 -5
- package/payload/config/wizard-prompts.yml +0 -4
- package/payload/scripts/messages.py +162 -26
- package/payload/scripts/version_manager.py +69 -40
- package/payload/version.yml.template +6 -2
- package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +3 -0
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +64 -1
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +61 -12
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +46 -10
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +40 -11
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +24 -8
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +29 -10
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +57 -23
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +155 -72
- package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +49 -20
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +162 -62
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +184 -87
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +38 -15
- package/payload/workflows/go/PROJECT-GO-CI.yaml +14 -5
- package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +375 -120
- package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +9 -4
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +12 -4
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +375 -120
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +9 -4
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +38 -9
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +19 -5
- package/payload/workflows/spring/PROJECT-SPRING-CI.yml +71 -35
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +9 -6
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +7 -4
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +425 -144
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +7 -4
- package/src/cli/args.js +32 -22
- package/src/commands/doctor.js +20 -0
- package/src/commands/dry-run.js +9 -1
- package/src/commands/full.js +9 -4
- package/src/commands/interactive.js +29 -30
- package/src/commands/status.js +19 -5
- package/src/context.js +4 -2
- package/src/core/copy/simple.js +31 -0
- package/src/core/copy/workflows.js +2 -2
- package/src/core/deploy-style.js +1 -1
- package/src/core/detect.js +3 -2
- package/src/core/options.js +88 -0
- package/src/core/release-options.js +6 -23
- package/src/core/types.js +15 -8
- package/src/core/version-yml.js +68 -26
- package/src/i18n/catalog/en/cli.js +3 -1
- package/src/i18n/catalog/en/commands.js +15 -0
- package/src/i18n/catalog/en/core2.js +3 -0
- package/src/i18n/catalog/en/ui.js +1 -0
- package/src/i18n/catalog/ko/cli.js +3 -1
- package/src/i18n/catalog/ko/commands.js +15 -0
- package/src/i18n/catalog/ko/core2.js +3 -0
- package/src/i18n/catalog/ko/ui.js +1 -0
- package/src/index.js +7 -3
- package/src/ui/env-plan.js +1 -1
- package/src/ui/prompts.js +9 -5
- package/src/ui/status-cards.js +2 -2
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +0 -241
- 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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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,
|
|
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
|
-
# -
|
|
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="$
|
|
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
|
-
#
|
|
272
|
-
|
|
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
|