project-auto-wizard 0.12.2 → 0.13.1

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 (91) 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 +9 -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 +482 -107
  12. package/payload/version.yml.template +5 -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 +26 -60
  43. package/src/commands/dry-run.js +59 -5
  44. package/src/commands/full.js +135 -55
  45. package/src/commands/install-settings.js +51 -0
  46. package/src/commands/interactive-flutter.js +1 -1
  47. package/src/commands/interactive.js +135 -69
  48. package/src/commands/purge.js +50 -21
  49. package/src/commands/status.js +18 -12
  50. package/src/commands/uninstall.js +22 -22
  51. package/src/context.js +5 -5
  52. package/src/core/baseline.js +20 -5
  53. package/src/core/branches.js +19 -1
  54. package/src/core/branding.js +1 -1
  55. package/src/core/breaking-check.js +14 -19
  56. package/src/core/breaking.js +7 -3
  57. package/src/core/copy/app-files.js +28 -0
  58. package/src/core/copy/gitignore.js +20 -12
  59. package/src/core/copy/readme.js +24 -5
  60. package/src/core/copy/simple.js +52 -13
  61. package/src/core/copy/workflows.js +133 -111
  62. package/src/core/deploy-style.js +83 -11
  63. package/src/core/detect-fs.js +127 -24
  64. package/src/core/detect.js +177 -47
  65. package/src/core/errors.js +3 -0
  66. package/src/core/flutter-doctor.js +49 -0
  67. package/src/core/flutter-hooks.js +29 -0
  68. package/src/core/flutter-options.js +4 -3
  69. package/src/core/fsutil.js +23 -6
  70. package/src/core/logger.js +55 -19
  71. package/src/core/paths-resolve.js +50 -47
  72. package/src/core/paths.js +26 -1
  73. package/src/core/release-options.js +23 -0
  74. package/src/core/removal-exec.js +67 -0
  75. package/src/core/removal-plan.js +75 -19
  76. package/src/core/types.js +136 -0
  77. package/src/core/verify.js +85 -19
  78. package/src/core/version-yml.js +42 -8
  79. package/src/core/wizard-env.js +21 -14
  80. package/src/core/wizard-labels.js +34 -25
  81. package/src/index.js +128 -60
  82. package/src/ui/ansi.js +3 -1
  83. package/src/ui/banner.js +12 -8
  84. package/src/ui/env-plan.js +68 -44
  85. package/src/ui/prompts.js +22 -12
  86. package/src/ui/readline-engine.js +109 -22
  87. package/src/ui/status-cards.js +10 -5
  88. package/src/ui/summary.js +71 -33
  89. package/payload/scripts/__pycache__/changelog_manager.cpython-314.pyc +0 -0
  90. package/payload/scripts/__pycache__/issue_helper.cpython-314.pyc +0 -0
  91. 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,200 @@ 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
