@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/github/IOS_NATIVE_CI_SETUP.md +58 -3
- package/templates/scripts/ci/ios-native/asc_build_history.py +284 -0
- package/templates/scripts/ci/ios-native/asc_common.py +137 -34
- package/templates/scripts/ci/ios-native/asc_version_create.py +365 -0
- package/templates/scripts/ci/ios-native/asc_version_fetch.py +274 -0
- package/templates/scripts/ci/ios-native/asc_version_reuse.py +177 -0
- package/templates/scripts/ci/ios-native/autoupdate_check.sh +30 -35
- package/templates/scripts/ci/ios-native/commit_bot_changes.sh +29 -7
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +269 -203
- package/templates/scripts/ci/ios-native/mmv_decide_create.py +181 -0
- package/templates/scripts/ci/ios-native/mmv_floor_check.py +260 -0
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +22 -44
- package/templates/scripts/ci/ios-native/version_utils.py +352 -0
|
@@ -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)
|