@daemux/store-automator 0.10.93 → 0.10.94
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 +73 -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
|
@@ -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
|