@daemux/store-automator 0.10.93 → 0.10.95
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/src/install.mjs +32 -10
- package/templates/github/IOS_NATIVE_CI_SETUP.md +188 -0
- package/templates/github/workflows/deploy.yml +23 -1
- package/templates/scripts/ci/ios-native/autoupdate_check.sh +119 -0
- package/templates/scripts/ci/ios-native/cert_factory.py +190 -0
- package/templates/scripts/ci/ios-native/commit_bot_changes.sh +60 -0
- package/templates/scripts/ci/ios-native/creds_store.py +328 -0
- package/templates/scripts/ci/ios-native/keychain.py +103 -0
- package/templates/scripts/ci/ios-native/prepare_signing.py +155 -271
- package/templates/scripts/ci/ios-native/profile_io.py +64 -0
- package/templates/scripts/ci/ios-native/profile_manager.py +147 -25
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +84 -27
|
@@ -11,6 +11,27 @@ Talks to the App Store Connect API to:
|
|
|
11
11
|
* Install the resulting ``.mobileprovision`` into every Xcode profile
|
|
12
12
|
directory (Xcode 15 and Xcode 16+ use different paths).
|
|
13
13
|
|
|
14
|
+
Caching
|
|
15
|
+
-------
|
|
16
|
+
|
|
17
|
+
``provision_all_bundles`` is now cache-aware. Callers pass the workspace
|
|
18
|
+
``creds_dir`` and a ``cache_hit`` flag indicating whether the cert was
|
|
19
|
+
itself reused from cache. The function:
|
|
20
|
+
|
|
21
|
+
* If the manifest's cached cert_id matches the current cert_id AND the
|
|
22
|
+
cert was reused, it tries to reuse each bundle's profile from disk
|
|
23
|
+
(``find_reusable_profile``) before falling back to a fresh
|
|
24
|
+
``create_profile`` round-trip. Profile create on Apple's side is
|
|
25
|
+
rate-limited and disturbs the team's profile-list view, so caching
|
|
26
|
+
is worth it.
|
|
27
|
+
* If the cert changed (cap rotation, expiry, manual revoke) it forces
|
|
28
|
+
a full regen — Apple invalidates a profile the moment its leaf cert
|
|
29
|
+
goes away, so reusing the on-disk file would archive an unsigned
|
|
30
|
+
binary.
|
|
31
|
+
* After the loop it GCs orphan ``.mobileprovision`` files whose bundle
|
|
32
|
+
is no longer in ``bundle_ids`` (e.g. an extension was deleted from
|
|
33
|
+
the target list) and rewrites the manifest with the current set.
|
|
34
|
+
|
|
14
35
|
Xcode pbxproj editing lives in ``pbxproj_editor.py``; this file is
|
|
15
36
|
ASC-facing only.
|
|
16
37
|
"""
|
|
@@ -18,12 +39,14 @@ ASC-facing only.
|
|
|
18
39
|
from __future__ import annotations
|
|
19
40
|
|
|
20
41
|
import base64
|
|
21
|
-
import plistlib
|
|
22
42
|
import subprocess
|
|
43
|
+
from datetime import datetime
|
|
23
44
|
from pathlib import Path
|
|
24
45
|
|
|
46
|
+
import creds_store
|
|
25
47
|
from asc_common import get_json, request
|
|
26
48
|
from pbxproj_editor import PROFILE_PREFIX
|
|
49
|
+
from profile_io import decode_profile_plist, ensure_utc
|
|
27
50
|
|
|
28
51
|
|
|
29
52
|
# Xcode 16+ moved the canonical profile directory. Older Xcode releases
|
|
@@ -121,65 +144,164 @@ def create_profile(
|
|
|
121
144
|
return base64.b64decode(payload)
|
|
122
145
|
|
|
123
146
|
|
|
124
|
-
def install_profile(profile_der: bytes) -> tuple[str, str]:
|
|
147
|
+
def install_profile(profile_der: bytes) -> tuple[str, str, datetime]:
|
|
125
148
|
"""Install the raw profile into every Xcode-visible directory.
|
|
126
149
|
|
|
127
|
-
Returns ``(uuid, team_id)``. ``team_id`` is the team
|
|
128
|
-
the profile — the effective team that Xcode
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
Returns ``(uuid, team_id, expiration)``. ``team_id`` is the team
|
|
151
|
+
Apple assigned to the profile — the effective team that Xcode
|
|
152
|
+
expects to find in ``DEVELOPMENT_TEAM``. ``expiration`` is the
|
|
153
|
+
profile's ``ExpirationDate`` normalised to UTC; the caller persists
|
|
154
|
+
it in the cache manifest.
|
|
132
155
|
"""
|
|
133
|
-
|
|
134
|
-
primary.mkdir(parents=True, exist_ok=True)
|
|
135
|
-
tmp_path = primary / "tmp.mobileprovision"
|
|
136
|
-
tmp_path.write_bytes(profile_der)
|
|
137
|
-
decoded = subprocess.check_output(
|
|
138
|
-
["security", "cms", "-D", "-i", str(tmp_path)]
|
|
139
|
-
)
|
|
140
|
-
plist = plistlib.loads(decoded)
|
|
156
|
+
plist = decode_profile_plist(profile_der, _PROFILE_DIRS[0])
|
|
141
157
|
uuid = plist["UUID"]
|
|
142
158
|
team_ids = plist.get("TeamIdentifier") or []
|
|
143
159
|
team_id = team_ids[0] if team_ids else ""
|
|
144
160
|
profile_name = plist.get("Name") or "<unknown>"
|
|
161
|
+
expiration = ensure_utc(plist.get("ExpirationDate"))
|
|
145
162
|
final_name = f"{uuid}.mobileprovision"
|
|
146
163
|
for directory in _PROFILE_DIRS:
|
|
147
164
|
directory.mkdir(parents=True, exist_ok=True)
|
|
148
165
|
(directory / final_name).write_bytes(profile_der)
|
|
149
|
-
tmp_path.unlink(missing_ok=True)
|
|
150
166
|
print(
|
|
151
167
|
f" profile {profile_name!r}: uuid={uuid} team={team_id} "
|
|
152
|
-
f"dirs={[str(d) for d in _PROFILE_DIRS]}"
|
|
168
|
+
f"exp={expiration.isoformat()} dirs={[str(d) for d in _PROFILE_DIRS]}"
|
|
153
169
|
)
|
|
154
|
-
return uuid, team_id
|
|
170
|
+
return uuid, team_id, expiration
|
|
155
171
|
|
|
156
172
|
|
|
157
173
|
# --------------------------------------------------------------------------- #
|
|
158
174
|
# Orchestration #
|
|
159
175
|
# --------------------------------------------------------------------------- #
|
|
160
176
|
|
|
177
|
+
def _try_reuse_cached(
|
|
178
|
+
bid: str,
|
|
179
|
+
name: str,
|
|
180
|
+
cert_id: str,
|
|
181
|
+
creds_dir: Path,
|
|
182
|
+
manifest: dict,
|
|
183
|
+
) -> tuple[str, str, dict] | None:
|
|
184
|
+
"""Return ``(uuid, team, entry)`` if a cached profile reinstalls cleanly.
|
|
185
|
+
|
|
186
|
+
A corrupt .mobileprovision (truncated, manually edited, ``security
|
|
187
|
+
cms`` decode failure) must NEVER abort the build — log and return
|
|
188
|
+
``None`` so the caller falls through to the fresh-create path.
|
|
189
|
+
"""
|
|
190
|
+
cached = creds_store.find_reusable_profile(manifest, bid, cert_id, creds_dir)
|
|
191
|
+
if cached is None:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
der = creds_store.profile_path(creds_dir, cached.uuid).read_bytes()
|
|
195
|
+
uuid, team_id, _ = install_profile(der)
|
|
196
|
+
except (OSError, subprocess.CalledProcessError, ValueError, KeyError) as exc:
|
|
197
|
+
print(
|
|
198
|
+
f"::warning::cached profile {cached.filename!r} unusable "
|
|
199
|
+
f"({exc!r}); regenerating"
|
|
200
|
+
)
|
|
201
|
+
return None
|
|
202
|
+
# cached.expiration is already UTC-aware (load_profile_manifest routes
|
|
203
|
+
# it through _parse_iso); no astimezone needed.
|
|
204
|
+
entry = {
|
|
205
|
+
"bundle_id": bid,
|
|
206
|
+
"name": cached.name or name,
|
|
207
|
+
"uuid": uuid,
|
|
208
|
+
"filename": f"{uuid}.mobileprovision",
|
|
209
|
+
"expiration": cached.expiration.isoformat(),
|
|
210
|
+
}
|
|
211
|
+
print(f"Reused cached profile {name} -> {uuid} ({bid})")
|
|
212
|
+
return uuid, team_id, entry
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _provision_bundle(
|
|
216
|
+
token: str,
|
|
217
|
+
bid: str,
|
|
218
|
+
cert_id: str,
|
|
219
|
+
creds_dir: Path,
|
|
220
|
+
manifest: dict,
|
|
221
|
+
can_reuse: bool,
|
|
222
|
+
) -> tuple[str, str, str, dict]:
|
|
223
|
+
"""Provision one bundle; return ``(name, uuid, team, manifest_entry)``."""
|
|
224
|
+
name = profile_name_for(bid)
|
|
225
|
+
if can_reuse:
|
|
226
|
+
reused = _try_reuse_cached(bid, name, cert_id, creds_dir, manifest)
|
|
227
|
+
if reused is not None:
|
|
228
|
+
uuid, team_id, entry = reused
|
|
229
|
+
return name, uuid, team_id, entry
|
|
230
|
+
|
|
231
|
+
bundle_pk = ensure_bundle_id(token, bid)
|
|
232
|
+
delete_profile_by_name(token, name)
|
|
233
|
+
profile_der = create_profile(token, name, bundle_pk, cert_id)
|
|
234
|
+
uuid, team_id, expiration = install_profile(profile_der)
|
|
235
|
+
creds_store.write_cached_profile(creds_dir, uuid, profile_der)
|
|
236
|
+
entry = {
|
|
237
|
+
"bundle_id": bid,
|
|
238
|
+
"name": name,
|
|
239
|
+
"uuid": uuid,
|
|
240
|
+
"filename": f"{uuid}.mobileprovision",
|
|
241
|
+
"expiration": expiration.isoformat(),
|
|
242
|
+
}
|
|
243
|
+
print(f"Installed fresh profile {name} -> {uuid} ({bid})")
|
|
244
|
+
return name, uuid, team_id, entry
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _gc_orphan_profiles(
|
|
248
|
+
creds_dir: Path, old_manifest: dict, new_entries: list[dict]
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Remove cached .mobileprovision files for bundles no longer present."""
|
|
251
|
+
new_uuids = {e["uuid"] for e in new_entries}
|
|
252
|
+
for raw in old_manifest.get("profiles", []):
|
|
253
|
+
uuid = raw.get("uuid")
|
|
254
|
+
if not uuid or uuid in new_uuids:
|
|
255
|
+
continue
|
|
256
|
+
path = creds_store.profile_path(creds_dir, uuid)
|
|
257
|
+
if path.exists():
|
|
258
|
+
path.unlink(missing_ok=True)
|
|
259
|
+
print(f"GC'd orphan profile {uuid} ({raw.get('bundle_id')!r})")
|
|
260
|
+
|
|
261
|
+
|
|
161
262
|
def provision_all_bundles(
|
|
162
263
|
token: str,
|
|
163
264
|
bundle_ids: list[str],
|
|
164
265
|
cert_id: str,
|
|
266
|
+
*,
|
|
267
|
+
creds_dir: Path,
|
|
268
|
+
cache_hit: bool,
|
|
165
269
|
) -> tuple[list[tuple[str, str, str]], str]:
|
|
166
|
-
"""Create + install a CI profile for each bundle id.
|
|
270
|
+
"""Create + install a CI profile for each bundle id, with caching.
|
|
167
271
|
|
|
168
272
|
Returns ``(mappings, team_id)`` where mappings is a list of
|
|
169
273
|
``(bundle_id, profile_name, uuid)`` tuples and ``team_id`` is the
|
|
170
274
|
team Apple assigned to the profiles (shared — the ASC API key binds
|
|
171
275
|
every profile it issues to a single team).
|
|
276
|
+
|
|
277
|
+
``cache_hit`` says whether the cert came from cache. ``creds_dir``
|
|
278
|
+
is the workspace ``creds/`` root. When the manifest's cert_id
|
|
279
|
+
differs from ``cert_id`` we force a full regen — Apple invalidates
|
|
280
|
+
every profile bound to the prior cert the moment that cert is
|
|
281
|
+
revoked, so reusing on-disk profiles would archive unsigned bits.
|
|
172
282
|
"""
|
|
283
|
+
manifest = creds_store.load_profile_manifest(creds_dir)
|
|
284
|
+
can_reuse = cache_hit and manifest.get("cert_id") == cert_id
|
|
285
|
+
if cache_hit and not can_reuse:
|
|
286
|
+
print(
|
|
287
|
+
f"Manifest cert_id {manifest.get('cert_id')!r} differs from "
|
|
288
|
+
f"current {cert_id!r}; regenerating all profiles"
|
|
289
|
+
)
|
|
290
|
+
|
|
173
291
|
results: list[tuple[str, str, str]] = []
|
|
292
|
+
new_entries: list[dict] = []
|
|
174
293
|
effective_team = ""
|
|
175
294
|
for bid in bundle_ids:
|
|
176
|
-
name =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
profile_der = create_profile(token, name, bundle_pk, cert_id)
|
|
180
|
-
uuid, team_id = install_profile(profile_der)
|
|
295
|
+
name, uuid, team_id, entry = _provision_bundle(
|
|
296
|
+
token, bid, cert_id, creds_dir, manifest, can_reuse
|
|
297
|
+
)
|
|
181
298
|
if team_id:
|
|
182
299
|
effective_team = team_id
|
|
183
|
-
print(f"Installed provisioning profile {name} -> {uuid} ({bid})")
|
|
184
300
|
results.append((bid, name, uuid))
|
|
301
|
+
new_entries.append(entry)
|
|
302
|
+
|
|
303
|
+
_gc_orphan_profiles(creds_dir, manifest, new_entries)
|
|
304
|
+
creds_store.write_profile_manifest(
|
|
305
|
+
creds_dir, {"cert_id": cert_id, "profiles": new_entries}
|
|
306
|
+
)
|
|
185
307
|
return results, effective_team
|
|
@@ -78,29 +78,24 @@ def _log(msg: str) -> None:
|
|
|
78
78
|
print(msg, file=sys.stderr)
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
# Apple's 409 STATE_ERROR detail string for the known-benign case where
|
|
82
|
+
# the per-localization slot is locked because the parent version is
|
|
83
|
+
# transitioning between states. Substring match (not regex / equality) so
|
|
84
|
+
# we don't break if Apple appends or reformats surrounding context.
|
|
85
|
+
_WHATSNEW_LOCKED_DETAIL = "cannot be edited at this time"
|
|
86
|
+
|
|
87
|
+
|
|
81
88
|
def _patch_localization(
|
|
82
89
|
token: str, localization_id: str, whats_new: str
|
|
83
90
|
) -> bool:
|
|
84
91
|
"""PATCH a single localization's whatsNew. Returns True on success,
|
|
85
92
|
False when Apple reports the localization is locked (409 STATE_ERROR).
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
or transitioning) -- a race the version-level state check at the top
|
|
93
|
-
of main() cannot see.
|
|
94
|
-
|
|
95
|
-
Without this allow-list, asc_common.request() raises SystemExit on
|
|
96
|
-
the 409, which the script's top-level try/except cannot swallow
|
|
97
|
-
(SystemExit is explicitly re-raised). The whole CI run then fails
|
|
98
|
-
at exit code 1 even though the IPA upload already succeeded.
|
|
99
|
-
|
|
100
|
-
Other non-2xx statuses (auth, 5xx, malformed payloads, real ASC
|
|
101
|
-
outages) are NOT in the allow-list and continue to fail loud --
|
|
102
|
-
asc_common.request() retries 5xx automatically and SystemExits on
|
|
103
|
-
everything else.
|
|
94
|
+
See _handle_localization_409 for the rationale behind allow_status={409}
|
|
95
|
+
and the benign-lock vs unexpected-409 routing. Other non-2xx statuses
|
|
96
|
+
(auth, 5xx, malformed payloads, real ASC outages) are NOT in the
|
|
97
|
+
allow-list and continue to fail loud -- asc_common.request() retries
|
|
98
|
+
5xx automatically and SystemExits on everything else.
|
|
104
99
|
"""
|
|
105
100
|
resp = request(
|
|
106
101
|
"PATCH",
|
|
@@ -116,16 +111,75 @@ def _patch_localization(
|
|
|
116
111
|
allow_status={409},
|
|
117
112
|
)
|
|
118
113
|
if resp.status_code == 409:
|
|
119
|
-
|
|
120
|
-
f"appStoreVersionLocalization {localization_id} returned 409 "
|
|
121
|
-
f"STATE_ERROR (locked); whatsNew not patched for this "
|
|
122
|
-
f"localization. Other localizations and the rest of the run "
|
|
123
|
-
f"continue."
|
|
124
|
-
)
|
|
114
|
+
_handle_localization_409(localization_id, resp)
|
|
125
115
|
return False
|
|
126
116
|
return True
|
|
127
117
|
|
|
128
118
|
|
|
119
|
+
def _handle_localization_409(localization_id: str, resp) -> None:
|
|
120
|
+
"""Route an ASC 409 STATE_ERROR on a localization PATCH to the right
|
|
121
|
+
log channel based on the response detail.
|
|
122
|
+
|
|
123
|
+
Apple returns 409 STATE_ERROR ("Attribute 'whatsNew' cannot be edited
|
|
124
|
+
at this time") on individual localizations even when the parent
|
|
125
|
+
appStoreVersion's appStoreState is in the editable allow-list checked
|
|
126
|
+
upstream. This happens when the per-localization state is locked
|
|
127
|
+
independently (e.g. submitted, in-review at the localization level,
|
|
128
|
+
or transitioning) -- a race the version-level state check at the top
|
|
129
|
+
of main() cannot see.
|
|
130
|
+
|
|
131
|
+
Without an allow_status={409} entry in the request, asc_common.request()
|
|
132
|
+
raises SystemExit on the 409, which the script's top-level try/except
|
|
133
|
+
cannot swallow (SystemExit is explicitly re-raised). The whole CI run
|
|
134
|
+
then fails at exit code 1 even though the IPA upload already succeeded.
|
|
135
|
+
|
|
136
|
+
The benign-lock detail is logged plain -- it's the expected,
|
|
137
|
+
non-actionable case and emitting `::warning::` annotations every clean
|
|
138
|
+
run produces noise in the GH workflow summary. Any OTHER 409 detail
|
|
139
|
+
(genuinely unexpected) is still surfaced as `::warning::`.
|
|
140
|
+
"""
|
|
141
|
+
detail = _extract_409_detail(resp)
|
|
142
|
+
if _WHATSNEW_LOCKED_DETAIL in detail:
|
|
143
|
+
_log(
|
|
144
|
+
f"[whatsNew] localization {localization_id} is currently "
|
|
145
|
+
f"locked, skipping (detail: {detail!r})"
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
_warn(
|
|
149
|
+
f"appStoreVersionLocalization {localization_id} returned 409 "
|
|
150
|
+
f"with unexpected detail {detail!r}; whatsNew not patched for "
|
|
151
|
+
f"this localization. Other localizations and the rest of the "
|
|
152
|
+
f"run continue."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _extract_409_detail(resp) -> str:
|
|
157
|
+
"""Pull the human-readable detail string out of an ASC 409 response.
|
|
158
|
+
|
|
159
|
+
ASC error envelope: {"errors": [{"status": "409", "code": "STATE_ERROR",
|
|
160
|
+
"title": "...", "detail": "Attribute 'whatsNew' cannot be edited at this
|
|
161
|
+
time"}]}. We collapse all error details into a single string so the
|
|
162
|
+
benign-lock substring match survives Apple returning multiple errors
|
|
163
|
+
in one response. Falls back to raw response text if JSON parsing fails
|
|
164
|
+
(defensive -- Apple's error envelopes have been stable for years but
|
|
165
|
+
the network layer occasionally returns HTML on infrastructure faults).
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
body = resp.json()
|
|
169
|
+
except ValueError:
|
|
170
|
+
return resp.text or ""
|
|
171
|
+
errors = body.get("errors") if isinstance(body, dict) else None
|
|
172
|
+
if not errors:
|
|
173
|
+
return ""
|
|
174
|
+
parts = []
|
|
175
|
+
for err in errors:
|
|
176
|
+
if isinstance(err, dict):
|
|
177
|
+
detail = err.get("detail") or err.get("title") or ""
|
|
178
|
+
if detail:
|
|
179
|
+
parts.append(detail)
|
|
180
|
+
return " | ".join(parts)
|
|
181
|
+
|
|
182
|
+
|
|
129
183
|
def _create_localization(
|
|
130
184
|
token: str, version_id: str, locale: str, whats_new: str
|
|
131
185
|
) -> str:
|
|
@@ -204,10 +258,13 @@ def _update_all_localizations(
|
|
|
204
258
|
else:
|
|
205
259
|
skipped += 1
|
|
206
260
|
if skipped:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
261
|
+
# Per-localization detail (lock state, unexpected 409, etc.) was
|
|
262
|
+
# already emitted above by _patch_localization -- this is just the
|
|
263
|
+
# rollup count so the workflow log shows a single summary line.
|
|
264
|
+
# We deliberately avoid "see warnings above": for the benign-lock
|
|
265
|
+
# case _patch_localization emits plain _log entries (no ::warning::),
|
|
266
|
+
# so a "warnings above" pointer would mislead the reader.
|
|
267
|
+
_log(f"whatsNew skipped for {skipped} locked localization(s)")
|
|
211
268
|
return count
|
|
212
269
|
|
|
213
270
|
|