project-auto-wizard 0.1.5
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/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/project-auto-wizard.js +19 -0
- package/package.json +36 -0
- package/payload/coderabbit.yaml +19 -0
- package/payload/config/breaking-changes.json +1 -0
- package/payload/config/wizard-prompts.yml +62 -0
- package/payload/scripts/changelog_manager.py +726 -0
- package/payload/scripts/version_manager.py +617 -0
- package/payload/version.yml.template +48 -0
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +303 -0
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +293 -0
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +289 -0
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +192 -0
- package/payload/workflows/common/secret-backup/PROJECT-COMMON-SECRET-FILE-UPLOAD.yaml +209 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +591 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +700 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +308 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +992 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +689 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +987 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +471 -0
- package/payload/workflows/flutter/PROJECT-FLUTTER-SUH-LAB-APP-BUILD-TRIGGER.yaml +500 -0
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +185 -0
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +271 -0
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +81 -0
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +2191 -0
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +386 -0
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +194 -0
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +255 -0
- package/payload/workflows/spring/PROJECT-SPRING-GITHUB-PACKAGES-PUBLISH.yml +84 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-CI.yml +316 -0
- package/payload/workflows/spring/nexus/PROJECT-SPRING-NEXUS-PUBLISH.yml +58 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +535 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +437 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +2277 -0
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +425 -0
- package/src/cli/args.js +95 -0
- package/src/cli/help.js +27 -0
- package/src/commands/full.js +53 -0
- package/src/commands/interactive.js +276 -0
- package/src/commands/revert.js +60 -0
- package/src/commands/version.js +31 -0
- package/src/commands/workflows.js +49 -0
- package/src/context.js +26 -0
- package/src/core/assets.js +47 -0
- package/src/core/branches.js +60 -0
- package/src/core/branding.js +15 -0
- package/src/core/breaking-check.js +65 -0
- package/src/core/breaking.js +26 -0
- package/src/core/copy/coderabbit.js +26 -0
- package/src/core/copy/gitignore.js +55 -0
- package/src/core/copy/readme.js +30 -0
- package/src/core/copy/simple.js +23 -0
- package/src/core/copy/workflows.js +233 -0
- package/src/core/detect-fs.js +106 -0
- package/src/core/detect.js +62 -0
- package/src/core/fsutil.js +46 -0
- package/src/core/options-ask.js +99 -0
- package/src/core/paths-resolve.js +261 -0
- package/src/core/paths.js +16 -0
- package/src/core/version-yml.js +190 -0
- package/src/core/wizard-env.js +101 -0
- package/src/core/wizard-labels.js +107 -0
- package/src/index.js +162 -0
- package/src/ui/ansi.js +26 -0
- package/src/ui/banner.js +29 -0
- package/src/ui/env-plan.js +207 -0
- package/src/ui/prompts.js +99 -0
- package/src/ui/readline-engine.js +257 -0
- package/src/ui/status-cards.js +56 -0
- package/src/ui/summary.js +127 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
version_manager.py — general-purpose version management script (stdlib only).
|
|
4
|
+
|
|
5
|
+
This script is copied into user repos (.github/scripts/) by project-auto-wizard
|
|
6
|
+
and runs standalone on GitHub Actions ubuntu runners (python3, no third-party deps).
|
|
7
|
+
|
|
8
|
+
It is a Python rewrite of the battle-tested bash version_manager.sh from
|
|
9
|
+
SUH-DEVOPS-TEMPLATE. Behavioral equivalence with that script is the design goal:
|
|
10
|
+
- version.yml is the single source of truth for `version` and `version_code`.
|
|
11
|
+
- version.yml is edited via line-based regex replacements that preserve all
|
|
12
|
+
comments and formatting (never rewritten wholesale, never parsed with a YAML lib).
|
|
13
|
+
- Versions are synced out to type-specific project files (build.gradle,
|
|
14
|
+
pubspec.yaml, package.json, pyproject.toml, Info.plist, app.json, ...).
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
version_manager.py get # current version (synced)
|
|
18
|
+
version_manager.py get-code # current version_code
|
|
19
|
+
version_manager.py increment # patch+1, sync, bump version_code
|
|
20
|
+
version_manager.py increment-code # version_code+1 only
|
|
21
|
+
version_manager.py set X.Y.Z # set version explicitly, sync
|
|
22
|
+
version_manager.py sync # sync version.yml <-> project files
|
|
23
|
+
|
|
24
|
+
Contract:
|
|
25
|
+
- The LAST line printed to stdout is always the value (callers do `| tail -n 1`).
|
|
26
|
+
- Exit 0 on success; exit 1 on validation failure or missing version.yml.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import datetime
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
VERSION_YML = "version.yml"
|
|
38
|
+
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log(message):
|
|
42
|
+
"""Non-value logging output, written to stderr (mirroring the bash
|
|
43
|
+
script's log_* helpers). stdout is reserved for the value contract:
|
|
44
|
+
its last line is always the command's result."""
|
|
45
|
+
print(message, file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ===================================================================
|
|
49
|
+
# Newline-preserving file I/O
|
|
50
|
+
# ===================================================================
|
|
51
|
+
|
|
52
|
+
def _detect_eol(raw):
|
|
53
|
+
"""Return the dominant line ending of raw text ("\r\n" or "\n")."""
|
|
54
|
+
crlf = raw.count("\r\n")
|
|
55
|
+
lf = raw.count("\n") - crlf
|
|
56
|
+
return "\r\n" if crlf > lf else "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_file(path):
|
|
60
|
+
"""Read a text file with newlines normalized to \\n for regex processing.
|
|
61
|
+
The original dominant line ending is re-applied by write_file()."""
|
|
62
|
+
with open(path, "r", encoding="utf-8", newline="") as f:
|
|
63
|
+
return f.read().replace("\r\n", "\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_file(path, text):
|
|
67
|
+
"""Write text preserving the dominant line ending of the existing file
|
|
68
|
+
on disk (LF stays LF, CRLF stays CRLF — never platform-dependent)."""
|
|
69
|
+
p = Path(path)
|
|
70
|
+
eol = "\n"
|
|
71
|
+
if p.is_file():
|
|
72
|
+
with open(p, "r", encoding="utf-8", newline="") as f:
|
|
73
|
+
eol = _detect_eol(f.read())
|
|
74
|
+
if eol != "\n":
|
|
75
|
+
text = text.replace("\n", eol)
|
|
76
|
+
with open(p, "w", encoding="utf-8", newline="") as f:
|
|
77
|
+
f.write(text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ===================================================================
|
|
81
|
+
# version.yml line-based read/write helpers
|
|
82
|
+
# ===================================================================
|
|
83
|
+
|
|
84
|
+
def _version_yml_path():
|
|
85
|
+
return Path(VERSION_YML)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def require_version_yml():
|
|
89
|
+
if not _version_yml_path().is_file():
|
|
90
|
+
log("ERROR: version.yml not found")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_text():
|
|
95
|
+
return read_file(_version_yml_path())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_text(text):
|
|
99
|
+
write_file(_version_yml_path(), text)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_scalar_key(key, default=None):
|
|
103
|
+
"""Read a simple top-level `key: "value"` or `key: value` line.
|
|
104
|
+
Trailing `# comment` (unquoted values only) is stripped."""
|
|
105
|
+
text = read_text()
|
|
106
|
+
m = re.search(
|
|
107
|
+
r'^' + re.escape(key) + r':[ \t]*(.*)$',
|
|
108
|
+
text,
|
|
109
|
+
re.MULTILINE,
|
|
110
|
+
)
|
|
111
|
+
if not m:
|
|
112
|
+
return default
|
|
113
|
+
raw = m.group(1).strip()
|
|
114
|
+
if raw.startswith('"'):
|
|
115
|
+
qm = re.match(r'"([^"]*)"', raw)
|
|
116
|
+
val = qm.group(1) if qm else raw.strip('"')
|
|
117
|
+
else:
|
|
118
|
+
# unquoted scalar: strip trailing comment
|
|
119
|
+
val = raw.split("#", 1)[0].strip()
|
|
120
|
+
return val if val != "" else default
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_scalar_key(key, value, quote=True):
|
|
124
|
+
"""Replace a top-level `key: ...` line's value, preserving everything else.
|
|
125
|
+
If the key doesn't exist, does nothing (mirrors bash's yq behavior of only
|
|
126
|
+
updating existing keys for metadata fields)."""
|
|
127
|
+
text = read_text()
|
|
128
|
+
pattern = re.compile(r'^(' + re.escape(key) + r':)[ \t]*.*$', re.MULTILINE)
|
|
129
|
+
if not pattern.search(text):
|
|
130
|
+
return False
|
|
131
|
+
if quote:
|
|
132
|
+
replacement = r'\1 "' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
133
|
+
else:
|
|
134
|
+
replacement = r'\1 ' + str(value)
|
|
135
|
+
new_text = pattern.sub(replacement, text, count=1)
|
|
136
|
+
write_text(new_text)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def key_exists(key):
|
|
141
|
+
text = read_text()
|
|
142
|
+
return re.search(r'^' + re.escape(key) + r':', text, re.MULTILINE) is not None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_current_version():
|
|
146
|
+
return read_scalar_key("version", "0.0.0")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_project_type():
|
|
150
|
+
return read_scalar_key("project_type", "basic")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_project_types_csv():
|
|
154
|
+
"""Return project_types as a list. Supports both:
|
|
155
|
+
project_types: ["a", "b"]
|
|
156
|
+
project_types:
|
|
157
|
+
- "a"
|
|
158
|
+
- "b"
|
|
159
|
+
Returns [] if key absent (legacy single-type mode)."""
|
|
160
|
+
text = read_text()
|
|
161
|
+
|
|
162
|
+
# Inline array form: project_types: ["a", "b"]
|
|
163
|
+
m = re.search(r'^project_types:[ \t]*\[(.*?)\][ \t]*$', text, re.MULTILINE)
|
|
164
|
+
if m:
|
|
165
|
+
inner = m.group(1)
|
|
166
|
+
items = re.findall(r'"([^"]*)"|\'([^\']*)\'', inner)
|
|
167
|
+
types = [a or b for a, b in items]
|
|
168
|
+
return [t for t in types if t]
|
|
169
|
+
|
|
170
|
+
# Block list form:
|
|
171
|
+
# project_types:
|
|
172
|
+
# - "a"
|
|
173
|
+
# - "b"
|
|
174
|
+
m = re.search(r'^project_types:[ \t]*\n((?:[ \t]+-[ \t]*.*\n?)+)', text, re.MULTILINE)
|
|
175
|
+
if m:
|
|
176
|
+
block = m.group(1)
|
|
177
|
+
types = re.findall(r'-\s*["\']?([^"\'\n]+?)["\']?\s*$', block, re.MULTILINE)
|
|
178
|
+
return [t.strip() for t in types if t.strip()]
|
|
179
|
+
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_type_path(project_type, project_types_list=None):
|
|
184
|
+
"""Return project_paths.<type> if set, else '.' (repo root)."""
|
|
185
|
+
text = read_text()
|
|
186
|
+
m = re.search(r'^project_paths:[ \t]*\n((?:[ \t]+.+\n?)+)', text, re.MULTILINE)
|
|
187
|
+
if not m:
|
|
188
|
+
return "."
|
|
189
|
+
block = m.group(1)
|
|
190
|
+
km = re.search(
|
|
191
|
+
r'^[ \t]+["\']?' + re.escape(project_type) + r'["\']?:[ \t]*["\']?([^"\'\n]+?)["\']?[ \t]*$',
|
|
192
|
+
block,
|
|
193
|
+
re.MULTILINE,
|
|
194
|
+
)
|
|
195
|
+
if km:
|
|
196
|
+
val = km.group(1).strip()
|
|
197
|
+
if val and val != "null":
|
|
198
|
+
return val
|
|
199
|
+
return "."
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_version_code():
|
|
203
|
+
require_version_yml()
|
|
204
|
+
code = read_scalar_key("version_code", None)
|
|
205
|
+
if code is None or code == "" or code == "null":
|
|
206
|
+
log("WARNING: version_code field missing, adding default value 1")
|
|
207
|
+
text = read_text()
|
|
208
|
+
if re.search(r'^version:', text, re.MULTILINE):
|
|
209
|
+
new_text = re.sub(
|
|
210
|
+
r'^(version:[^\n]*\n)',
|
|
211
|
+
r'\1version_code: 1 # app build number\n',
|
|
212
|
+
text,
|
|
213
|
+
count=1,
|
|
214
|
+
flags=re.MULTILINE,
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
new_text = text.rstrip("\n") + '\nversion_code: 1 # app build number\n'
|
|
218
|
+
write_text(new_text)
|
|
219
|
+
return "1"
|
|
220
|
+
return code.strip()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def set_version_code(new_code):
|
|
224
|
+
text = read_text()
|
|
225
|
+
pattern = re.compile(r'^version_code:[ \t]*.*$', re.MULTILINE)
|
|
226
|
+
replacement = f'version_code: {new_code} # app build number'
|
|
227
|
+
if pattern.search(text):
|
|
228
|
+
new_text = pattern.sub(replacement, text, count=1)
|
|
229
|
+
else:
|
|
230
|
+
new_text = re.sub(
|
|
231
|
+
r'^(version:[^\n]*\n)',
|
|
232
|
+
r'\1' + replacement + '\n',
|
|
233
|
+
text,
|
|
234
|
+
count=1,
|
|
235
|
+
flags=re.MULTILINE,
|
|
236
|
+
)
|
|
237
|
+
write_text(new_text)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_version(version):
|
|
241
|
+
return bool(version) and SEMVER_RE.match(version) is not None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def increment_patch(version):
|
|
245
|
+
major, minor, patch = version.split(".")
|
|
246
|
+
return f"{major}.{minor}.{int(patch) + 1}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def compare_versions(v1, v2):
|
|
250
|
+
"""Return 1 if v1>v2, -1 if v1<v2, 0 if equal."""
|
|
251
|
+
p1 = [int(x) for x in v1.split(".")]
|
|
252
|
+
p2 = [int(x) for x in v2.split(".")]
|
|
253
|
+
for a, b in zip(p1, p2):
|
|
254
|
+
if a > b:
|
|
255
|
+
return 1
|
|
256
|
+
if a < b:
|
|
257
|
+
return -1
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_higher_version(v1, v2):
|
|
262
|
+
return v1 if compare_versions(v1, v2) >= 0 else v2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def update_version_yml(new_version):
|
|
266
|
+
write_scalar_key("version", new_version)
|
|
267
|
+
today = datetime.date.today().isoformat()
|
|
268
|
+
user = os.environ.get("GITHUB_ACTOR", "")
|
|
269
|
+
if not user:
|
|
270
|
+
try:
|
|
271
|
+
import getpass
|
|
272
|
+
user = getpass.getuser()
|
|
273
|
+
except Exception:
|
|
274
|
+
user = "unknown"
|
|
275
|
+
if re.search(r'^\s+last_updated:', read_text(), re.MULTILINE):
|
|
276
|
+
_write_nested_scalar("last_updated", today)
|
|
277
|
+
if re.search(r'^\s+last_updated_by:', read_text(), re.MULTILINE):
|
|
278
|
+
_write_nested_scalar("last_updated_by", user)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _write_nested_scalar(key, value):
|
|
282
|
+
"""Replace an indented ` key: "value"` line anywhere in the file
|
|
283
|
+
(used for metadata.* fields), preserving indentation and comments."""
|
|
284
|
+
text = read_text()
|
|
285
|
+
pattern = re.compile(r'^([ \t]+' + re.escape(key) + r':)[ \t]*.*$', re.MULTILINE)
|
|
286
|
+
if not pattern.search(text):
|
|
287
|
+
return False
|
|
288
|
+
escaped = value.replace('\\', '\\\\').replace('"', '\\"')
|
|
289
|
+
replacement = r'\1 "' + escaped + '"'
|
|
290
|
+
new_text = pattern.sub(replacement, text, count=1)
|
|
291
|
+
write_text(new_text)
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ===================================================================
|
|
296
|
+
# Project file sync (type-specific)
|
|
297
|
+
# ===================================================================
|
|
298
|
+
|
|
299
|
+
def sync_spring(path_dir, new_version):
|
|
300
|
+
"""Look for build.gradle or build.gradle.kts under path_dir (root of that dir, like bash's maxdepth 2)."""
|
|
301
|
+
candidates = []
|
|
302
|
+
for name in ("build.gradle", "build.gradle.kts"):
|
|
303
|
+
for p in [Path(path_dir) / name] + list(Path(path_dir).glob("*/" + name)):
|
|
304
|
+
if p.is_file():
|
|
305
|
+
candidates.append(p)
|
|
306
|
+
if not candidates:
|
|
307
|
+
log(f"WARNING: spring: no build.gradle(.kts) found under {path_dir} — skipping")
|
|
308
|
+
return
|
|
309
|
+
for gradle_file in candidates:
|
|
310
|
+
text = read_file(gradle_file)
|
|
311
|
+
new_text = re.sub(r"version\s*=\s*'[^']*'", f"version = '{new_version}'", text)
|
|
312
|
+
new_text = re.sub(r'version\s*=\s*"[^"]*"', f'version = "{new_version}"', new_text)
|
|
313
|
+
write_file(gradle_file, new_text)
|
|
314
|
+
log(f"updated: {gradle_file}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def sync_flutter(path_dir, new_version, version_code):
|
|
318
|
+
target = Path(path_dir) / "pubspec.yaml"
|
|
319
|
+
if not target.is_file():
|
|
320
|
+
log(f"WARNING: flutter: {target} not found — skipping")
|
|
321
|
+
return
|
|
322
|
+
text = read_file(target)
|
|
323
|
+
full_version = f"{new_version}+{version_code}"
|
|
324
|
+
pattern = re.compile(r'^(version:)[ \t]*.*$', re.MULTILINE)
|
|
325
|
+
if pattern.search(text):
|
|
326
|
+
new_text = pattern.sub(r'\1 ' + full_version, text, count=1)
|
|
327
|
+
else:
|
|
328
|
+
new_text = text.rstrip("\n") + f"\nversion: {full_version}\n"
|
|
329
|
+
write_file(target, new_text)
|
|
330
|
+
log(f"updated: {target}")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def sync_json_version(target, new_version, key_path):
|
|
334
|
+
if not target.is_file():
|
|
335
|
+
log(f"WARNING: {target} not found — skipping")
|
|
336
|
+
return
|
|
337
|
+
try:
|
|
338
|
+
data = json.loads(read_file(target))
|
|
339
|
+
except json.JSONDecodeError as e:
|
|
340
|
+
log(f"WARNING: {target} invalid JSON ({e}) — skipping")
|
|
341
|
+
return
|
|
342
|
+
node = data
|
|
343
|
+
for k in key_path[:-1]:
|
|
344
|
+
node = node.setdefault(k, {})
|
|
345
|
+
node[key_path[-1]] = new_version
|
|
346
|
+
write_file(target, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
347
|
+
log(f"updated: {target}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def sync_python(path_dir, new_version):
|
|
351
|
+
target = Path(path_dir) / "pyproject.toml"
|
|
352
|
+
if not target.is_file():
|
|
353
|
+
log(f"WARNING: python: {target} not found — skipping")
|
|
354
|
+
return
|
|
355
|
+
text = read_file(target)
|
|
356
|
+
new_text = re.sub(r'^version\s*=\s*"[^"]*"', f'version = "{new_version}"', text, count=1, flags=re.MULTILINE)
|
|
357
|
+
write_file(target, new_text)
|
|
358
|
+
log(f"updated: {target}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def sync_react_native(path_dir, new_version):
|
|
362
|
+
ios_dir = Path(path_dir) / "ios"
|
|
363
|
+
found_plist = False
|
|
364
|
+
if ios_dir.is_dir():
|
|
365
|
+
for plist_file in ios_dir.rglob("Info.plist"):
|
|
366
|
+
text = read_file(plist_file)
|
|
367
|
+
if "CFBundleShortVersionString" in text:
|
|
368
|
+
new_text = re.sub(
|
|
369
|
+
r'(<key>CFBundleShortVersionString</key>\s*<string>)[^<]*(</string>)',
|
|
370
|
+
r'\g<1>' + new_version + r'\g<2>',
|
|
371
|
+
text,
|
|
372
|
+
)
|
|
373
|
+
write_file(plist_file, new_text)
|
|
374
|
+
log(f"updated: {plist_file}")
|
|
375
|
+
found_plist = True
|
|
376
|
+
else:
|
|
377
|
+
log(f"WARNING: react-native: {ios_dir} not found — skipping")
|
|
378
|
+
|
|
379
|
+
gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
|
|
380
|
+
if gradle_file.is_file():
|
|
381
|
+
text = read_file(gradle_file)
|
|
382
|
+
new_text = re.sub(r'versionName\s+"[^"]*"', f'versionName "{new_version}"', text)
|
|
383
|
+
write_file(gradle_file, new_text)
|
|
384
|
+
log(f"updated: {gradle_file}")
|
|
385
|
+
else:
|
|
386
|
+
log(f"WARNING: react-native: {gradle_file} not found — skipping")
|
|
387
|
+
|
|
388
|
+
if not found_plist and not gradle_file.is_file():
|
|
389
|
+
log(f"WARNING: react-native: no target files found under {path_dir}")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def sync_for_type(project_type, new_version, version_code_getter):
|
|
393
|
+
path_dir = get_type_path(project_type)
|
|
394
|
+
if project_type == "spring":
|
|
395
|
+
sync_spring(path_dir, new_version)
|
|
396
|
+
elif project_type == "flutter":
|
|
397
|
+
sync_flutter(path_dir, new_version, version_code_getter())
|
|
398
|
+
elif project_type in ("react", "next", "node"):
|
|
399
|
+
sync_json_version(Path(path_dir) / "package.json", new_version, ["version"])
|
|
400
|
+
elif project_type == "python":
|
|
401
|
+
sync_python(path_dir, new_version)
|
|
402
|
+
elif project_type == "react-native":
|
|
403
|
+
sync_react_native(path_dir, new_version)
|
|
404
|
+
elif project_type == "react-native-expo":
|
|
405
|
+
sync_json_version(Path(path_dir) / "app.json", new_version, ["expo", "version"])
|
|
406
|
+
elif project_type == "basic":
|
|
407
|
+
pass
|
|
408
|
+
else:
|
|
409
|
+
log(f"WARNING: unknown project type: {project_type} — skipping")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def sync_all_project_files(new_version):
|
|
413
|
+
types = get_project_types_csv()
|
|
414
|
+
if not types:
|
|
415
|
+
types = [get_project_type()]
|
|
416
|
+
for t in types:
|
|
417
|
+
sync_for_type(t, new_version, get_version_code)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def update_all_versions(new_version):
|
|
421
|
+
update_version_yml(new_version)
|
|
422
|
+
sync_all_project_files(new_version)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ===================================================================
|
|
426
|
+
# Project file -> version read-back (for sync comparison)
|
|
427
|
+
# ===================================================================
|
|
428
|
+
|
|
429
|
+
def get_project_file_version(project_type):
|
|
430
|
+
path_dir = get_type_path(project_type)
|
|
431
|
+
version = None
|
|
432
|
+
try:
|
|
433
|
+
if project_type == "spring":
|
|
434
|
+
for name in ("build.gradle", "build.gradle.kts"):
|
|
435
|
+
p = Path(path_dir) / name
|
|
436
|
+
if p.is_file():
|
|
437
|
+
text = p.read_text(encoding="utf-8")
|
|
438
|
+
m = re.search(r"^\s*version\s*=\s*['\"](\d+\.\d+\.\d+)['\"]", text, re.MULTILINE)
|
|
439
|
+
if m:
|
|
440
|
+
version = m.group(1)
|
|
441
|
+
break
|
|
442
|
+
elif project_type == "flutter":
|
|
443
|
+
p = Path(path_dir) / "pubspec.yaml"
|
|
444
|
+
if p.is_file():
|
|
445
|
+
text = p.read_text(encoding="utf-8")
|
|
446
|
+
m = re.search(r'^version:\s*([^\s#]+)', text, re.MULTILINE)
|
|
447
|
+
if m:
|
|
448
|
+
version = m.group(1).split("+")[0]
|
|
449
|
+
elif project_type in ("react", "next", "node"):
|
|
450
|
+
p = Path(path_dir) / "package.json"
|
|
451
|
+
if p.is_file():
|
|
452
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
453
|
+
version = data.get("version")
|
|
454
|
+
elif project_type == "react-native":
|
|
455
|
+
ios_dir = Path(path_dir) / "ios"
|
|
456
|
+
plist = None
|
|
457
|
+
if ios_dir.is_dir():
|
|
458
|
+
plists = list(ios_dir.rglob("Info.plist"))
|
|
459
|
+
plist = plists[0] if plists else None
|
|
460
|
+
if plist is not None:
|
|
461
|
+
text = plist.read_text(encoding="utf-8")
|
|
462
|
+
m = re.search(r'<key>CFBundleShortVersionString</key>\s*<string>([^<]*)</string>', text)
|
|
463
|
+
if m:
|
|
464
|
+
version = m.group(1)
|
|
465
|
+
else:
|
|
466
|
+
gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
|
|
467
|
+
if gradle_file.is_file():
|
|
468
|
+
text = gradle_file.read_text(encoding="utf-8")
|
|
469
|
+
m = re.search(r'versionName\s+"([^"]+)"', text)
|
|
470
|
+
if m:
|
|
471
|
+
version = m.group(1)
|
|
472
|
+
elif project_type == "react-native-expo":
|
|
473
|
+
p = Path(path_dir) / "app.json"
|
|
474
|
+
if p.is_file():
|
|
475
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
476
|
+
version = (data.get("expo") or {}).get("version")
|
|
477
|
+
elif project_type == "python":
|
|
478
|
+
p = Path(path_dir) / "pyproject.toml"
|
|
479
|
+
if p.is_file():
|
|
480
|
+
text = p.read_text(encoding="utf-8")
|
|
481
|
+
m = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE)
|
|
482
|
+
if m:
|
|
483
|
+
version = m.group(1)
|
|
484
|
+
except Exception as e:
|
|
485
|
+
log(f"WARNING: failed reading project file for {project_type}: {e}")
|
|
486
|
+
version = None
|
|
487
|
+
|
|
488
|
+
if not version:
|
|
489
|
+
version = get_current_version()
|
|
490
|
+
return version
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def sync_versions():
|
|
494
|
+
yml_version = get_current_version()
|
|
495
|
+
primary_type = get_project_type()
|
|
496
|
+
project_version = get_project_file_version(primary_type)
|
|
497
|
+
|
|
498
|
+
log("Version sync check")
|
|
499
|
+
log(f" version.yml: {yml_version}")
|
|
500
|
+
log(f" project file: {project_version}")
|
|
501
|
+
|
|
502
|
+
if yml_version != project_version:
|
|
503
|
+
if validate_version(yml_version) and validate_version(project_version):
|
|
504
|
+
higher = get_higher_version(yml_version, project_version)
|
|
505
|
+
log(f"Version mismatch detected, syncing to higher version: {higher}")
|
|
506
|
+
if higher != yml_version:
|
|
507
|
+
update_version_yml(higher)
|
|
508
|
+
if higher != project_version:
|
|
509
|
+
sync_all_project_files(higher)
|
|
510
|
+
return higher
|
|
511
|
+
else:
|
|
512
|
+
log("WARNING: version format invalid, cannot sync")
|
|
513
|
+
return yml_version
|
|
514
|
+
else:
|
|
515
|
+
types = get_project_types_csv()
|
|
516
|
+
if types:
|
|
517
|
+
log(f"Multi-type — reconciling all type files to version.yml version: {yml_version}")
|
|
518
|
+
sync_all_project_files(yml_version)
|
|
519
|
+
log(f"Version already in sync: {yml_version}")
|
|
520
|
+
return yml_version
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ===================================================================
|
|
524
|
+
# Commands
|
|
525
|
+
# ===================================================================
|
|
526
|
+
|
|
527
|
+
def cmd_get(args):
|
|
528
|
+
require_version_yml()
|
|
529
|
+
version = sync_versions()
|
|
530
|
+
print(version)
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def cmd_get_code(args):
|
|
535
|
+
require_version_yml()
|
|
536
|
+
code = get_version_code()
|
|
537
|
+
print(code)
|
|
538
|
+
return 0
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def cmd_increment_code(args):
|
|
542
|
+
require_version_yml()
|
|
543
|
+
current = int(get_version_code())
|
|
544
|
+
new_code = current + 1
|
|
545
|
+
set_version_code(new_code)
|
|
546
|
+
print(new_code)
|
|
547
|
+
return 0
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def cmd_increment(args):
|
|
551
|
+
require_version_yml()
|
|
552
|
+
current_version = sync_versions()
|
|
553
|
+
if not validate_version(current_version):
|
|
554
|
+
log(f"ERROR: invalid version format: {current_version}")
|
|
555
|
+
return 1
|
|
556
|
+
new_version = increment_patch(current_version)
|
|
557
|
+
update_all_versions(new_version)
|
|
558
|
+
|
|
559
|
+
current_code = int(get_version_code())
|
|
560
|
+
set_version_code(current_code + 1)
|
|
561
|
+
|
|
562
|
+
print(new_version)
|
|
563
|
+
return 0
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def cmd_set(args):
|
|
567
|
+
require_version_yml()
|
|
568
|
+
new_version = args.version
|
|
569
|
+
if not validate_version(new_version):
|
|
570
|
+
log(f"ERROR: invalid version format: {new_version} (must be x.y.z)")
|
|
571
|
+
return 1
|
|
572
|
+
update_all_versions(new_version)
|
|
573
|
+
print(new_version)
|
|
574
|
+
return 0
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def cmd_sync(args):
|
|
578
|
+
require_version_yml()
|
|
579
|
+
synced = sync_versions()
|
|
580
|
+
print(synced)
|
|
581
|
+
return 0
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def build_parser():
|
|
585
|
+
parser = argparse.ArgumentParser(prog="version_manager.py")
|
|
586
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
587
|
+
|
|
588
|
+
sub.add_parser("get")
|
|
589
|
+
sub.add_parser("get-code")
|
|
590
|
+
sub.add_parser("increment")
|
|
591
|
+
sub.add_parser("increment-code")
|
|
592
|
+
sub.add_parser("sync")
|
|
593
|
+
|
|
594
|
+
p_set = sub.add_parser("set")
|
|
595
|
+
p_set.add_argument("version")
|
|
596
|
+
|
|
597
|
+
return parser
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def main(argv=None):
|
|
601
|
+
parser = build_parser()
|
|
602
|
+
args = parser.parse_args(argv)
|
|
603
|
+
|
|
604
|
+
handlers = {
|
|
605
|
+
"get": cmd_get,
|
|
606
|
+
"get-code": cmd_get_code,
|
|
607
|
+
"increment": cmd_increment,
|
|
608
|
+
"increment-code": cmd_increment_code,
|
|
609
|
+
"set": cmd_set,
|
|
610
|
+
"sync": cmd_sync,
|
|
611
|
+
}
|
|
612
|
+
handler = handlers[args.command]
|
|
613
|
+
return handler(args)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
if __name__ == "__main__":
|
|
617
|
+
sys.exit(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ===
|
|
2
|
+
# Version format: x.y.z (semantic versioning)
|
|
3
|
+
# - patch: auto-incremented by the release workflows
|
|
4
|
+
# - version_code: monotonically increasing build number, bumped alongside version
|
|
5
|
+
# - major/minor: NOT bumped automatically — edit this file manually
|
|
6
|
+
# (e.g., 1.0.5 -> 1.1.0 or 2.0.0)
|
|
7
|
+
# ===
|
|
8
|
+
# Supported project types: spring, flutter, next, react, react-native,
|
|
9
|
+
# react-native-expo, node, python, basic
|
|
10
|
+
# Multi-type projects: list every type in the project_types array
|
|
11
|
+
# -> project_type (singular) always mirrors project_types[0] — do not edit it directly
|
|
12
|
+
# Monorepo paths: project_paths maps each type to its subdir relative to the repo root
|
|
13
|
+
# (e.g., flutter: "app", react: "client"). Omitted -> repo root.
|
|
14
|
+
#
|
|
15
|
+
# Synced files per type:
|
|
16
|
+
# - spring: build.gradle / build.gradle.kts
|
|
17
|
+
# - flutter: pubspec.yaml
|
|
18
|
+
# - next/react/node: package.json
|
|
19
|
+
# - react-native: Info.plist + build.gradle
|
|
20
|
+
# - react-native-expo: app.json
|
|
21
|
+
# - python: pyproject.toml
|
|
22
|
+
# - basic: version.yml only
|
|
23
|
+
# ===
|
|
24
|
+
|
|
25
|
+
version: "{{VERSION}}"
|
|
26
|
+
version_code: {{VERSION_CODE}} # app build number
|
|
27
|
+
project_types: {{PROJECT_TYPES}} # first entry is primary
|
|
28
|
+
project_type: "{{PROJECT_TYPE}}" # auto-mirror of project_types[0] — do not edit directly
|
|
29
|
+
{{PROJECT_PATHS}}
|
|
30
|
+
metadata:
|
|
31
|
+
last_updated: "{{NOW}}"
|
|
32
|
+
last_updated_by: "project-auto-wizard"
|
|
33
|
+
default_branch: "{{DEFAULT_BRANCH}}"
|
|
34
|
+
integration_date: "{{TODAY}}"
|
|
35
|
+
template:
|
|
36
|
+
source: "project-auto-wizard"
|
|
37
|
+
version: "{{TEMPLATE_VERSION}}"
|
|
38
|
+
integrated_date: "{{TODAY}}"
|
|
39
|
+
last_update_date: "{{TODAY}}"
|
|
40
|
+
branches:
|
|
41
|
+
main: "{{MAIN_BRANCH}}"
|
|
42
|
+
develop: "{{DEVELOP_BRANCH}}"
|
|
43
|
+
mode: "{{BRANCH_MODE}}" # pr-flow | trunk-based
|
|
44
|
+
options:
|
|
45
|
+
nexus: {{OPT_NEXUS}}
|
|
46
|
+
secret_backup: {{OPT_SECRET_BACKUP}}
|
|
47
|
+
coderabbit: {{OPT_CODERABBIT}}
|
|
48
|
+
{{DEPLOY}}
|