@daemux/store-automator 0.10.95 → 0.10.96

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