+ _GRADLE_SHARED_BLOCKS = ("allprojects", "subprojects")
440
+
441
+
442
+ def _gradle_code(line, quote=""):
443
+ """한 줄에서 주석을 떼고 따옴표 안 글자는 공백으로 바꾼다. url 'https://…'의 `//`나
444
+ 문자열 속 중괄호가 블록 깊이 계산을 흐트러뜨리지 않게 한다. detect.js와 같은 규칙.
445
+
446
+ quote는 이 줄이 시작될 때 열려 있던 따옴표다. 삼중따옴표(\"\"\" ''')는 여러 줄에 걸치므로
447
+ (코드, 줄 끝에서 열려 있는 따옴표)를 돌려줘 다음 줄이 이어받게 한다. 한 줄짜리 따옴표는
448
+ 줄 끝에서 닫힌 것으로 본다."""
449
+ out, i = [], 0
450
+ while i < len(line):
451
+ ch = line[i]
452
+ if quote:
453
+ if ch == "\\" and i + 1 < len(line):
454
+ out.append(" "); i += 2; continue
455
+ if line.startswith(quote, i):
456
+ out.append(quote); i += len(quote); quote = ""; continue
457
+ out.append(" ")
458
+ elif line.startswith(('"""', "'''"), i):
459
+ quote = line[i:i + 3]; out.append(quote); i += 3; continue
460
+ elif ch in "'\"":
461
+ quote = ch; out.append(ch)
462
+ elif line.startswith("//", i):
463
+ break
464
+ else:
465
+ out.append(ch)
466
+ i += 1
467
+ return "".join(out), quote if len(quote) == 3 else ""
468
+
469
+
470
+ def _gradle_version_matches(text):
471
+ """프로젝트 버전 줄의 match 목록. 읽기와 동기화가 이 한 곳을 공유해 같은 줄만 다룬다.
472
+
473
+ 들여쓰지 않은 `version =`이 있으면 그것만 쓴다. 없을 때만 allprojects/subprojects 블록 안의
474
+ 들여쓴 줄을 인정한다 — `node { version = '20.11.0' }` 같은 플러그인 설정 블록을
475
+ 프로젝트 버전으로 오인하면 버전이 뛰고 빌드 설정이 깨진다."""
476
+ top, shared = [], []
477
+ stack = [] # 열린 블록 이름. 줄 끝이 `{`로 끝나는 줄의 마지막 단어
478
+ quote = "" # 여러 줄 문자열 안이면 그 따옴표. 문자열 속 줄은 코드가 아니다
479
+ pos = 0
480
+ for line in text.splitlines(keepends=True):
481
+ m = None if quote else _GRADLE_VERSION_RE.match(line)
482
+ if m:
483
+ m = _GRADLE_VERSION_RE.match(text, pos)
484
+ if not m.group(1)[:1].isspace():
485
+ top.append(m)
486
+ elif any(b in _GRADLE_SHARED_BLOCKS for b in stack):
487
+ shared.append(m)
488
+ code, quote = _gradle_code(line, quote)
489
+ name = re.search(r"(\w+)\s*\{[^{}]*$", code)
490
+ for ch in code:
491
+ if ch == "{":
492
+ stack.append(name.group(1) if name else "")
493
+ elif ch == "}" and stack:
494
+ stack.pop()
495
+ pos += len(line)
496
+ return top or shared
497
+
498
+
319
499
  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)."""
500
+ """Look for build.gradle or build.gradle.kts under path_dir (root of that dir, like bash's maxdepth 2),
501
+ and pom.xml for Maven projects."""
321
502
  candidates = []
322
503
  for name in ("build.gradle", "build.gradle.kts"):
323
504
  for p in [Path(path_dir) / name] + list(Path(path_dir).glob("*/" + name)):
324
505
  if p.is_file():
325
506
  candidates.append(p)
507
+ has_pom = sync_maven(path_dir, new_version)
326
508
  if not candidates:
327
- log(f"WARNING: spring: no build.gradle(.kts) found under {path_dir} — skipping")
509
+ if not has_pom:
510
+ log(f"WARNING: spring: no build.gradle(.kts) or pom.xml found under {path_dir} — skipping")
328
511
  return
329
512
  for gradle_file in candidates:
330
513
  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)
514
+ # 줄 시작의 `version =`만 프로젝트 버전이다. 앵커가 없으면 kotlin_version 같은
515
+ # 의존성 버전 변수까지 함께 바뀌어 빌드가 깨진다.
516
+ matches = _gradle_version_matches(text)
517
+ new_text = text
518
+ # 뒤에서부터 바꿔 앞쪽 span이 밀리지 않게 한다.
519
+ for m in reversed(matches):
520
+ replaced = f"{m.group(1)}{m.group(2)}{_keep_snapshot(m.group(3), new_version)}{m.group(2)}"
521
+ new_text = new_text[:m.start()] + replaced + new_text[m.end():]
522
+ if not matches:
523
+ log(f"WARNING: spring: no `version = '...'` line in {gradle_file} — skipping")
524
+ continue
333
525
  write_file(gradle_file, new_text)
334
526
  log(f"updated: {gradle_file}")
335
527
 
336
528
 
529
+ def _pubspec_build_number(path_dir):
530
+ """pubspec.yaml `version: x.y.z+N`의 N. 없으면 None."""
531
+ target = Path(path_dir) / "pubspec.yaml"
532
+ if not target.is_file():
533
+ return None
534
+ m = re.search(r'^version:[ \t]*[^\s#+]+\+(\d+)', read_file(target), re.MULTILINE)
535
+ return int(m.group(1)) if m else None
536
+
537
+
538
+ def get_reconciled_version_code():
539
+ """version.yml의 version_code와 pubspec.yaml의 +N 중 큰 값.
540
+ 로컬 수동 배포 등으로 pubspec 쪽이 앞서 있으면 스토어는 더 작은 build number를
541
+ 거부하므로, 그 값을 version.yml에 반영해 역행을 막는다."""
542
+ code = int(get_version_code())
543
+ types = get_project_types_csv()
544
+ pubspec_codes = [
545
+ n for n in (_pubspec_build_number(get_type_path(t)) for t in types if t == "flutter")
546
+ if n is not None
547
+ ]
548
+ if pubspec_codes and max(pubspec_codes) > code:
549
+ log(f"pubspec.yaml build number {max(pubspec_codes)} is ahead of version_code {code} — adopting it")
550
+ code = max(pubspec_codes)
551
+ set_version_code(code)
552
+ return code
553
+
554
+
337
555
  def sync_flutter(path_dir, new_version, version_code):
338
556
  target = Path(path_dir) / "pubspec.yaml"
339
557
  if not target.is_file():
@@ -350,49 +568,115 @@ def sync_flutter(path_dir, new_version, version_code):
350
568
  log(f"updated: {target}")
351
569
 
352
570
 
571
+ def _json_indent(text):
572
+ """Indentation used by an existing JSON file — the first indented line after a newline
573
+ (e.g. 4 spaces or a tab). Single-line JSON stays on one line (None).
574
+ Rewriting with a fixed indent turns every version bump into a whole-file diff."""
575
+ if "\n" not in text.strip():
576
+ return None
577
+ m = re.search(r'\n([ \t]+)\S', text)
578
+ return m.group(1) if m else 2
579
+
580
+
353
581
  def sync_json_version(target, new_version, key_path):
354
582
  if not target.is_file():
355
583
  log(f"WARNING: {target} not found — skipping")
356
584
  return
585
+ raw = read_file(target)
357
586
  try:
358
- data = json.loads(read_file(target))
587
+ data = json.loads(raw)
359
588
  except json.JSONDecodeError as e:
360
- log(f"WARNING: {target} invalid JSON ({e}) — skipping")
361
- return
589
+ # 건너뛰고 성공으로 끝내면 태그·version.yml과 패키지 버전이 조용히 어긋난다.
590
+ raise VersionSyncError(f"{target} is not valid JSON ({e}) — cannot write version")
362
591
  node = data
363
592
  for k in key_path[:-1]:
364
593
  node = node.setdefault(k, {})
365
594
  node[key_path[-1]] = new_version
366
- write_file(target, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
595
+ write_file(target, json.dumps(data, indent=_json_indent(raw), ensure_ascii=False) + "\n")
367
596
  log(f"updated: {target}")
368
597
 
369
598
 
599
+ _TOML_HEADER_RE = re.compile(r'^[ \t]*\[+[ \t]*([^\]\n]+?)[ \t]*\]+[ \t]*(?:#.*)?$', re.MULTILINE)
600
+ _TOML_VERSION_RE = re.compile(r'^[ \t]*version[ \t]*=[ \t]*([\'"])([^\'"\n]*)\1', re.MULTILINE)
601
+
602
+
603
+ def _pyproject_version_span(text):
604
+ """[project] 또는 [tool.poetry] 섹션의 version 값 구간. [tool.*] 등 다른 섹션의
605
+ `version =`은 도구 설정이라 패키지 버전으로 쓰지 않는다."""
606
+ headers = list(_TOML_HEADER_RE.finditer(text))
607
+ for i, h in enumerate(headers):
608
+ if h.group(1) not in ("project", "tool.poetry"):
609
+ continue
610
+ end = headers[i + 1].start() if i + 1 < len(headers) else len(text)
611
+ m = _TOML_VERSION_RE.search(text, h.end(), end)
612
+ if m:
613
+ return m.start(2), m.end(2)
614
+ return None
615
+
616
+
617
+ # setup(version="x.y.z"). python_requires·python_version 같은 다른 키는 앞 글자로 거른다.
618
+ _SETUP_PY_VERSION_RE = re.compile(r"""(?<![\w.])version\s*=\s*(['"])([^'"]+)\1""")
619
+
620
+
621
+ def _python_version_spans(path_dir):
622
+ """버전을 읽고 쓰는 파일과 값 구간 목록. 읽을 때는 앞쪽(pyproject.toml → setup.py)이 우선이다.
623
+ setup.py만 쓰는 프로젝트도 설치 때 버전을 읽으므로, 릴리스 때도 같은 파일을 읽고 써야 한다."""
624
+ spans = []
625
+ pyproject = Path(path_dir) / "pyproject.toml"
626
+ if pyproject.is_file():
627
+ span = _pyproject_version_span(read_file(pyproject))
628
+ if span:
629
+ spans.append((pyproject, span))
630
+ setup_py = Path(path_dir) / "setup.py"
631
+ if setup_py.is_file():
632
+ m = _SETUP_PY_VERSION_RE.search(read_file(setup_py))
633
+ if m:
634
+ spans.append((setup_py, (m.start(2), m.end(2))))
635
+ return spans
636
+
637
+
370
638
  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")
639
+ spans = _python_version_spans(path_dir)
640
+ if not spans:
641
+ # dynamic = ["version"] 등 버전을 파일에 두지 않는 구성은 쓸 곳이 없다.
642
+ log(f"WARNING: python: no version in pyproject.toml [project]/[tool.poetry] or setup.py under {path_dir} — skipping")
374
643
  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}")
644
+ for target, (start, end) in spans:
645
+ text = read_file(target)
646
+ write_file(target, text[:start] + new_version + text[end:])
647
+ log(f"updated: {target}")
648
+
649
+
650
+ _PLIST_VERSION_RE = re.compile(r'(<key>CFBundleShortVersionString</key>\s*<string>)([^<]*)(</string>)')
651
+
652
+
653
+ def _rn_app_plists(ios_dir):
654
+ """앱 타깃의 Info.plist만 이름순으로. Pods·빌드 산출물·테스트 타깃은 우리 버전이 아니다.
655
+ 읽기와 동기화가 같은 파일 집합을 보도록 한 곳에서 고른다."""
656
+ skip = {"Pods", "build"}
657
+ return [
658
+ p for p in sorted(ios_dir.glob("*/Info.plist"))
659
+ if p.parent.name not in skip and not p.parent.name.endswith("Tests")
660
+ ]
379
661
 
380
662
 
381
663
  def sync_react_native(path_dir, new_version):
382
664
  ios_dir = Path(path_dir) / "ios"
383
665
  found_plist = False
384
666
  if ios_dir.is_dir():
385
- for plist_file in ios_dir.rglob("Info.plist"):
667
+ for plist_file in _rn_app_plists(ios_dir):
386
668
  text = read_file(plist_file)
387
- if "CFBundleShortVersionString" in text:
388
- new_text = re.sub(
389
- r'(<key>CFBundleShortVersionString</key>\s*<string>)[^<]*(</string>)',
390
- r'\g<1>' + new_version + r'\g<2>',
391
- text,
392
- )
393
- write_file(plist_file, new_text)
394
- log(f"updated: {plist_file}")
395
- found_plist = True
669
+ m = _PLIST_VERSION_RE.search(text)
670
+ if not m:
671
+ continue
672
+ # $(MARKETING_VERSION) 같은 빌드 변수 참조는 Xcode 설정이 원본이라 덮어쓰지 않는다.
673
+ if m.group(2).strip().startswith("$("):
674
+ log(f"skipped: {plist_file} — CFBundleShortVersionString references a build variable")
675
+ continue
676
+ new_text = _PLIST_VERSION_RE.sub(lambda mm: mm.group(1) + new_version + mm.group(3), text)
677
+ write_file(plist_file, new_text)
678
+ log(f"updated: {plist_file}")
679
+ found_plist = True
396
680
  else:
397
681
  log(f"WARNING: react-native: {ios_dir} not found — skipping")
398
682
 
@@ -411,24 +695,11 @@ def sync_react_native(path_dir, new_version):
411
695
 
412
696
  def sync_for_type(project_type, new_version, version_code_getter):
413
697
  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:
698
+ handler = TYPE_HANDLERS.get(project_type)
699
+ if handler is None:
431
700
  log(f"WARNING: unknown project type: {project_type} — skipping")
701
+ return
702
+ handler.sync(path_dir, new_version, version_code_getter)
432
703
 
433
704
 
434
705
  def sync_all_project_files(new_version):
@@ -437,74 +708,175 @@ def sync_all_project_files(new_version):
437
708
  # No silent fallback: an unreadable project_types used to degrade to
438
709
  # "basic" and skip every sync without a word.
439
710
  raise SystemExit("ERROR: version.yml has no readable project_types — cannot sync project files")
711
+ errors = []
440
712
  for t in types:
441
- sync_for_type(t, new_version, get_version_code)
713
+ # 한 타입이 실패해도 나머지는 맞춰 두고, 실패는 끝에서 모아 종료 코드로 알린다.
714
+ try:
715
+ sync_for_type(t, new_version, get_reconciled_version_code)
716
+ except VersionSyncError as e:
717
+ log(f"ERROR: {t}: {e}")
718
+ errors.append(t)
719
+ if errors:
720
+ raise VersionSyncError(f"project file sync failed for: {', '.join(errors)}")
721
+
722
+
723
+ README_VERSION_LINE_RE = re.compile(
724
+ r"^(##[^:\n]*:[ \t]*)v?\d+\.\d+\.\d+([ \t]*\(\d{4}-\d{2}-\d{2}\))?[ \t]*$", re.MULTILINE)
725
+
726
+
727
+ def update_readme_version(new_version, path="README.md"):
728
+ """AUTO-VERSION-SECTION 마커 바로 아래 버전 줄만 새 버전으로 바꾼다.
729
+
730
+ README 갱신 워크플로우는 태그를 만든 뒤에 돌기 때문에, 버전 확정 커밋에 README가 함께
731
+ 들어가지 않으면 태그 시점 README가 한 버전 전을 가리킨다. 마커·줄 삽입 같은 나머지 일은
732
+ 그 워크플로우에 맡기고, 여기서는 이미 있는 줄만 고친다(날짜 표기 여부도 그대로 둔다)."""
733
+ p = Path(path)
734
+ if not p.is_file():
735
+ return False
736
+ text = read_file(p)
737
+ lines = text.split("\n")
738
+ for i, line in enumerate(lines[:-1]):
739
+ if "AUTO-VERSION-SECTION" not in line:
740
+ continue
741
+ m = README_VERSION_LINE_RE.match(lines[i + 1])
742
+ if not m:
743
+ return False
744
+ date = f" ({datetime.date.today().isoformat()})" if m.group(2) else ""
745
+ new_line = f"{m.group(1)}v{new_version}{date}"
746
+ if new_line == lines[i + 1]:
747
+ return False
748
+ lines[i + 1] = new_line
749
+ write_file(p, "\n".join(lines))
750
+ log(f"README.md version line -> v{new_version}")
751
+ return True
752
+ return False
442
753
 
443
754
 
444
755
  def update_all_versions(new_version):
445
756
  update_version_yml(new_version)
446
757
  sync_all_project_files(new_version)
758
+ update_readme_version(new_version)
447
759
 
448
760
 
449
761
  # ===================================================================
450
- # Project file -> version read-back (for sync comparison)
762
+ # Project file -> version read-back, and the per-type handler table
451
763
  # ===================================================================
452
764
 
765
+ def _read_spring(path_dir):
766
+ # build.gradle에 버전이 없으면(allprojects 설정만 있는 경우 등) build.gradle.kts → pom.xml 순으로 이어 본다.
767
+ for name in ("build.gradle", "build.gradle.kts"):
768
+ p = Path(path_dir) / name
769
+ if p.is_file():
770
+ for m in _gradle_version_matches(read_file(p)):
771
+ version = core_version(m.group(3))
772
+ if version:
773
+ return version
774
+ pom = Path(path_dir) / "pom.xml"
775
+ if pom.is_file():
776
+ return core_version(_pom_text(read_file(pom), ["version"]))
777
+ return None
778
+
779
+
780
+ def _read_flutter(path_dir):
781
+ p = Path(path_dir) / "pubspec.yaml"
782
+ if p.is_file():
783
+ text = p.read_text(encoding="utf-8")
784
+ m = re.search(r'^version:\s*([^\s#]+)', text, re.MULTILINE)
785
+ if m:
786
+ return core_version(m.group(1))
787
+ return None
788
+
789
+
790
+ def _read_package_json(path_dir):
791
+ p = Path(path_dir) / "package.json"
792
+ if p.is_file():
793
+ data = json.loads(p.read_text(encoding="utf-8"))
794
+ return core_version(data.get("version"))
795
+ return None
796
+
797
+
798
+ def _read_react_native(path_dir):
799
+ """릴리스 때 쓰는 파일(ios/<앱>/Info.plist, android/app/build.gradle)에서 읽는다.
800
+ package.json version은 동기화하지 않으므로 기준으로 삼으면 매 릴리스마다 어긋난다.
801
+ $(MARKETING_VERSION) 참조나 템플릿 기본값 "1.0"처럼 x.y.z가 아닌 값은 건너뛴다."""
802
+ ios_dir = Path(path_dir) / "ios"
803
+ if ios_dir.is_dir():
804
+ for plist in _rn_app_plists(ios_dir):
805
+ m = _PLIST_VERSION_RE.search(read_file(plist))
806
+ version = core_version(m.group(2)) if m else None
807
+ if version:
808
+ return version
809
+ gradle_file = Path(path_dir) / "android" / "app" / "build.gradle"
810
+ if gradle_file.is_file():
811
+ m = re.search(r'versionName\s+"([^"]+)"', read_file(gradle_file))
812
+ if m:
813
+ return core_version(m.group(1))
814
+ return None
815
+
816
+
817
+ def _read_expo(path_dir):
818
+ p = Path(path_dir) / "app.json"
819
+ if p.is_file():
820
+ data = json.loads(p.read_text(encoding="utf-8"))
821
+ return core_version((data.get("expo") or {}).get("version"))
822
+ return None
823
+
824
+
825
+ def _read_python(path_dir):
826
+ for target, (start, end) in _python_version_spans(path_dir):
827
+ version = core_version(read_file(target)[start:end])
828
+ if version:
829
+ return version
830
+ return None
831
+
832
+
833
+ def _read_none(path_dir):
834
+ return None
835
+
836
+
837
+ def _sync_none(path_dir, new_version, version_code_getter):
838
+ pass
839
+
840
+
841
+ class TypeHandler(NamedTuple):
842
+ """타입별 버전 파일 처리. read(path_dir)는 파일의 버전(없으면 None),
843
+ sync(path_dir, new_version, version_code_getter)는 새 버전을 파일에 쓴다."""
844
+ read: Callable
845
+ sync: Callable
846
+
847
+
848
+ _PACKAGE_JSON = TypeHandler(
849
+ read=_read_package_json,
850
+ sync=lambda d, v, _code: sync_json_version(Path(d) / "package.json", v, ["version"]),
851
+ )
852
+
853
+ # 새 타입은 여기 한 줄만 추가하면 읽기(sync 비교)와 쓰기가 함께 따라온다.
854
+ # 버전 파일이 없는 타입(basic·go)은 version.yml만 쓰도록 빈 핸들러를 둔다.
855
+ TYPE_HANDLERS = {
856
+ "spring": TypeHandler(read=_read_spring, sync=lambda d, v, _code: sync_spring(d, v)),
857
+ # build number가 필요한 타입만 version_code를 계산한다 (pubspec 조정 부수효과가 있다).
858
+ "flutter": TypeHandler(read=_read_flutter, sync=lambda d, v, code: sync_flutter(d, v, code())),
859
+ "react": _PACKAGE_JSON,
860
+ "next": _PACKAGE_JSON,
861
+ "node": _PACKAGE_JSON,
862
+ "python": TypeHandler(read=_read_python, sync=lambda d, v, _code: sync_python(d, v)),
863
+ "react-native": TypeHandler(read=_read_react_native, sync=lambda d, v, _code: sync_react_native(d, v)),
864
+ "react-native-expo": TypeHandler(
865
+ read=_read_expo,
866
+ sync=lambda d, v, _code: sync_json_version(Path(d) / "app.json", v, ["expo", "version"]),
867
+ ),
868
+ "basic": TypeHandler(read=_read_none, sync=_sync_none),
869
+ "go": TypeHandler(read=_read_none, sync=_sync_none),
870
+ }
871
+
872
+
453
873
  def get_project_file_version(project_type):
454
874
  path_dir = get_type_path(project_type)
875
+ handler = TYPE_HANDLERS.get(project_type)
455
876
  version = None
456
877
  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)
