@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,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Note: ~68-line helper exceeds the 50-line function cap. Pre-existing
|
|
3
|
+
# in the source-of-truth; refactor tracked separately to avoid bundling
|
|
4
|
+
# unrelated edits into round-13 trims.
|
|
5
|
+
"""
|
|
6
|
+
App Store Connect stale-editable appStoreVersion reuse.
|
|
7
|
+
|
|
8
|
+
Split out of asc_version_create.py to keep each module under the 400-line
|
|
9
|
+
cap. Owns the "PATCH then DELETE+POST fallback" flow that recovers from
|
|
10
|
+
ASC's "You cannot create a new version of the App in the current state."
|
|
11
|
+
409 (CI run 24640907316).
|
|
12
|
+
|
|
13
|
+
Why: ASC allows only ONE editable appStoreVersion at a time. When a
|
|
14
|
+
stale editable is sitting below our target marketing floor, a straight
|
|
15
|
+
POST of the new version returns 409 ENTITY_ERROR.RELATIONSHIP.INVALID
|
|
16
|
+
with pointer /data/relationships/app. Bumping the version cannot fix
|
|
17
|
+
this -- the editable itself must be renamed (PATCH) or removed (DELETE)
|
|
18
|
+
first.
|
|
19
|
+
|
|
20
|
+
Exit code:
|
|
21
|
+
5 -- PATCH failed AND DELETE failed (e.g. version locked by review);
|
|
22
|
+
manual intervention required in ASC.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json as _json
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
from asc_common import request
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Substrings in error.detail that indicate "a stale editable exists and
|
|
34
|
+
# blocks creating a new version". ASC returns this as 409 with
|
|
35
|
+
# code=ENTITY_ERROR.RELATIONSHIP.INVALID and pointer=/data/relationships/app
|
|
36
|
+
# (CI run 24640907316). Case-insensitive match.
|
|
37
|
+
_EDITABLE_EXISTS_DETAIL_HINTS = (
|
|
38
|
+
"create a new version",
|
|
39
|
+
"cannot create a new version",
|
|
40
|
+
"current state",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _log_body(body: dict, tag: str) -> None:
|
|
45
|
+
"""Pretty-print a request body to stderr before the call."""
|
|
46
|
+
try:
|
|
47
|
+
pretty = _json.dumps(body, indent=2, sort_keys=True)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
pretty = repr(body)
|
|
50
|
+
print(f"[{tag}] request body: {pretty}", file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def patch_version(app_id: str, existing_id: str, target_version: str, token: str):
|
|
54
|
+
"""PATCH /appStoreVersions/{id} to rename versionString. Preserves
|
|
55
|
+
metadata and review attachments. 4xx statuses allowed so the caller
|
|
56
|
+
can fall back to DELETE+POST.
|
|
57
|
+
|
|
58
|
+
ASC PATCH shape per JSON:API: data.type=appStoreVersions, data.id
|
|
59
|
+
must match the URL id, attributes carries the rename."""
|
|
60
|
+
body = {
|
|
61
|
+
"data": {
|
|
62
|
+
"type": "appStoreVersions",
|
|
63
|
+
"id": str(existing_id),
|
|
64
|
+
"attributes": {"versionString": target_version},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
_log_body(body, "patch-version")
|
|
68
|
+
return request(
|
|
69
|
+
"PATCH", f"/appStoreVersions/{existing_id}", token,
|
|
70
|
+
json_body=body, allow_status={400, 403, 404, 409, 422},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def delete_version(app_id: str, existing_id: str, token: str):
|
|
75
|
+
"""DELETE /appStoreVersions/{id}. 4xx statuses allowed so the caller
|
|
76
|
+
can surface a clear error when the record is locked by review."""
|
|
77
|
+
return request(
|
|
78
|
+
"DELETE", f"/appStoreVersions/{existing_id}", token,
|
|
79
|
+
allow_status={400, 403, 404, 409, 422},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_2xx(resp) -> bool:
|
|
84
|
+
return 200 <= getattr(resp, "status_code", 0) < 300
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _body_text(resp) -> str:
|
|
88
|
+
t = getattr(resp, "text", None)
|
|
89
|
+
return t if isinstance(t, str) else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def reuse_stale_editable(
|
|
93
|
+
*,
|
|
94
|
+
app_id: str,
|
|
95
|
+
existing_id: str,
|
|
96
|
+
target_version: str,
|
|
97
|
+
token: str,
|
|
98
|
+
patch_fn=None,
|
|
99
|
+
delete_fn=None,
|
|
100
|
+
post_fn=None,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Reuse a stale editable appStoreVersion that blocks creating a new
|
|
103
|
+
version. Tries PATCH (rename) first; on failure falls back to
|
|
104
|
+
DELETE + POST. SystemExit(5) when DELETE also fails.
|
|
105
|
+
|
|
106
|
+
All three ASC calls are injected so tests can drive the sequence
|
|
107
|
+
without hitting the network. ``post_fn`` must accept
|
|
108
|
+
``(app_id, version, token)`` -- same shape as ``create_version``.
|
|
109
|
+
"""
|
|
110
|
+
patch = patch_fn if patch_fn is not None else patch_version
|
|
111
|
+
delete = delete_fn if delete_fn is not None else delete_version
|
|
112
|
+
if post_fn is None:
|
|
113
|
+
# Lazy import to avoid a circular dep with asc_version_create.
|
|
114
|
+
from asc_version_create import create_version as _default_post
|
|
115
|
+
post = _default_post
|
|
116
|
+
else:
|
|
117
|
+
post = post_fn
|
|
118
|
+
|
|
119
|
+
print(
|
|
120
|
+
f"[reuse-stale] PATCH /appStoreVersions/{existing_id} "
|
|
121
|
+
f"versionString -> {target_version}",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
resp = patch(app_id, existing_id, target_version, token)
|
|
125
|
+
if _is_2xx(resp):
|
|
126
|
+
print("[reuse-stale] PATCH ok", file=sys.stderr)
|
|
127
|
+
return existing_id
|
|
128
|
+
|
|
129
|
+
print(
|
|
130
|
+
f"[reuse-stale] PATCH failed status={resp.status_code} "
|
|
131
|
+
f"body={_body_text(resp)}",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
print(
|
|
136
|
+
f"[reuse-stale] DELETE /appStoreVersions/{existing_id}",
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
del_resp = delete(app_id, existing_id, token)
|
|
140
|
+
if not _is_2xx(del_resp):
|
|
141
|
+
print(
|
|
142
|
+
f"::error::stale editable appStoreVersion id={existing_id} "
|
|
143
|
+
f"cannot be renamed or deleted -- manually intervene in ASC. "
|
|
144
|
+
f"DELETE status={del_resp.status_code} body={_body_text(del_resp)}",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
raise SystemExit(5)
|
|
148
|
+
|
|
149
|
+
print("[reuse-stale] DELETE ok, retrying POST", file=sys.stderr)
|
|
150
|
+
post_resp = post(app_id, target_version, token)
|
|
151
|
+
if not _is_2xx(post_resp):
|
|
152
|
+
print(
|
|
153
|
+
f"::error::stale editable {existing_id} was deleted but POST "
|
|
154
|
+
f"for {target_version} still failed "
|
|
155
|
+
f"status={post_resp.status_code} body={_body_text(post_resp)}",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
raise SystemExit(5)
|
|
159
|
+
return post_resp.json()["data"]["id"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_editable_exists_409(errors: list[dict]) -> bool:
|
|
163
|
+
"""True iff the 409 is attributable to a pre-existing editable
|
|
164
|
+
appStoreVersion (ASC allows only one editable at a time). Matches
|
|
165
|
+
the classifier in the CI run 24640907316 spec: code is
|
|
166
|
+
ENTITY_ERROR.RELATIONSHIP.INVALID AND detail contains "create a
|
|
167
|
+
new version" (or similar hint)."""
|
|
168
|
+
for e in errors:
|
|
169
|
+
if not isinstance(e, dict):
|
|
170
|
+
continue
|
|
171
|
+
code = (e.get("code") or "").strip()
|
|
172
|
+
detail = (e.get("detail") or "").strip().lower()
|
|
173
|
+
if code == "ENTITY_ERROR.RELATIONSHIP.INVALID" and any(
|
|
174
|
+
hint in detail for hint in _EDITABLE_EXISTS_DETAIL_HINTS
|
|
175
|
+
):
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
@@ -76,44 +76,39 @@ git config user.email >/dev/null 2>&1 || \
|
|
|
76
76
|
# importing them. Those caches are build artifacts of *this* run, not part
|
|
77
77
|
# of the action distribution -- sweeping them into the autoupdate bot
|
|
78
78
|
# commit is benign but noisy (every clean run emits a "refresh
|
|
79
|
-
# __pycache__" diff).
|
|
80
|
-
# (untracked) paths under the directory; we filter out anything matching
|
|
81
|
-
# `/__pycache__/` or ending in `.pyc`, then feed the rest to `git add -f`.
|
|
79
|
+
# __pycache__" diff).
|
|
82
80
|
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
81
|
+
# DESIGN DECISION: use `git add -A` (stages additions, modifications, AND
|
|
82
|
+
# deletions) instead of the previous `git ls-files -mo` flow. `ls-files
|
|
83
|
+
# -mo` enumerates modified + other (untracked) paths but NOT deletions:
|
|
84
|
+
# when an autoupdate cycle removes a script (e.g. `prepare_signing.py`
|
|
85
|
+
# gets renamed to `prepare_signing_v2.py` in a new release), the old
|
|
86
|
+
# file would stay tracked in the consumer repo forever -- the bot
|
|
87
|
+
# commit would add the new file but never remove the orphan. `git add
|
|
88
|
+
# -A` handles all three change kinds in one call. We then unstage any
|
|
89
|
+
# pycache paths that the broad `-A` swept in.
|
|
91
90
|
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# treats as fatal even though it's the no-op case we want. Capture to a
|
|
98
|
-
# variable first and skip the xargs call when empty -- portable across
|
|
99
|
-
# both BSD and GNU xargs.
|
|
91
|
+
# Force flag (`-f`) is implicit in `git add -A` for explicitly listed
|
|
92
|
+
# pathspecs that match .gitignore patterns? No -- `-A` still honors
|
|
93
|
+
# .gitignore for untracked files. To preserve the prior semantics
|
|
94
|
+
# (consumer's .gitignore must not silently drop autoupdate-managed
|
|
95
|
+
# files like `.daemux-version`), we pass `--force` explicitly.
|
|
100
96
|
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
printf '%s\n' "$files" \
|
|
97
|
+
# BSD xargs (default on macos-15 runners) does NOT support
|
|
98
|
+
# `-r`/`--no-run-if-empty`, so we capture pycache paths into a variable
|
|
99
|
+
# and skip the xargs call when empty -- portable across BSD and GNU.
|
|
100
|
+
git add -A --force -- .github/actions/swift-app/ 2>/dev/null || true
|
|
101
|
+
|
|
102
|
+
# Unstage Python bytecode that the broad `-A` swept in. `git diff
|
|
103
|
+
# --cached --name-only` lists every staged path; we filter to pycache
|
|
104
|
+
# entries and `git reset HEAD --` to drop them from the index. Working
|
|
105
|
+
# tree is left untouched (the .pyc files remain on disk; only their
|
|
106
|
+
# index entries are removed).
|
|
107
|
+
pycache="$(git diff --cached --name-only -- .github/actions/swift-app/ \
|
|
108
|
+
| grep -E '(/__pycache__/|\.pyc$)' || true)"
|
|
109
|
+
if [[ -n "$pycache" ]]; then
|
|
110
|
+
printf '%s\n' "$pycache" \
|
|
116
111
|
| tr '\n' '\0' \
|
|
117
|
-
| xargs -0 git
|
|
112
|
+
| xargs -0 git reset HEAD -- 2>/dev/null || true
|
|
118
113
|
fi
|
|
119
114
|
echo "autoupdate: staged refreshed action files (deploy.yml not staged — see comment; __pycache__ excluded)"
|
|
@@ -12,20 +12,42 @@ fi
|
|
|
12
12
|
staged="$(git diff --cached --name-only)"
|
|
13
13
|
has_cert=0
|
|
14
14
|
has_auto=0
|
|
15
|
+
has_bump=0
|
|
15
16
|
# autoupdate_check.sh deliberately does NOT stage .github/workflows/deploy.yml
|
|
16
17
|
# (GITHUB_TOKEN cannot push workflow-file changes), so we only need to detect
|
|
17
18
|
# action/ paths to classify the commit as an autoupdate.
|
|
18
19
|
if grep -qE '^creds/' <<<"$staged"; then has_cert=1; fi
|
|
19
20
|
if grep -qE '^\.github/actions/swift-app/' <<<"$staged"; then has_auto=1; fi
|
|
21
|
+
# marketing-version-auto-bump (mmv_floor_check) writes the bumped value
|
|
22
|
+
# into the project's source-of-truth (resolved by version_utils):
|
|
23
|
+
# - xcodegen project.yml / project.yaml / Project.yml / Project.yaml
|
|
24
|
+
# - *.xcconfig referenced by the project
|
|
25
|
+
# - project.pbxproj (only when no xcodegen spec is present)
|
|
26
|
+
# - Info.plist's CFBundleShortVersionString
|
|
27
|
+
# Any of those staged paths means a version bump rode along in this
|
|
28
|
+
# commit; surface it in the subject so reviewers see the version
|
|
29
|
+
# delta without diffing.
|
|
30
|
+
if grep -qE '(\.pbxproj|Info\.plist|(^|/)[Pp]roject\.ya?ml|\.xcconfig)$' \
|
|
31
|
+
<<<"$staged"; then
|
|
32
|
+
has_bump=1
|
|
33
|
+
fi
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
# Build a subject that lists every concern that actually fired. Avoids a
|
|
36
|
+
# 2^N case explosion by composing components in a stable order.
|
|
37
|
+
parts=()
|
|
38
|
+
if [[ $has_cert -eq 1 ]]; then parts+=("refresh signing identity"); fi
|
|
39
|
+
if [[ $has_auto -eq 1 ]]; then parts+=("autoupdate swift-app-ci"); fi
|
|
40
|
+
if [[ $has_bump -eq 1 ]]; then parts+=("bump MARKETING_VERSION"); fi
|
|
41
|
+
if [[ ${#parts[@]} -eq 0 ]]; then
|
|
28
42
|
subject="ci: bot maintenance [skip ci]"
|
|
43
|
+
else
|
|
44
|
+
joined=""
|
|
45
|
+
for ((i = 0; i < ${#parts[@]}; i++)); do
|
|
46
|
+
if [[ $i -eq 0 ]]; then joined="${parts[i]}"
|
|
47
|
+
else joined="$joined + ${parts[i]}"
|
|
48
|
+
fi
|
|
49
|
+
done
|
|
50
|
+
subject="ci: $joined [skip ci]"
|
|
29
51
|
fi
|
|
30
52
|
|
|
31
53
|
git config user.name >/dev/null 2>&1 || git config user.name "github-actions[bot]"
|