@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.
@@ -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 Apple assigned to
128
- the profile — the effective team that Xcode expects to find in
129
- ``DEVELOPMENT_TEAM``. It may differ from any team value in
130
- ci.config.yaml; the ASC API key is bound to exactly one team and every
131
- profile it issues inherits that team.
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
- primary = _PROFILE_DIRS[0]
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 = profile_name_for(bid)
177
- bundle_pk = ensure_bundle_id(token, bid)
178
- delete_profile_by_name(token, name)
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