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.
Files changed (86) hide show
  1. package/README.ja.md +165 -0
  2. package/README.ko.md +165 -0
  3. package/README.md +107 -286
  4. package/README.zh-CN.md +165 -0
  5. package/bin/project-auto-wizard.js +1 -2
  6. package/package.json +7 -3
  7. package/payload/config/breaking-changes.json +24 -0
  8. package/payload/config/wizard-prompts.yml +45 -0
  9. package/payload/scripts/changelog_manager.py +141 -46
  10. package/payload/scripts/issue_helper.py +34 -5
  11. package/payload/scripts/version_manager.py +397 -97
  12. package/payload/version.yml.template +4 -2
  13. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +39 -9
  14. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +165 -38
  15. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +14 -5
  16. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +220 -6
  17. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +89 -3
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml +78 -16
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +72 -16
  20. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml +62 -20
  21. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +51 -13
  22. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +75 -11
  23. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +52 -3
  24. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml +60 -11
  25. package/payload/workflows/go/PROJECT-GO-CI.yaml +10 -1
  26. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +148 -28
  27. package/payload/workflows/go/PROJECT-GO-SIMPLE-CICD.yaml +45 -4
  28. package/payload/workflows/next/PROJECT-NEXT-CI.yaml +25 -3
  29. package/payload/workflows/next/PROJECT-NEXT-CICD.yaml +83 -9
  30. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +70 -6
  31. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +148 -28
  32. package/payload/workflows/python/PROJECT-PYTHON-SIMPLE-CICD.yaml +45 -4
  33. package/payload/workflows/react/PROJECT-REACT-CI.yaml +25 -3
  34. package/payload/workflows/react/PROJECT-REACT-CICD.yaml +84 -10
  35. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +13 -3
  36. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-NGINX-CICD.yaml +47 -5
  37. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-NONSTOP-TRAEFIK-CICD.yaml +47 -5
  38. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +153 -30
  39. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-SIMPLE-CICD.yaml +47 -5
  40. package/src/cli/args.js +33 -24
  41. package/src/cli/help.js +16 -4
  42. package/src/commands/doctor.js +17 -10
  43. package/src/commands/dry-run.js +54 -1
  44. package/src/commands/full.js +127 -50
  45. package/src/commands/interactive-flutter.js +1 -1
  46. package/src/commands/interactive.js +124 -56
  47. package/src/commands/purge.js +59 -17
  48. package/src/commands/status.js +16 -1
  49. package/src/commands/uninstall.js +58 -13
  50. package/src/context.js +5 -5
  51. package/src/core/baseline.js +20 -5
  52. package/src/core/branches.js +19 -1
  53. package/src/core/branding.js +1 -1
  54. package/src/core/breaking-check.js +14 -19
  55. package/src/core/breaking.js +7 -3
  56. package/src/core/copy/gitignore.js +20 -12
  57. package/src/core/copy/readme.js +24 -5
  58. package/src/core/copy/simple.js +52 -13
  59. package/src/core/copy/workflows.js +90 -47
  60. package/src/core/deploy-style.js +83 -11
  61. package/src/core/detect-fs.js +127 -24
  62. package/src/core/detect.js +131 -47
  63. package/src/core/errors.js +3 -0
  64. package/src/core/flutter-options.js +4 -3
  65. package/src/core/fsutil.js +23 -6
  66. package/src/core/logger.js +55 -19
  67. package/src/core/paths-resolve.js +50 -47
  68. package/src/core/paths.js +26 -1
  69. package/src/core/release-options.js +23 -0
  70. package/src/core/removal-plan.js +75 -19
  71. package/src/core/types.js +113 -0
  72. package/src/core/verify.js +85 -19
  73. package/src/core/version-yml.js +42 -8
  74. package/src/core/wizard-env.js +21 -14
  75. package/src/core/wizard-labels.js +34 -25
  76. package/src/index.js +122 -49
  77. package/src/ui/ansi.js +3 -1
  78. package/src/ui/banner.js +12 -8
  79. package/src/ui/env-plan.js +68 -44
  80. package/src/ui/prompts.js +22 -12
  81. package/src/ui/readline-engine.js +109 -22
  82. package/src/ui/status-cards.js +10 -5
  83. package/src/ui/summary.js +71 -33
  84. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  85. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  86. 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
- It is a Python rewrite of the battle-tested bash reference implementation
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 or missing version.yml.
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
- m = re.search(r'^project_paths:[ \t]*\n((?:[ \t]+.+\n?)+)', text, re.MULTILINE)
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) + r'["\']?:[ \t]*["\']?([^"\'\n]+?)["\']?[ \t]*$',
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
- log(f"WARNING: spring: no build.gradle(.kts) found under {path_dir} — skipping")
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
- new_text = re.sub(r"version\s*=\s*'[^']*'", f"version = '{new_version}'", text)
332
- new_text = re.sub(r'version\s*=\s*"[^"]*"', f'version = "{new_version}"', new_text)
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(read_file(target))
526
+ data = json.loads(raw)
359
527
  except json.JSONDecodeError as e:
360
- log(f"WARNING: {target} invalid JSON ({e}) — skipping")
361
- return
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=2, ensure_ascii=False) + "\n")
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
- target = Path(path_dir) / "pyproject.toml"
372
- if not target.is_file():
373
- log(f"WARNING: python: {target} not found — skipping")
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
- text = read_file(target)
376
- new_text = re.sub(r'^version\s*=\s*"[^"]*"', f'version = "{new_version}"', text, count=1, flags=re.MULTILINE)
377
- write_file(target, new_text)
378
- log(f"updated: {target}")
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
- if project_type == "spring":
415
- sync_spring(path_dir, new_version)
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
- sync_for_type(t, new_version, get_version_code)
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 (for sync comparison)
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 project_type == "spring":
458
- for name in ("build.gradle", "build.gradle.kts"):
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 = get_version_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 = int(get_version_code())
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
- return handler(args)
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}}"