878
+ if handler is not None:
879
+ version = handler.read(path_dir)
508
880
  except Exception as e:
509
881
  log(f"WARNING: failed reading project file for {project_type}: {e}")
510
882
  version = None
@@ -560,14 +932,14 @@ def cmd_get(args):
560
932
 
561
933
  def cmd_get_code(args):
562
934
  require_version_yml()
563
- code = get_version_code()
935
+ code = get_reconciled_version_code()
564
936
  print(code)
565
937
  return 0
566
938
 
567
939
 
568
940
  def cmd_increment_code(args):
569
941
  require_version_yml()
570
- current = int(get_version_code())
942
+ current = get_reconciled_version_code()
571
943
  new_code = current + 1
572
944
  set_version_code(new_code)
573
945
  print(new_code)
@@ -582,11 +954,10 @@ def cmd_increment(args):
582
954
  return 1
583
955
  bump = getattr(args, "bump", None) or "patch"
584
956
  new_version = increment_version(current_version, bump)
957
+ # build number를 먼저 올려야 이어지는 pubspec 동기화에 새 값이 함께 기록된다.
958
+ set_version_code(get_reconciled_version_code() + 1)
585
959
  update_all_versions(new_version)
586
960
 
587
- current_code = int(get_version_code())
588
- set_version_code(current_code + 1)
589
-
590
961
  print(new_version)
591
962
  return 0
592
963
 
@@ -640,7 +1011,11 @@ def main(argv=None):
640
1011
  "sync": cmd_sync,
641
1012
  }
642
1013
  handler = handlers[args.command]
643
- return handler(args)
1014
+ try:
1015
+ return handler(args)
1016
+ except VersionSyncError as e:
1017
+ log(f"ERROR: {e}")
1018
+ return 1
644
1019
 
645
1020
 
646
1021
  if __name__ == "__main__":