@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.
@@ -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
@@ -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
- Apple returns 409 STATE_ERROR ("Attribute 'whatsNew' cannot be edited
88
- at this time") on individual localizations even when the parent
89
- appStoreVersion's appStoreState is in the editable allow-list checked
90
- upstream. This happens when the per-localization state is locked
91
- independently (e.g. submitted, in-review at the localization level,
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
- _warn(
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
- _log(
208
- f"whatsNew skipped for {skipped} locked localization(s) "
209
- f"(see warnings above)"
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