@daemux/store-automator 0.10.95 → 0.10.97

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.
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Marketing-version arithmetic + project-file write helpers.
4
+
5
+ Extracted from ``mmv_floor_check`` so the floor-check module stays under
6
+ the project's 10-functions-per-file cap. Two concerns live here:
7
+
8
+ 1. ``compute_next_version(current, floor, policy)`` -- pure semver
9
+ arithmetic. Given the project's current MARKETING_VERSION and the
10
+ ASC combined floor, returns the next value that satisfies BOTH:
11
+
12
+ result > floor (so ASC accepts the new row)
13
+ result > current (so the bump is observable)
14
+
15
+ Policy is ``'rollover'`` (default), ``'patch'``, or ``'minor'``.
16
+ Rollover bumps the patch component with carry: at .9 it rolls
17
+ into the next minor (1.0.9 -> 1.1.0), and at minor=9, patch=9 it
18
+ cascades into the next major (1.9.9 -> 2.0.0). Major has no upper
19
+ limit (9.9.9 -> 10.0.0). Patch (legacy) bumps the patch component
20
+ unbounded (1.0.9 -> 1.0.10) -- preserved for backward compat with
21
+ consumers pinning the historical default. Minor bumps the minor
22
+ component and resets patch to 0.
23
+
24
+ 2. ``write_marketing_version(new_version)`` -- I/O. Writes
25
+ ``new_version`` into the project's source-of-truth. Resolution order:
26
+
27
+ a. xcodegen project.yml / project.yaml / Project.yml /
28
+ Project.yaml at the workspace root (the source of truth for
29
+ xcodegen consumers; the generated .xcodeproj is regenerated
30
+ on every build and editing it is futile).
31
+ b. *.xcconfig files referenced from the pbxproj's
32
+ baseConfigurationReference (some projects keep
33
+ MARKETING_VERSION in xcconfig regardless of xcodegen).
34
+ c. project.pbxproj (native xcodeproj workflow without xcodegen).
35
+ d. Info.plist's CFBundleShortVersionString (older / hand-rolled
36
+ projects where the plist holds the version literal).
37
+
38
+ When xcodegen is detected (a) but the YAML/xcconfig substitution
39
+ finds nothing, the function returns False with a ``::warning::`` --
40
+ it deliberately does NOT fall through to writing pbxproj because
41
+ xcodegen would wipe the change on the next generate.
42
+
43
+ On every successful write, drops a marker file at
44
+ ``$RUNNER_TEMP/swift-app-mv-bumped`` listing the touched path(s).
45
+ The action.yml staging step reads that marker to stage exactly the
46
+ files mmv touched -- never the broader pbxproj/Info.plist diff
47
+ produced by prepare_signing's manual-signing patches.
48
+
49
+ DESIGN DECISION: this module owns BOTH concerns (arithmetic and write)
50
+ because they are tightly cohesive -- the only caller is the auto-bump
51
+ branch in ``mmv_floor_check``, which always pairs them. Splitting them
52
+ across two modules would force the caller to wire two imports for one
53
+ logical operation. Tests still get pure-function isolation via
54
+ ``compute_next_version``.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import os
60
+ import plistlib
61
+ import re
62
+ import subprocess
63
+ import sys
64
+ from pathlib import Path
65
+
66
+ # Reuse the same semver shape as mmv_floor_check.SEM_RE so projects with
67
+ # 2-component MARKETING_VERSION (e.g. "1.0") parse identically.
68
+ _SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
69
+ _MARKETING_LINE = re.compile(
70
+ r"^(\s*)MARKETING_VERSION\s*=\s*[^;]+;\s*$", re.MULTILINE,
71
+ )
72
+ # xcodegen project.yml line shape (YAML key colon, optional quotes).
73
+ # Matches "MARKETING_VERSION: 1.0" / "MARKETING_VERSION: '1.0'" /
74
+ # "MARKETING_VERSION: \"1.0\"". Indent preserved via group 1.
75
+ _YAML_MARKETING_LINE = re.compile(
76
+ r"^(\s*)MARKETING_VERSION\s*:\s*['\"]?[^'\"\s#]+['\"]?\s*$",
77
+ re.MULTILINE,
78
+ )
79
+ # xcconfig MARKETING_VERSION line: "MARKETING_VERSION = 1.0" with
80
+ # optional [sdk=*] / [config=Release] qualifiers.
81
+ _XCCONFIG_MARKETING_LINE = re.compile(
82
+ r"^(\s*MARKETING_VERSION(?:\[[^\]]+\])?\s*=\s*)\S+\s*$",
83
+ re.MULTILINE,
84
+ )
85
+ _XCODEGEN_SPEC_NAMES = (
86
+ "project.yml", "project.yaml", "Project.yml", "Project.yaml",
87
+ )
88
+ # Marker file the action.yml staging step reads to know which project
89
+ # files to stage. Empty / missing marker means no auto-bump fired and
90
+ # nothing project-side should be staged. Located under RUNNER_TEMP so
91
+ # it does not pollute the repo and is wiped between runs.
92
+ _BUMP_MARKER_NAME = "swift-app-mv-bumped"
93
+
94
+
95
+ def _parse(version: str) -> tuple[int, int, int]:
96
+ """Parse 'M.m[.p]' -> (M, m, p). Raises ValueError on non-semver."""
97
+ m = _SEM_RE.match(version or "")
98
+ if not m:
99
+ raise ValueError(f"not a semver: {version!r}")
100
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))
101
+
102
+
103
+ def compute_next_version(current: str, floor: str, policy: str) -> str:
104
+ """Compute the next MARKETING_VERSION that satisfies both:
105
+ result > floor AND result > current.
106
+
107
+ ``policy`` is ``'rollover'`` (default), ``'patch'``, or ``'minor'``.
108
+ The ``'none'`` case is handled by the caller (it preserves the
109
+ existing fail-the-build semantics) and never reaches this function.
110
+
111
+ Cross-train semantics (shared by 'patch' and 'rollover'): stay on
112
+ the higher major.minor train of (current, floor) so we never
113
+ silently advance an unrelated minor or major; when trains match,
114
+ base patch is max(current.patch, floor.patch). 'rollover' adds
115
+ carry: patch>9 rolls to patch=0, minor+=1; resulting minor>9
116
+ rolls to minor=0, major+=1; major has no upper limit (9.9.9 ->
117
+ 10.0.0 just keeps growing). The ``>9`` (not ``==10``) check
118
+ handles inputs already past the rollover boundary -- e.g. a
119
+ project previously on the 'patch' policy that reached 1.0.10
120
+ before switching to 'rollover' must still cascade
121
+ (1.0.10 -> 1.1.0) rather than emit a malformed 1.0.11. 'patch'
122
+ (legacy) is unbounded (1.0.9 -> 1.0.10), preserved verbatim for
123
+ backward compat with consumers that pinned the historical default.
124
+ """
125
+ if policy not in ("rollover", "patch", "minor"):
126
+ raise ValueError(f"unknown policy: {policy!r}")
127
+ cur, fl = _parse(current), _parse(floor)
128
+ if policy == "minor":
129
+ major = max(cur[0], fl[0])
130
+ return f"{major}.{max(cur[1], fl[1]) + 1}.0"
131
+ # patch / rollover share train resolution.
132
+ if cur[:2] >= fl[:2]:
133
+ major, minor, patch_base = cur[0], cur[1], cur[2]
134
+ if cur[:2] == fl[:2]:
135
+ patch_base = max(cur[2], fl[2])
136
+ else:
137
+ major, minor, patch_base = fl
138
+ if policy == "patch":
139
+ return f"{major}.{minor}.{patch_base + 1}"
140
+ # rollover: patch+1 with carry into minor, then into major.
141
+ # ``>9`` (not ``==10``) so inputs already over the boundary
142
+ # (e.g. a 1.0.10 left over from the legacy 'patch' policy)
143
+ # still cascade cleanly into the next minor / major train.
144
+ new_patch = patch_base + 1
145
+ if new_patch > 9:
146
+ new_patch, minor = 0, minor + 1
147
+ if minor > 9:
148
+ minor, major = 0, major + 1
149
+ return f"{major}.{minor}.{new_patch}"
150
+
151
+
152
+ def _xcodebuild_args() -> tuple[list[str], Path]:
153
+ """Mirror resolve_marketing_version._xcodebuild_args() so we resolve
154
+ Info.plist relative to the same project base."""
155
+ workspace = os.environ.get("WORKSPACE", "").strip()
156
+ project = os.environ.get("PROJECT", "").strip()
157
+ if workspace:
158
+ return ["-workspace", workspace], Path(workspace).parent
159
+ return ["-project", project], Path(project).parent
160
+
161
+
162
+ def _resolve_pbxproj_path() -> Path | None:
163
+ """Return the project.pbxproj path, or None when only a workspace is
164
+ set (in that case the workspace points at one or more xcodeproj files
165
+ we cannot disambiguate without parsing it -- caller falls back to
166
+ Info.plist)."""
167
+ project = os.environ.get("PROJECT", "").strip()
168
+ if not project:
169
+ return None
170
+ pbx = Path(project) / "project.pbxproj"
171
+ return pbx if pbx.is_file() else None
172
+
173
+
174
+ def _record_bumped_path(path: Path) -> None:
175
+ """Capture the bumped file in git's index AND drop a marker line for
176
+ the action.yml staging step.
177
+
178
+ Why immediate ``git add``: prepare_signing.py mutates pbxproj later
179
+ in the pipeline (manual-signing settings, profile specifiers).
180
+ Staging the *current* file content NOW snapshots the post-bump,
181
+ pre-prepare_signing state in the index. The signing edits hit the
182
+ working tree only; ``git diff --cached`` keeps showing exactly the
183
+ version bump until something explicitly re-stages.
184
+
185
+ The marker file is supplementary: it lets the staging step verify
186
+ auto-bump fired and surface the touched paths in CI logs even when
187
+ the immediate ``git add`` is a no-op (e.g. local dev runs where
188
+ RUNNER_TEMP is set but the path is gitignored)."""
189
+ try:
190
+ subprocess.run(
191
+ ["git", "add", "-f", "--", str(path)],
192
+ check=False, capture_output=True, timeout=30,
193
+ )
194
+ except (OSError, subprocess.SubprocessError) as exc:
195
+ print(
196
+ f"[auto-bump] git add failed for {path}: {exc!r}",
197
+ file=sys.stderr,
198
+ )
199
+ runner_temp = os.environ.get("RUNNER_TEMP", "").strip()
200
+ if not runner_temp:
201
+ return
202
+ marker = Path(runner_temp) / _BUMP_MARKER_NAME
203
+ try:
204
+ with open(marker, "a", encoding="utf-8") as fh:
205
+ fh.write(f"{path}\n")
206
+ except OSError as exc:
207
+ print(
208
+ f"[auto-bump] could not append to {marker}: {exc!r}",
209
+ file=sys.stderr,
210
+ )
211
+
212
+
213
+ def _write_yaml_marketing_version(path: Path, new_version: str) -> bool:
214
+ """Regex-rewrite the FIRST ``MARKETING_VERSION:`` key in a YAML
215
+ spec. Preserves indentation and surrounding formatting. Returns True
216
+ on substitution, False when the key is not present in this file."""
217
+ text = path.read_text(encoding="utf-8")
218
+ new_text, count = _YAML_MARKETING_LINE.subn(
219
+ rf"\g<1>MARKETING_VERSION: {new_version}", text,
220
+ )
221
+ if count == 0:
222
+ return False
223
+ path.write_text(new_text, encoding="utf-8")
224
+ print(
225
+ f"[auto-bump] wrote MARKETING_VERSION={new_version} to "
226
+ f"{path} ({count} occurrence(s))",
227
+ file=sys.stderr,
228
+ )
229
+ _record_bumped_path(path)
230
+ return True
231
+
232
+
233
+ def _write_xcconfig_marketing_version(new_version: str) -> bool:
234
+ """Rewrite ``MARKETING_VERSION = X`` in the first xcconfig file that
235
+ declares the key. Walks the project's parent directory
236
+ non-recursively for top-level configs and one level down (covers
237
+ common ``Configs/`` / ``BuildConfig/`` layouts). Returns True on
238
+ the first successful write, False when no xcconfig declares the
239
+ key."""
240
+ _proj_args, base = _xcodebuild_args()
241
+ if not base.is_dir():
242
+ return False
243
+ candidates: list[Path] = list(sorted(base.glob("*.xcconfig")))
244
+ for sub in sorted(p for p in base.iterdir() if p.is_dir()):
245
+ candidates.extend(sorted(sub.glob("*.xcconfig")))
246
+ for path in candidates:
247
+ try:
248
+ text = path.read_text(encoding="utf-8")
249
+ except OSError:
250
+ continue
251
+ new_text, count = _XCCONFIG_MARKETING_LINE.subn(
252
+ rf"\g<1>{new_version}", text,
253
+ )
254
+ if count == 0:
255
+ continue
256
+ path.write_text(new_text, encoding="utf-8")
257
+ print(
258
+ f"[auto-bump] wrote MARKETING_VERSION={new_version} to "
259
+ f"{path} ({count} occurrence(s))",
260
+ file=sys.stderr,
261
+ )
262
+ _record_bumped_path(path)
263
+ return True
264
+ return False
265
+
266
+
267
+ def _write_pbxproj_marketing_version(pbx: Path, new_version: str) -> bool:
268
+ """Rewrite every ``MARKETING_VERSION = ...;`` line in pbxproj to
269
+ ``MARKETING_VERSION = <new_version>;``. Returns True when at least
270
+ one substitution happened, False otherwise.
271
+
272
+ SAFETY: caller MUST have ruled out xcodegen first. Editing a
273
+ generated pbxproj is futile when ``project.yml`` is the source of
274
+ truth -- the next ``xcodegen generate`` would wipe the bump."""
275
+ text = pbx.read_text(encoding="utf-8")
276
+ new_text, count = _MARKETING_LINE.subn(
277
+ rf"\g<1>MARKETING_VERSION = {new_version};", text,
278
+ )
279
+ if count == 0:
280
+ return False
281
+ pbx.write_text(new_text, encoding="utf-8")
282
+ print(
283
+ f"[auto-bump] wrote MARKETING_VERSION={new_version} to "
284
+ f"{pbx} ({count} build configuration(s))",
285
+ file=sys.stderr,
286
+ )
287
+ _record_bumped_path(pbx)
288
+ return True
289
+
290
+
291
+ _INFOPLIST_FILE_RE = re.compile(
292
+ r"^\s*INFOPLIST_FILE\s*=\s*(.+?)\s*$", re.MULTILINE,
293
+ )
294
+
295
+
296
+ def _write_infoplist_short_version(new_version: str) -> bool:
297
+ """Fallback: stamp CFBundleShortVersionString into the project's
298
+ resolved Info.plist. Returns True on success, False when no plist
299
+ resolves."""
300
+ proj_args, base = _xcodebuild_args()
301
+ scheme = os.environ.get("SCHEME", "").strip()
302
+ config = os.environ.get("CONFIGURATION", "").strip() or "Release"
303
+ cmd = ["xcodebuild", *proj_args, "-scheme", scheme,
304
+ "-configuration", config, "-showBuildSettings"]
305
+ try:
306
+ out = subprocess.run(
307
+ cmd, check=False, capture_output=True, text=True, timeout=120,
308
+ )
309
+ except (OSError, subprocess.SubprocessError):
310
+ return False
311
+ m = _INFOPLIST_FILE_RE.search(out.stdout or "")
312
+ if not m:
313
+ return False
314
+ rel = m.group(1).strip()
315
+ for path in (base / rel, Path(rel)):
316
+ if not path.is_file():
317
+ continue
318
+ try:
319
+ with open(path, "rb") as fh:
320
+ data = plistlib.load(fh)
321
+ except (OSError, plistlib.InvalidFileException, ValueError):
322
+ continue
323
+ if not isinstance(data, dict):
324
+ continue
325
+ data["CFBundleShortVersionString"] = new_version
326
+ with open(path, "wb") as fh:
327
+ plistlib.dump(data, fh)
328
+ print(
329
+ f"[auto-bump] wrote CFBundleShortVersionString={new_version} "
330
+ f"to {path}", file=sys.stderr,
331
+ )
332
+ _record_bumped_path(path)
333
+ return True
334
+ return False
335
+
336
+
337
+ _XCODEGEN_REFUSE_MSG = (
338
+ "::warning::auto-bump: xcodegen project.yml detected but "
339
+ "MARKETING_VERSION not found in YAML or xcconfig. Refusing to "
340
+ "edit the generated pbxproj (would be wiped by the next "
341
+ "`xcodegen generate`). Add `settings: { base: { "
342
+ "MARKETING_VERSION: <value> } }` to project.yml or pin "
343
+ "`marketing-version-auto-bump: 'none'`."
344
+ )
345
+
346
+
347
+ def write_marketing_version(new_version: str) -> bool:
348
+ """Persist ``new_version`` into the project source-of-truth.
349
+
350
+ Resolution order is xcodegen-aware: project.yml -> xcconfig ->
351
+ project.pbxproj (only without a spec) -> Info.plist. Records every
352
+ successful write to ``$RUNNER_TEMP/<marker>`` so the action.yml
353
+ staging step picks up exactly what was touched (never
354
+ prepare_signing's pbxproj diff). Returns True on the first
355
+ successful write, False when no source of truth resolved."""
356
+ _proj_args, base = _xcodebuild_args()
357
+ spec_bases = {Path(".").resolve()}
358
+ if base.exists():
359
+ spec_bases.add(base.resolve())
360
+ specs = [d / name for d in spec_bases for name in _XCODEGEN_SPEC_NAMES
361
+ if (d / name).is_file()]
362
+ for spec in specs:
363
+ if _write_yaml_marketing_version(spec, new_version):
364
+ return True
365
+ if _write_xcconfig_marketing_version(new_version):
366
+ return True
367
+ if specs:
368
+ print(_XCODEGEN_REFUSE_MSG, file=sys.stderr)
369
+ return False
370
+ pbx = _resolve_pbxproj_path()
371
+ if pbx is not None and _write_pbxproj_marketing_version(pbx, new_version):
372
+ return True
373
+ return _write_infoplist_short_version(new_version)