project-auto-wizard 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +165 -0
- package/README.ko.md +165 -0
- package/README.md +107 -286
- package/README.zh-CN.md +165 -0
- package/bin/project-auto-wizard.js +1 -2
- package/package.json +7 -3
- package/payload/config/breaking-changes.json +24 -0
- package/payload/config/wizard-prompts.yml +45 -0
- package/payload/scripts/changelog_manager.py +141 -46
- package/payload/scripts/issue_helper.py +34 -5
- package/payload/scripts/version_manager.py +397 -97
- package/payload/version.yml.template +4 -2
- package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
- package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
- package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
- package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
- package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
- package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
- package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
- package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
- package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
- package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
- package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
- package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
- package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
- package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
- package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
- package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
- package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
- package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
- package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
- package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
- package/src/cli/args.js +33 -24
- package/src/cli/help.js +16 -4
- package/src/commands/doctor.js +17 -10
- package/src/commands/dry-run.js +54 -1
- package/src/commands/full.js +127 -50
- package/src/commands/interactive-flutter.js +1 -1
- package/src/commands/interactive.js +124 -56
- package/src/commands/purge.js +59 -17
- package/src/commands/status.js +16 -1
- package/src/commands/uninstall.js +58 -13
- package/src/context.js +5 -5
- package/src/core/baseline.js +20 -5
- package/src/core/branches.js +19 -1
- package/src/core/branding.js +1 -1
- package/src/core/breaking-check.js +14 -19
- package/src/core/breaking.js +7 -3
- package/src/core/copy/gitignore.js +20 -12
- package/src/core/copy/readme.js +24 -5
- package/src/core/copy/simple.js +52 -13
- package/src/core/copy/workflows.js +90 -47
- package/src/core/deploy-style.js +83 -11
- package/src/core/detect-fs.js +127 -24
- package/src/core/detect.js +131 -47
- package/src/core/errors.js +3 -0
- package/src/core/flutter-options.js +4 -3
- package/src/core/fsutil.js +23 -6
- package/src/core/logger.js +55 -19
- package/src/core/paths-resolve.js +50 -47
- package/src/core/paths.js +26 -1
- package/src/core/release-options.js +23 -0
- package/src/core/removal-plan.js +75 -19
- package/src/core/types.js +113 -0
- package/src/core/verify.js +85 -19
- package/src/core/version-yml.js +42 -8
- package/src/core/wizard-env.js +21 -14
- package/src/core/wizard-labels.js +34 -25
- package/src/index.js +122 -49
- package/src/ui/ansi.js +3 -1
- package/src/ui/banner.js +12 -8
- package/src/ui/env-plan.js +68 -44
- package/src/ui/prompts.js +22 -12
- package/src/ui/readline-engine.js +109 -22
- package/src/ui/status-cards.js +10 -5
- package/src/ui/summary.js +71 -33
- package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
- package/payload/scripts/__pycache__/version_manager.cpython-314.pyc +0 -0
|
@@ -5,13 +5,15 @@ version_manager.py — general-purpose version management script (stdlib only).
|
|
|
5
5
|
This script is copied into user repos (.github/scripts/) by project-auto-wizard
|
|
6
6
|
and runs standalone on GitHub Actions ubuntu runners (python3, no third-party deps).
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
(version_manager.sh). Behavioral equivalence with that script is the design goal:
|
|
8
|
+
Design rules:
|
|
10
9
|
- version.yml is the single source of truth for `version` and `version_code`.
|
|
11
10
|
- version.yml is edited via line-based regex replacements that preserve all
|
|
12
11
|
comments and formatting (never rewritten wholesale, never parsed with a YAML lib).
|
|
13
12
|
- Versions are synced out to type-specific project files (build.gradle,
|
|
14
13
|
pubspec.yaml, package.json, pyproject.toml, Info.plist, app.json, ...).
|
|
14
|
+
- increment/set also rewrite the README.md version line under the
|
|
15
|
+
AUTO-VERSION-SECTION marker, so the release commit (and its tag) already
|
|
16
|
+
shows the new version.
|
|
15
17
|
|
|
16
18
|
Usage:
|
|
17
19
|
version_manager.py get # current version (synced)
|
|
@@ -23,7 +25,8 @@ Usage:
|
|
|
23
25
|
|
|
24
26
|
Contract:
|
|
25
27
|
- 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
|
|
28
|
+
- Exit 0 on success; exit 1 on validation failure, missing version.yml,
|
|
29
|
+
or when a version could not be written (e.g. invalid package.json).
|
|
27
30
|
"""
|
|
28
31
|
|
|
29
32
|
import argparse
|
|
@@ -33,10 +36,35 @@ import os
|
|
|
33
36
|
import re
|
|
34
37
|
import sys
|
|
35
38
|
from pathlib import Path
|
|
39
|
+
from typing import Callable, NamedTuple
|
|
36
40
|
|
|
37
41
|
VERSION_YML = "version.yml"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VersionSyncError(Exception):
|
|
45
|
+
"""version.yml·프로젝트 파일에 버전을 쓰지 못했다 — exit 1로 알린다."""
|
|
38
46
|
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
39
47
|
|
|
48
|
+
# 프로젝트 파일 버전 해석 규칙 — 설치 시 감지(src/core/detect.js coreVersion)와 같아야 한다.
|
|
49
|
+
# version.yml은 x.y.z만 저장·증가시키므로 파일의 1.2.3-rc.1·1.2.3+4·1.2.0-SNAPSHOT은 코어 x.y.z로 읽는다.
|
|
50
|
+
# 다르게 읽으면 설치 직후 version.yml 값과 첫 릴리스 때 읽은 값이 어긋난다.
|
|
51
|
+
_CORE_VERSION_RE = re.compile(r"^v?(\d+\.\d+\.\d+)(?:[-+][0-9A-Za-z.+-]*)?$")
|
|
52
|
+
|
|
53
|
+
# Gradle·Maven의 -SNAPSHOT은 특정 버전의 프리릴리스가 아니라 "개발 중 빌드" 표식이다(배포 저장소 선택,
|
|
54
|
+
# 산출물 이름에 쓰인다). 그래서 버전을 올려도 떼지 않고 새 버전 뒤에 그대로 붙인다.
|
|
55
|
+
# rc.1·beta.1 같은 프리릴리스는 그 버전 전용 표식이라 새 x.y.z로 릴리스할 때 버린다.
|
|
56
|
+
SNAPSHOT_SUFFIX = "-SNAPSHOT"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def core_version(value):
|
|
60
|
+
"""x.y.z[-pre][+build] → x.y.z. 버전 형식이 아니면 None."""
|
|
61
|
+
m = _CORE_VERSION_RE.match(str(value or "").strip())
|
|
62
|
+
return m.group(1) if m else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _keep_snapshot(old_value, new_version):
|
|
66
|
+
return new_version + SNAPSHOT_SUFFIX if str(old_value).endswith(SNAPSHOT_SUFFIX) else new_version
|
|
67
|
+
|
|
40
68
|
|
|
41
69
|
def log(message):
|
|
42
70
|
"""Non-value logging output, written to stderr (mirroring the bash
|
|
@@ -185,12 +213,15 @@ def get_project_types_csv():
|
|
|
185
213
|
def get_type_path(project_type, project_types_list=None):
|
|
186
214
|
"""Return project_paths.<type> if set, else '.' (repo root)."""
|
|
187
215
|
text = read_text()
|
|
188
|
-
|
|
216
|
+
# The wizard writes trailing comments on both the key line and value lines
|
|
217
|
+
# (e.g. `project_paths: # ...`, ` flutter: "app" # app/pubspec.yaml`).
|
|
218
|
+
m = re.search(r'^project_paths:[ \t]*(?:#[^\n]*)?\n((?:[ \t]+.+\n?)+)', text, re.MULTILINE)
|
|
189
219
|
if not m:
|
|
190
220
|
return "."
|
|
191
221
|
block = m.group(1)
|
|
192
222
|
km = re.search(
|
|
193
|
-
r'^[ \t]+["\']?' + re.escape(project_type)
|
|
223
|
+
r'^[ \t]+["\']?' + re.escape(project_type)
|
|
224
|
+
+ r'["\']?:[ \t]*["\']?([^"\'#\n]+?)["\']?[ \t]*(?:#.*)?$',
|
|
194
225
|
block,
|
|
195
226
|
re.MULTILINE,
|
|
196
227
|
)
|
|
@@ -283,7 +314,18 @@ def get_higher_version(v1, v2):
|
|
|
283
314
|
|
|
284
315
|
|
|
285
316
|
def update_version_yml(new_version):
|
|
286
|
-
write_scalar_key("version", new_version)
|
|
317
|
+
if not write_scalar_key("version", new_version):
|
|
318
|
+
# 키가 없으면 아무것도 쓰지 않은 채 성공처럼 끝나 매번 같은 버전이 나왔다.
|
|
319
|
+
log("WARNING: version field missing in version.yml, adding it")
|
|
320
|
+
text = read_text()
|
|
321
|
+
line = f'version: "{new_version}"\n'
|
|
322
|
+
if re.search(r'^version_code:', text, re.MULTILINE):
|
|
323
|
+
new_text = re.sub(r'^(?=version_code:)', lambda _m: line, text, count=1, flags=re.MULTILINE)
|
|
324
|
+
else:
|
|
325
|
+
new_text = text.rstrip("\n") + "\n" + line
|
|
326
|
+
write_text(new_text)
|
|
327
|
+
if read_scalar_key("version") != new_version:
|
|
328
|
+
raise VersionSyncError(f"failed to write version {new_version} to version.yml")
|
|
287
329
|
today = datetime.date.today().isoformat()
|
|
288
330
|
user = os.environ.get("GITHUB_ACTOR", "")
|
|
289
331
|
if not user:
|
|
@@ -316,24 +358,139 @@ def _write_nested_scalar(key, value):
|
|
|
316
358
|
# Project file sync (type-specific)
|
|
317
359
|
# ===================================================================
|
|
318
360
|
|
|
361
|
+
_XML_TOKEN_RE = re.compile(
|
|
362
|
+
r'<!--.*?-->|<!\[CDATA\[.*?\]\]>|<\?.*?\?>|<![^>]*>|<(/?)([A-Za-z_][\w.:-]*)[^>]*?(/?)>',
|
|
363
|
+
re.DOTALL,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _pom_text_span(text, path):
|
|
368
|
+
"""루트(<project>) 기준 경로의 요소 텍스트 구간 (start, end). 없으면 None.
|
|
369
|
+
<parent>·<dependencies> 안의 <version>은 다른 아티팩트 버전이라 깊이로 구분한다."""
|
|
370
|
+
stack = []
|
|
371
|
+
start = None
|
|
372
|
+
target = list(path)
|
|
373
|
+
for m in _XML_TOKEN_RE.finditer(text):
|
|
374
|
+
name = m.group(2)
|
|
375
|
+
if not name:
|
|
376
|
+
continue # 주석·CDATA·선언
|
|
377
|
+
if m.group(1): # 닫는 태그
|
|
378
|
+
if start is not None and stack[1:] == target:
|
|
379
|
+
return start, m.start()
|
|
380
|
+
if stack:
|
|
381
|
+
stack.pop()
|
|
382
|
+
continue
|
|
383
|
+
if m.group(3): # <tag/>
|
|
384
|
+
continue
|
|
385
|
+
stack.append(name)
|
|
386
|
+
if stack[1:] == target:
|
|
387
|
+
start = m.end()
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _pom_text(text, path):
|
|
392
|
+
span = _pom_text_span(text, path)
|
|
393
|
+
return text[span[0]:span[1]].strip() if span else None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _pom_replace(text, path, value):
|
|
397
|
+
span = _pom_text_span(text, path)
|
|
398
|
+
if not span:
|
|
399
|
+
return text, False
|
|
400
|
+
return text[:span[0]] + value + text[span[1]:], True
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def sync_maven(path_dir, new_version):
|
|
404
|
+
"""pom.xml의 프로젝트 자신의 <version>만 바꾼다. 하위 모듈은 루트를 가리키는
|
|
405
|
+
<parent><version>(과 루트와 같던 자기 <version>)만 따라 올린다."""
|
|
406
|
+
root_pom = Path(path_dir) / "pom.xml"
|
|
407
|
+
if not root_pom.is_file():
|
|
408
|
+
return False
|
|
409
|
+
text = read_file(root_pom)
|
|
410
|
+
old_version = _pom_text(text, ["version"])
|
|
411
|
+
if old_version is None:
|
|
412
|
+
log(f"WARNING: spring: {root_pom} has no project <version> (inherited from parent?) — skipping")
|
|
413
|
+
return True
|
|
414
|
+
if "${" in old_version:
|
|
415
|
+
# ${revision} 같은 CI-friendly 버전은 프로퍼티 쪽에서 관리하므로 건드리지 않는다.
|
|
416
|
+
log(f"WARNING: spring: {root_pom} <version> is a property ({old_version}) — skipping")
|
|
417
|
+
return True
|
|
418
|
+
new_full = _keep_snapshot(old_version, new_version)
|
|
419
|
+
new_text, _ = _pom_replace(text, ["version"], new_full)
|
|
420
|
+
write_file(root_pom, new_text)
|
|
421
|
+
log(f"updated: {root_pom}")
|
|
422
|
+
|
|
423
|
+
root_artifact = _pom_text(text, ["artifactId"])
|
|
424
|
+
for child in sorted(Path(path_dir).glob("*/pom.xml")):
|
|
425
|
+
ctext = read_file(child)
|
|
426
|
+
if _pom_text(ctext, ["parent", "artifactId"]) != root_artifact:
|
|
427
|
+
continue
|
|
428
|
+
if _pom_text(ctext, ["parent", "version"]) != old_version:
|
|
429
|
+
continue
|
|
430
|
+
ctext, _ = _pom_replace(ctext, ["parent", "version"], new_full)
|
|
431
|
+
if _pom_text(ctext, ["version"]) == old_version:
|
|
432
|
+
ctext, _ = _pom_replace(ctext, ["version"], new_full)
|
|
433
|
+
write_file(child, ctext)
|
|
434
|
+
log(f"updated: {child}")
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
_GRADLE_VERSION_RE = re.compile(r"""^([ \t]*version[ \t]*=[ \t]*)(['"])([^'"\n]*)\2""", re.MULTILINE)
|
|
439
|
+
|
|
440
|
+
|
|
319
441
|
def sync_spring(path_dir, new_version):
|
|
320
|
-
"""Look for build.gradle or build.gradle.kts under path_dir (root of that dir, like bash's maxdepth 2)
|
|
442
|
+
"""Look for build.gradle or build.gradle.kts under path_dir (root of that dir, like bash's maxdepth 2),
|
|
443
|
+
and pom.xml for Maven projects."""
|
|
321
444
|
candidates = []
|
|
322
445
|
for name in ("build.gradle", "build.gradle.kts"):
|
|
323
446
|
for p in [Path(path_dir) / name] + list(Path(path_dir).glob("*/" + name)):
|
|
324
447
|
if p.is_file():
|
|
325
448
|
candidates.append(p)
|
|
449
|
+
has_pom = sync_maven(path_dir, new_version)
|
|
326
450
|
if not candidates:
|
|
327
|
-
|
|
451
|
+
if not has_pom:
|
|
452
|
+
log(f"WARNING: spring: no build.gradle(.kts) or pom.xml found under {path_dir} — skipping")
|
|
328
453
|
return
|
|
329
454
|
for gradle_file in candidates:
|
|
330
455
|
text = read_file(gradle_file)
|
|
331
|
-
|
|
332
|
-
|
|
456
|
+
# 줄 시작의 `version =`만 프로젝트 버전이다. 앵커가 없으면 kotlin_version 같은
|
|
457
|
+
# 의존성 버전 변수까지 함께 바뀌어 빌드가 깨진다.
|
|
458
|
+
new_text, count = _GRADLE_VERSION_RE.subn(
|
|
459
|
+
lambda m: f"{m.group(1)}{m.group(2)}{_keep_snapshot(m.group(3), new_version)}{m.group(2)}", text,
|
|
460
|
+
)
|
|
461
|
+
if count == 0:
|
|
462
|
+
log(f"WARNING: spring: no `version = '...'` line in {gradle_file} — skipping")
|
|
463
|
+
continue
|
|
333
464
|
write_file(gradle_file, new_text)
|
|
334
465
|
log(f"updated: {gradle_file}")
|
|
335
466
|
|
|
336
467
|
|
|
468
|
+
def _pubspec_build_number(path_dir):
|
|
469
|
+
"""pubspec.yaml `version: x.y.z+N`의 N. 없으면 None."""
|
|
470
|
+
target = Path(path_dir) / "pubspec.yaml"
|
|
471
|
+
if not target.is_file():
|
|
472
|
+
return None
|
|
473
|
+
m = re.search(r'^version:[ \t]*[^\s#+]+\+(\d+)', read_file(target), re.MULTILINE)
|
|
474
|
+
return int(m.group(1)) if m else None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def get_reconciled_version_code():
|
|
478
|
+
"""version.yml의 version_code와 pubspec.yaml의 +N 중 큰 값.
|
|
479
|
+
로컬 수동 배포 등으로 pubspec 쪽이 앞서 있으면 스토어는 더 작은 build number를
|
|
480
|
+
거부하므로, 그 값을 version.yml에 반영해 역행을 막는다."""
|
|
481
|
+
code = int(get_version_code())
|
|
482
|
+
types = get_project_types_csv()
|
|
483
|
+
pubspec_codes = [
|
|
484
|
+
n for n in (_pubspec_build_number(get_type_path(t)) for t in types if t == "flutter")
|
|
485
|
+
if n is not None
|
|
486
|
+
]
|
|
487
|
+
if pubspec_codes and max(pubspec_codes) > code:
|
|
488
|
+
log(f"pubspec.yaml build number {max(pubspec_codes)} is ahead of version_code {code} — adopting it")
|
|
489
|
+
code = max(pubspec_codes)
|
|
490
|
+
set_version_code(code)
|
|
491
|
+
return code
|
|
492
|
+
|
|
493
|
+
|
|
337
494
|
def sync_flutter(path_dir, new_version, version_code):
|
|
338
495
|
target = Path(path_dir) / "pubspec.yaml"
|
|
339
496
|
if not target.is_file():
|
|
@@ -350,32 +507,83 @@ def sync_flutter(path_dir, new_version, version_code):
|
|
|
350
507
|
log(f"updated: {target}")
|
|
351
508
|
|
|
352
509
|
|
|
510
|
+
def _json_indent(text):
|
|
511
|
+
"""Indentation used by an existing JSON file — the first indented line after a newline
|
|
512
|
+
(e.g. 4 spaces or a tab). Single-line JSON stays on one line (None).
|
|
513
|
+
Rewriting with a fixed indent turns every version bump into a whole-file diff."""
|
|
514
|
+
if "\n" not in text.strip():
|
|
515
|
+
return None
|
|
516
|
+
m = re.search(r'\n([ \t]+)\S', text)
|
|
517
|
+
return m.group(1) if m else 2
|
|
518
|
+
|
|
519
|
+
|
|
353
520
|
def sync_json_version(target, new_version, key_path):
|
|
354
521
|
if not target.is_file():
|
|
355
522
|
log(f"WARNING: {target} not found — skipping")
|
|
356
523
|
return
|
|
524
|
+
raw = read_file(target)
|
|
357
525
|
try:
|
|
358
|
-
data = json.loads(
|
|
526
|
+
data = json.loads(raw)
|
|
359
527
|
except json.JSONDecodeError as e:
|
|
360
|
-
|
|
361
|
-
|
|
528
|
+
# 건너뛰고 성공으로 끝내면 태그·version.yml과 패키지 버전이 조용히 어긋난다.
|
|
529
|
+
raise VersionSyncError(f"{target} is not valid JSON ({e}) — cannot write version")
|
|
362
530
|
node = data
|
|
363
531
|
for k in key_path[:-1]:
|
|
364
532
|
node = node.setdefault(k, {})
|
|
365
533
|
node[key_path[-1]] = new_version
|
|
366
|
-
write_file(target, json.dumps(data, indent=
|
|
534
|
+
write_file(target, json.dumps(data, indent=_json_indent(raw), ensure_ascii=False) + "\n")
|
|
367
535
|
log(f"updated: {target}")
|
|
368
536
|
|
|
369
537
|
|
|
538
|
+
_TOML_HEADER_RE = re.compile(r'^[ \t]*\[+[ \t]*([^\]\n]+?)[ \t]*\]+[ \t]*(?:#.*)?$', re.MULTILINE)
|
|
539
|
+
_TOML_VERSION_RE = re.compile(r'^[ \t]*version[ \t]*=[ \t]*([\'"])([^\'"\n]*)\1', re.MULTILINE)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _pyproject_version_span(text):
|
|
543
|
+
"""[project] 또는 [tool.poetry] 섹션의 version 값 구간. [tool.*] 등 다른 섹션의
|
|
544
|
+
`version =`은 도구 설정이라 패키지 버전으로 쓰지 않는다."""
|
|
545
|
+
headers = list(_TOML_HEADER_RE.finditer(text))
|
|
546
|
+
for i, h in enumerate(headers):
|
|
547
|
+
if h.group(1) not in ("project", "tool.poetry"):
|
|
548
|
+
continue
|
|
549
|
+
end = headers[i + 1].start() if i + 1 < len(headers) else len(text)
|
|
550
|
+
m = _TOML_VERSION_RE.search(text, h.end(), end)
|
|
551
|
+
if m:
|
|
552
|
+
return m.start(2), m.end(2)
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# setup(version="x.y.z"). python_requires·python_version 같은 다른 키는 앞 글자로 거른다.
|
|
557
|
+
_SETUP_PY_VERSION_RE = re.compile(r"""(?<![\w.])version\s*=\s*(['"])([^'"]+)\1""")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _python_version_spans(path_dir):
|
|
561
|
+
"""버전을 읽고 쓰는 파일과 값 구간 목록. 읽을 때는 앞쪽(pyproject.toml → setup.py)이 우선이다.
|
|
562
|
+
setup.py만 쓰는 프로젝트도 설치 때 버전을 읽으므로, 릴리스 때도 같은 파일을 읽고 써야 한다."""
|
|
563
|
+
spans = []
|
|
564
|
+
pyproject = Path(path_dir) / "pyproject.toml"
|
|
565
|
+
if pyproject.is_file():
|
|
566
|
+
span = _pyproject_version_span(read_file(pyproject))
|
|
567
|
+
if span:
|
|
568
|
+
spans.append((pyproject, span))
|
|
569
|
+
setup_py = Path(path_dir) / "setup.py"
|
|
570
|
+
if setup_py.is_file():
|
|
571
|
+
m = _SETUP_PY_VERSION_RE.search(read_file(setup_py))
|
|
572
|
+
if m:
|
|
573
|
+
spans.append((setup_py, (m.start(2), m.end(2))))
|
|
574
|
+
return spans
|
|
575
|
+
|
|
576
|
+
|
|
370
577
|
def sync_python(path_dir, new_version):
|
|
371
|
-
|
|
372
|
-
if not
|
|
373
|
-
|
|
578
|
+
spans = _python_version_spans(path_dir)
|
|
579
|
+
if not spans:
|
|
580
|
+
# dynamic = ["version"] 등 버전을 파일에 두지 않는 구성은 쓸 곳이 없다.
|
|
581
|
+
log(f"WARNING: python: no version in pyproject.toml [project]/[tool.poetry] or setup.py under {path_dir} — skipping")
|
|
374
582
|
return
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
583
|
+
for target, (start, end) in spans:
|
|
584
|
+
text = read_file(target)
|
|
585
|
+
write_file(target, text[:start] + new_version + text[end:])
|
|
586
|
+
log(f"updated: {target}")
|
|
379
587
|
|
|
380
588
|
|
|
381
589
|
def sync_react_native(path_dir, new_version):
|
|
@@ -411,24 +619,11 @@ def sync_react_native(path_dir, new_version):
|
|
|
411
619
|
|
|
412
620
|
def sync_for_type(project_type, new_version, version_code_getter):
|
|
413
621
|
path_dir = get_type_path(project_type)
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
elif project_type == "flutter":
|
|
417
|
-
sync_flutter(path_dir, new_version, version_code_getter())
|
|
418
|
-
elif project_type in ("react", "next", "node"):
|
|
419
|
-
sync_json_version(Path(path_dir) / "package.json", new_version, ["version"])
|
|
420
|
-
elif project_type == "python":
|
|
421
|
-
sync_python(path_dir, new_version)
|
|
422
|
-
elif project_type == "react-native":
|
|
423
|
-
sync_react_native(path_dir, new_version)
|
|
424
|
-
elif project_type == "react-native-expo":
|
|
425
|
-
sync_json_version(Path(path_dir) / "app.json", new_version, ["expo", "version"])
|
|
426
|
-
elif project_type == "basic":
|
|
427
|
-
pass
|
|
428
|
-
elif project_type == "go":
|
|
429
|
-
pass
|
|
430
|
-
else:
|
|
622
|
+
handler = TYPE_HANDLERS.get(project_type)
|
|
623
|
+
if handler is None:
|
|
431
624
|
log(f"WARNING: unknown project type: {project_type} — skipping")
|
|
625
|
+
return
|
|
626
|
+
handler.sync(path_dir, new_version, version_code_getter)
|
|
432
627
|
|
|
433
628
|
|
|
434
629
|
def sync_all_project_files(new_version):
|
|
@@ -437,74 +632,176 @@ def sync_all_project_files(new_version):
|
|
|
437
632
|
# No silent fallback: an unreadable project_types used to degrade to
|
|
438
633
|
# "basic" and skip every sync without a word.
|
|
439
634
|
raise SystemExit("ERROR: version.yml has no readable project_types — cannot sync project files")
|
|
635
|
+
errors = []
|
|
440
636
|
for t in types:
|
|
441
|
-
|
|
637
|
+
# 한 타입이 실패해도 나머지는 맞춰 두고, 실패는 끝에서 모아 종료 코드로 알린다.
|
|
638
|
+
try:
|
|
639
|
+
sync_for_type(t, new_version, get_reconciled_version_code)
|
|
640
|
+
except VersionSyncError as e:
|
|
641
|
+
log(f"ERROR: {t}: {e}")
|
|
642
|
+
errors.append(t)
|
|
643
|
+
if errors:
|
|
644
|
+
raise VersionSyncError(f"project file sync failed for: {', '.join(errors)}")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
README_VERSION_LINE_RE = re.compile(
|
|
648
|
+
r"^(##[^:\n]*:[ \t]*)v?\d+\.\d+\.\d+([ \t]*\(\d{4}-\d{2}-\d{2}\))?[ \t]*$", re.MULTILINE)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def update_readme_version(new_version, path="README.md"):
|
|
652
|
+
"""AUTO-VERSION-SECTION 마커 바로 아래 버전 줄만 새 버전으로 바꾼다.
|
|
653
|
+
|
|
654
|
+
README 갱신 워크플로우는 태그를 만든 뒤에 돌기 때문에, 버전 확정 커밋에 README가 함께
|
|
655
|
+
들어가지 않으면 태그 시점 README가 한 버전 전을 가리킨다. 마커·줄 삽입 같은 나머지 일은
|
|
656
|
+
그 워크플로우에 맡기고, 여기서는 이미 있는 줄만 고친다(날짜 표기 여부도 그대로 둔다)."""
|
|
657
|
+
p = Path(path)
|
|
658
|
+
if not p.is_file():
|
|
659
|
+
return False
|
|
660
|
+
text = read_file(p)
|
|
661
|
+
lines = text.split("\n")
|
|
662
|
+
for i, line in enumerate(lines[:-1]):
|
|
663
|
+
if "AUTO-VERSION-SECTION" not in line:
|
|
664
|
+
continue
|
|
665
|
+
m = README_VERSION_LINE_RE.match(lines[i + 1])
|
|
666
|
+
if not m:
|
|
667
|
+
return False
|
|
668
|
+
date = f" ({datetime.date.today().isoformat()})" if m.group(2) else ""
|
|
669
|
+
new_line = f"{m.group(1)}v{new_version}{date}"
|
|
670
|
+
if new_line == lines[i + 1]:
|
|
671
|
+
return False
|
|
672
|
+
lines[i + 1] = new_line
|
|
673
|
+
write_file(p, "\n".join(lines))
|
|
674
|
+
log(f"README.md version line -> v{new_version}")
|
|
675
|
+
return True
|
|
676
|
+
return False
|
|
442
677
|
|
|
443
678
|
|
|
444
679
|
def update_all_versions(new_version):
|
|
445
680
|
update_version_yml(new_version)
|
|
446
681
|
sync_all_project_files(new_version)
|
|
682
|
+
update_readme_version(new_version)
|
|
447
683
|
|
|
448
684
|
|
|
449
685
|
# ===================================================================
|
|
450
|
-
# Project file -> version read-back
|
|
686
|
+
# Project file -> version read-back, and the per-type handler table
|
|
451
687
|
# ===================================================================
|
|
452
688
|
|
|
689
|
+
def _read_spring(path_dir):
|
|
690
|
+
# build.gradle에 버전이 없으면(allprojects 설정만 있는 경우 등) build.gradle.kts → pom.xml 순으로 이어 본다.
|
|
691
|
+
for name in ("build.gradle", "build.gradle.kts"):
|
|
692
|
+
p = Path(path_dir) / name
|
|
693
|
+
if p.is_file():
|
|
694
|
+
for m in _GRADLE_VERSION_RE.finditer(read_file(p)):
|
|
695
|
+
version = core_version(m.group(3))
|
|
696
|
+
if version:
|
|
697
|
+
return version
|
|
698
|
+
pom = Path(path_dir) / "pom.xml"
|
|
699
|
+
if pom.is_file():
|
|
700
|
+
return core_version(_pom_text(read_file(pom), ["version"]))
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _read_flutter(path_dir):
|
|
705
|
+
p = Path(path_dir) / "pubspec.yaml"
|
|
706
|
+
if p.is_file():
|
|
707
|
+
text = p.read_text(encoding="utf-8")
|
|
708
|
+
m = re.search(r'^version:\s*([^\s#]+)', text, re.MULTILINE)
|
|
709
|
+
if m:
|
|
710
|
+
return core_version(m.group(1))
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _read_package_json(path_dir):
|
|
715
|
+
p = Path(path_dir) / "package.json"
|
|
716
|
+
if p.is_file():
|
|
717
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
718
|
+
return core_version(data.get("version"))
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _read_react_native(path_dir):
|
|
723
|
+
"""릴리스 때 쓰는 파일(ios/<앱>/Info.plist, android/app/build.gradle)에서 읽는다.
|
|
724
|
+
package.json version은 동기화하지 않으므로 기준으로 삼으면 매 릴리스마다 어긋난다.
|
|
725
|
+
$(MARKETING_VERSION) 참조나 템플릿 기본값 "1.0"처럼 x.y.z가 아닌 값은 건너뛴다."""
|
|
726
|
+
ios_dir = Path(path_dir) / "ios"
|
|
727
|
+
if ios_dir.is_dir():
|
|
728
|
+
# rglob 첫 결과는 파일시스템 순서라 Pods·테스트 타깃 plist가 걸릴 수 있다 — 앱 폴더 한 단계만, 이름순.
|
|
729
|
+
for plist in sorted(ios_dir.glob("*/Info.plist")):
|
|
730
|
+
m = re.search(r'<key>CFBundleShortVersionString</key>\s*<string>([^<]*)</string>', read_file(plist))
|
|
731
|
+
version = core_version(m.group(1)) if m else None
|
|
732
|
+
if version:
|
|
733
|
+
return version
|
|
734
|
+
gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
|
|
735
|
+
if gradle_file.is_file():
|
|
736
|
+
m = re.search(r'versionName\s+"([^"]+)"', read_file(gradle_file))
|
|
737
|
+
if m:
|
|
738
|
+
return core_version(m.group(1))
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _read_expo(path_dir):
|
|
743
|
+
p = Path(path_dir) / "app.json"
|
|
744
|
+
if p.is_file():
|
|
745
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
746
|
+
return core_version((data.get("expo") or {}).get("version"))
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _read_python(path_dir):
|
|
751
|
+
for target, (start, end) in _python_version_spans(path_dir):
|
|
752
|
+
version = core_version(read_file(target)[start:end])
|
|
753
|
+
if version:
|
|
754
|
+
return version
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _read_none(path_dir):
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _sync_none(path_dir, new_version, version_code_getter):
|
|
763
|
+
pass
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class TypeHandler(NamedTuple):
|
|
767
|
+
"""타입별 버전 파일 처리. read(path_dir)는 파일의 버전(없으면 None),
|
|
768
|
+
sync(path_dir, new_version, version_code_getter)는 새 버전을 파일에 쓴다."""
|
|
769
|
+
read: Callable
|
|
770
|
+
sync: Callable
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
_PACKAGE_JSON = TypeHandler(
|
|
774
|
+
read=_read_package_json,
|
|
775
|
+
sync=lambda d, v, _code: sync_json_version(Path(d) / "package.json", v, ["version"]),
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# 새 타입은 여기 한 줄만 추가하면 읽기(sync 비교)와 쓰기가 함께 따라온다.
|
|
779
|
+
# 버전 파일이 없는 타입(basic·go)은 version.yml만 쓰도록 빈 핸들러를 둔다.
|
|
780
|
+
TYPE_HANDLERS = {
|
|
781
|
+
"spring": TypeHandler(read=_read_spring, sync=lambda d, v, _code: sync_spring(d, v)),
|
|
782
|
+
# build number가 필요한 타입만 version_code를 계산한다 (pubspec 조정 부수효과가 있다).
|
|
783
|
+
"flutter": TypeHandler(read=_read_flutter, sync=lambda d, v, code: sync_flutter(d, v, code())),
|
|
784
|
+
"react": _PACKAGE_JSON,
|
|
785
|
+
"next": _PACKAGE_JSON,
|
|
786
|
+
"node": _PACKAGE_JSON,
|
|
787
|
+
"python": TypeHandler(read=_read_python, sync=lambda d, v, _code: sync_python(d, v)),
|
|
788
|
+
"react-native": TypeHandler(read=_read_react_native, sync=lambda d, v, _code: sync_react_native(d, v)),
|
|
789
|
+
"react-native-expo": TypeHandler(
|
|
790
|
+
read=_read_expo,
|
|
791
|
+
sync=lambda d, v, _code: sync_json_version(Path(d) / "app.json", v, ["expo", "version"]),
|
|
792
|
+
),
|
|
793
|
+
"basic": TypeHandler(read=_read_none, sync=_sync_none),
|
|
794
|
+
"go": TypeHandler(read=_read_none, sync=_sync_none),
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
|
|
453
798
|
def get_project_file_version(project_type):
|
|
454
799
|
path_dir = get_type_path(project_type)
|
|
800
|
+
handler = TYPE_HANDLERS.get(project_type)
|
|
455
801
|
version = None
|
|
456
802
|
try:
|
|
457
|
-
if
|
|
458
|
-
|
|
459
|
-
p = Path(path_dir) / name
|
|
460
|
-
if p.is_file():
|
|
461
|
-
text = p.read_text(encoding="utf-8")
|
|
462
|
-
m = re.search(r"^\s*version\s*=\s*['\"](\d+\.\d+\.\d+)['\"]", text, re.MULTILINE)
|
|
463
|
-
if m:
|
|
464
|
-
version = m.group(1)
|
|
465
|
-
break
|
|
466
|
-
elif project_type == "flutter":
|
|
467
|
-
p = Path(path_dir) / "pubspec.yaml"
|
|
468
|
-
if p.is_file():
|
|
469
|
-
text = p.read_text(encoding="utf-8")
|
|
470
|
-
m = re.search(r'^version:\s*([^\s#]+)', text, re.MULTILINE)
|
|
471
|
-
if m:
|
|
472
|
-
version = m.group(1).split("+")[0]
|
|
473
|
-
elif project_type in ("react", "next", "node"):
|
|
474
|
-
p = Path(path_dir) / "package.json"
|
|
475
|
-
if p.is_file():
|
|
476
|
-
data = json.loads(p.read_text(encoding="utf-8"))
|
|
477
|
-
version = data.get("version")
|
|
478
|
-
elif project_type == "react-native":
|
|
479
|
-
ios_dir = Path(path_dir) / "ios"
|
|
480
|
-
plist = None
|
|
481
|
-
if ios_dir.is_dir():
|
|
482
|
-
plists = list(ios_dir.rglob("Info.plist"))
|
|
483
|
-
plist = plists[0] if plists else None
|
|
484
|
-
if plist is not None:
|
|
485
|
-
text = plist.read_text(encoding="utf-8")
|
|
486
|
-
m = re.search(r'<key>CFBundleShortVersionString</key>\s*<string>([^<]*)</string>', text)
|
|
487
|
-
if m:
|
|
488
|
-
version = m.group(1)
|
|
489
|
-
else:
|
|
490
|
-
gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
|
|
491
|
-
if gradle_file.is_file():
|
|
492
|
-
text = gradle_file.read_text(encoding="utf-8")
|
|
493
|
-
m = re.search(r'versionName\s+"([^"]+)"', text)
|
|
494
|
-
if m:
|
|
495
|
-
version = m.group(1)
|
|
496
|
-
elif project_type == "react-native-expo":
|
|
497
|
-
p = Path(path_dir) / "app.json"
|
|
498
|
-
if p.is_file():
|
|
499
|
-
data = json.loads(p.read_text(encoding="utf-8"))
|
|
500
|
-
version = (data.get("expo") or {}).get("version")
|
|
501
|
-
elif project_type == "python":
|
|
502
|
-
p = Path(path_dir) / "pyproject.toml"
|
|
503
|
-
if p.is_file():
|
|
504
|
-
text = p.read_text(encoding="utf-8")
|
|
505
|
-
m = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE)
|
|
506
|
-
if m:
|
|
507
|
-
version = m.group(1)
|
|
803
|
+
if handler is not None:
|
|
804
|
+
version = handler.read(path_dir)
|
|
508
805
|
except Exception as e:
|
|
509
806
|
log(f"WARNING: failed reading project file for {project_type}: {e}")
|
|
510
807
|
version = None
|
|
@@ -560,14 +857,14 @@ def cmd_get(args):
|
|
|
560
857
|
|
|
561
858
|
def cmd_get_code(args):
|
|
562
859
|
require_version_yml()
|
|
563
|
-
code =
|
|
860
|
+
code = get_reconciled_version_code()
|
|
564
861
|
print(code)
|
|
565
862
|
return 0
|
|
566
863
|
|
|
567
864
|
|
|
568
865
|
def cmd_increment_code(args):
|
|
569
866
|
require_version_yml()
|
|
570
|
-
current =
|
|
867
|
+
current = get_reconciled_version_code()
|
|
571
868
|
new_code = current + 1
|
|
572
869
|
set_version_code(new_code)
|
|
573
870
|
print(new_code)
|
|
@@ -582,11 +879,10 @@ def cmd_increment(args):
|
|
|
582
879
|
return 1
|
|
583
880
|
bump = getattr(args, "bump", None) or "patch"
|
|
584
881
|
new_version = increment_version(current_version, bump)
|
|
882
|
+
# build number를 먼저 올려야 이어지는 pubspec 동기화에 새 값이 함께 기록된다.
|
|
883
|
+
set_version_code(get_reconciled_version_code() + 1)
|
|
585
884
|
update_all_versions(new_version)
|
|
586
885
|
|
|
587
|
-
current_code = int(get_version_code())
|
|
588
|
-
set_version_code(current_code + 1)
|
|
589
|
-
|
|
590
886
|
print(new_version)
|
|
591
887
|
return 0
|
|
592
888
|
|
|
@@ -640,7 +936,11 @@ def main(argv=None):
|
|
|
640
936
|
"sync": cmd_sync,
|
|
641
937
|
}
|
|
642
938
|
handler = handlers[args.command]
|
|
643
|
-
|
|
939
|
+
try:
|
|
940
|
+
return handler(args)
|
|
941
|
+
except VersionSyncError as e:
|
|
942
|
+
log(f"ERROR: {e}")
|
|
943
|
+
return 1
|
|
644
944
|
|
|
645
945
|
|
|
646
946
|
if __name__ == "__main__":
|
|
@@ -18,14 +18,16 @@
|
|
|
18
18
|
# (e.g., flutter: "app", react: "client"). Omitted -> repo root.
|
|
19
19
|
#
|
|
20
20
|
# Synced files per type:
|
|
21
|
-
# - spring: build.gradle / build.gradle.kts
|
|
21
|
+
# - spring: build.gradle / build.gradle.kts / pom.xml
|
|
22
22
|
# - flutter: pubspec.yaml
|
|
23
23
|
# - next/react/node: package.json
|
|
24
24
|
# - react-native: Info.plist + build.gradle
|
|
25
25
|
# - react-native-expo: app.json
|
|
26
|
-
# - python: pyproject.toml
|
|
26
|
+
# - python: pyproject.toml / setup.py
|
|
27
27
|
# - go: (none — git tag based, go.mod has no version field)
|
|
28
28
|
# - basic: version.yml only
|
|
29
|
+
# A file version like 1.2.0-rc.1 or 1.2.0+4 is read as 1.2.0 (version below is x.y.z only).
|
|
30
|
+
# Gradle/Maven -SNAPSHOT is kept when the version is bumped (1.2.0-SNAPSHOT -> 1.2.1-SNAPSHOT).
|
|
29
31
|
# ===
|
|
30
32
|
|
|
31
33
|
version: "{{VERSION}}"
|