@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.
@@ -2,51 +2,47 @@
2
2
  """
3
3
  Prepare iOS code signing on a fresh CI runner (manual-signing variant).
4
4
 
5
- Steps:
6
-
7
- 1. Generate an RSA private key + CSR.
8
- 2. Create a new Apple Distribution certificate from the CSR; if the per-team
9
- cap is hit (409), revoke the oldest existing DISTRIBUTION cert and retry.
10
- 3. Discover every signable native target in the Xcode project (main app +
11
- extensions like Network Extensions, Widgets, WatchKit apps). SwiftPM
12
- resource bundles are skipped they can't be signed manually.
13
- 4. Register each bundle ID on App Store Connect if missing.
14
- 5. Create one IOS_APP_STORE provisioning profile per bundle ID, named
15
- ``CI-<bundle_id>``, linked to the new cert (deleting any stale profile
16
- with the same name so the reference matches the fresh cert).
17
- 6. Install every profile into ``~/Library/MobileDevice/Provisioning Profiles/``.
18
- 7. Patch the pbxproj in place so each signable target's build settings
19
- select manual signing + the matching CI profile. SwiftPM targets are
20
- left alone they keep their default automatic (no-sign) config.
21
- 8. Import cert + private key into a temporary keychain placed at the head of
22
- the user search list so codesign / xcodebuild can find it.
23
-
24
- Environment inputs:
25
- ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH - App Store Connect API key trio
26
- PROJECT - Xcode project path (.xcodeproj)
27
- TEAM_ID - Apple developer team identifier
28
- (optional; derived from the
29
- installed provisioning profile
30
- when empty)
31
- RUNNER_TEMP - GitHub Actions temp dir
32
-
33
- Writes nothing to stdout that would leak secrets.
5
+ Caching: the Apple Distribution cert + per-bundle provisioning profiles
6
+ live under the workspace's ``creds/`` directory and are reused across
7
+ runs. See ``creds_store.py`` for the on-disk layout and reuse rules.
8
+ This script orchestrates the load-or-regen flow:
9
+
10
+ 1. Try to reuse the cached cert (decrypts + NotAfter > 30d +
11
+ ``GET /certificates/{cert_id}`` = 200).
12
+ 2. On miss, invalidate the cache and create a fresh cert via
13
+ ``cert_factory`` (handling Apple's per-team cap of 2 by revoking
14
+ the OLDEST existing one).
15
+ 3. Hand the cert id + cache_hit flag to ``provision_all_bundles``,
16
+ which decides per-bundle whether to reuse a cached profile or
17
+ create a new one and updates the manifest.
18
+ 4. Orphan profiles (manifest entries for bundles no longer in the
19
+ target list) get GC'd from disk + manifest.
20
+ 5. After successful prep the workspace ``creds/`` tree is dirty with
21
+ any refreshed artifacts; ``action.yml`` commits the diff back when
22
+ the run is on the default branch.
23
+
24
+ Sibling modules: ``cert_factory`` (key+CSR+ASC POST), ``keychain``
25
+ (throwaway runner keychain), ``profile_manager`` (per-bundle profile
26
+ lifecycle), ``profile_io`` (CMS decode), ``creds_store`` (on-disk cache).
27
+
28
+ Env inputs: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (key trio), PROJECT
29
+ (.xcodeproj), TEAM_ID (optional; derived from profile plist when empty),
30
+ RUNNER_TEMP, CREDS_DIR (optional override; defaults to
31
+ ``$GITHUB_WORKSPACE/creds``). Writes nothing to stdout that would leak
32
+ secrets.
34
33
  """
35
34
 
36
35
  from __future__ import annotations
37
36
 
38
- import base64
39
37
  import json
40
38
  import os
41
- import subprocess
39
+ from datetime import datetime, timedelta, timezone
42
40
  from pathlib import Path
43
41
 
44
- from asc_common import get_json, make_jwt, request
45
- from cryptography import x509
46
- from cryptography.hazmat.primitives import hashes, serialization
47
- from cryptography.hazmat.primitives.asymmetric import rsa
48
- from cryptography.hazmat.primitives.serialization import pkcs12
49
- from cryptography.x509.oid import NameOID
42
+ import cert_factory
43
+ import creds_store
44
+ from asc_common import make_jwt
45
+ from keychain import setup_keychain
50
46
  from pbxproj_editor import discover_signable_targets, patch_project_signing
51
47
  from profile_manager import provision_all_bundles
52
48
 
@@ -59,215 +55,121 @@ def env(name: str) -> str:
59
55
 
60
56
 
61
57
  def optional_env(name: str) -> str:
62
- return os.environ.get(name, "") or ""
63
-
64
-
65
- def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
66
- private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
67
- csr = (
68
- x509.CertificateSigningRequestBuilder()
69
- .subject_name(
70
- x509.Name(
71
- [
72
- x509.NameAttribute(NameOID.COMMON_NAME, "Daemux CI"),
73
- x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
74
- ]
75
- )
76
- )
77
- .sign(private_key, hashes.SHA256())
78
- )
79
- csr_pem = csr.public_bytes(serialization.Encoding.PEM)
80
- csr_payload = b"".join(
81
- line for line in csr_pem.splitlines() if not line.startswith(b"-----")
82
- )
83
- return private_key, csr_payload
84
-
85
-
86
- def oldest_distribution_cert_id(token: str) -> str | None:
87
- """Return the DISTRIBUTION cert with the EARLIEST expiration date.
58
+ return os.environ.get(name, "")
88
59
 
89
- Apple's per-team distribution-cert cap is 2. When CI hits the cap we
90
- must revoke one to make room for a fresh cert. We deliberately pick
91
- the OLDEST cert (earliest expirationDate) because:
92
60
 
93
- * Each CI run signs an IPA, uploads it to ASC, and that build then
94
- spends minutes-to-hours in App Store Connect's processing
95
- pipeline. Processing re-validates the binary's leaf cert against
96
- Apple's current cert state. A revoked cert during processing
97
- produces ITMS-90035 ("invalid signature ... signed with an ad-hoc
98
- certificate, not a distribution certificate") and rejects the
99
- build that was already accepted at upload time.
61
+ def _load_or_create_cert(
62
+ token: str, creds_dir: Path
63
+ ) -> tuple[str, bytes, str, bool]:
64
+ """Return ``(cert_id, p12_bytes, password, cache_hit)``.
100
65
 
101
- * The NEWEST cert is, by construction, the one that signed the most
102
- recent build — i.e. the build that is right now in ASC processing
103
- and most exposed to the revocation race. Revoking it (the prior
104
- behavior) reliably broke the build the previous CI run produced.
105
-
106
- * The OLDEST cert is the one most likely to be from a build whose
107
- ASC processing has long since completed (each CI run only adds
108
- builds; processing finishes in minutes). Revoking it is the
109
- safest choice.
66
+ Cache hit when the on-disk cert decrypts, NotAfter > 30d away, and
67
+ Apple confirms the cert id is still alive. On miss the cache is
68
+ invalidated, a new cert is created (handling Apple's per-team cap),
69
+ and the cache is repopulated atomically.
110
70
  """
111
- data = get_json(
112
- "/certificates",
113
- token,
114
- params={
115
- "limit": "200",
116
- "sort": "-id",
117
- "filter[certificateType]": "DISTRIBUTION",
118
- },
119
- )
120
- oldest_id = None
121
- oldest_exp: str | None = None
122
- for cert in data.get("data", []):
123
- attrs = cert.get("attributes") or {}
124
- exp = attrs.get("expirationDate") or ""
125
- if not exp:
126
- continue
127
- if oldest_exp is None or exp < oldest_exp:
128
- oldest_exp = exp
129
- oldest_id = cert["id"]
130
- return oldest_id
131
-
132
-
133
- def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
134
- body = {
135
- "data": {
136
- "type": "certificates",
137
- "attributes": {
138
- "csrContent": csr_b64,
139
- "certificateType": "DISTRIBUTION",
140
- },
141
- }
142
- }
143
- resp = request(
144
- "POST", "/certificates", token, json_body=body, allow_status={409}
145
- )
146
- if resp.status_code == 409:
147
- # Revoke the OLDEST cert, NOT the newest. The newest cert signed
148
- # the previous build that may still be in ASC processing — revoking
149
- # it during processing yields ITMS-90035 on the prior build.
150
- # See oldest_distribution_cert_id() for the full rationale.
151
- print("Distribution cert cap hit; revoking oldest existing cert")
152
- target = oldest_distribution_cert_id(token)
153
- if not target:
154
- raise SystemExit(
155
- "409 from cert create but no existing DISTRIBUTION cert "
156
- "found to revoke"
71
+ cached = creds_store.load_cached_cert(creds_dir)
72
+ if cached and creds_store.verify_cert_alive(token, cached.cert_id):
73
+ print(
74
+ f"Reusing cached distribution cert {cached.cert_id} "
75
+ f"(NotAfter {cached.not_after.isoformat()})"
76
+ )
77
+ # Warn when inside T-60d..T-30d so operators see renewal is pending
78
+ # but auto-renew has not yet fired.
79
+ now = datetime.now(timezone.utc)
80
+ renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
81
+ warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
82
+ if renew_at < cached.not_after <= warn_at:
83
+ print(
84
+ f"::warning::Cert {cached.cert_id} expires in "
85
+ f"{(cached.not_after - now).days}d; auto-renew fires at "
86
+ f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
157
87
  )
158
- request("DELETE", f"/certificates/{target}", token)
159
- resp = request("POST", "/certificates", token, json_body=body)
160
- data = resp.json()["data"]
161
- cert_id = data["id"]
162
- cert_der = base64.b64decode(data["attributes"]["certificateContent"])
163
- print(f"Created DISTRIBUTION cert {cert_id}")
164
- return cert_id, cert_der
165
-
166
-
167
- def write_p12(
168
- private_key: rsa.RSAPrivateKey, cert_der: bytes, out_path: Path, passwd: str
169
- ) -> None:
170
- cert = x509.load_der_x509_certificate(cert_der)
171
- p12 = pkcs12.serialize_key_and_certificates(
172
- name=b"Daemux CI",
173
- key=private_key,
174
- cert=cert,
175
- cas=None,
176
- encryption_algorithm=serialization.BestAvailableEncryption(
177
- passwd.encode()
178
- ),
179
- )
180
- out_path.write_bytes(p12)
88
+ return cached.cert_id, cached.p12_bytes, cached.password, True
181
89
 
90
+ if cached:
91
+ print(
92
+ f"Cached cert {cached.cert_id} no longer alive on Apple; "
93
+ "invalidating cache and regenerating"
94
+ )
95
+ creds_store.invalidate_cache(creds_dir)
182
96
 
183
- def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
184
- subprocess.run(
185
- ["security", "delete-keychain", keychain_path],
186
- check=False,
187
- capture_output=True,
188
- )
189
- subprocess.check_call(
190
- ["security", "create-keychain", "-p", keychain_pass, keychain_path]
97
+ private_key, csr_b64 = cert_factory.generate_key_and_csr()
98
+ cert_id, cert_der = cert_factory.create_distribution_cert(
99
+ token, csr_b64.decode()
191
100
  )
192
- subprocess.check_call(
193
- ["security", "set-keychain-settings", "-lut", "21600", keychain_path]
194
- )
195
- subprocess.check_call(
196
- ["security", "unlock-keychain", "-p", keychain_pass, keychain_path]
101
+ password = creds_store.P12_PASSWORD
102
+ p12_bytes = cert_factory.serialize_p12(private_key, cert_der, password)
103
+ not_after = creds_store.cert_not_after_from_der(cert_der)
104
+ creds_store.write_cert_bundle(
105
+ creds_dir, cert_id, p12_bytes, password, not_after
197
106
  )
107
+ print(f"Persisted fresh cert bundle to {creds_dir}")
108
+ return cert_id, p12_bytes, password, False
198
109
 
199
110
 
200
- def _import_p12(
201
- keychain_path: str, keychain_pass: str, p12_path: Path, p12_pass: str
202
- ) -> None:
203
- subprocess.check_call(
204
- [
205
- "security", "import", str(p12_path),
206
- "-P", p12_pass,
207
- "-A",
208
- "-t", "cert",
209
- "-f", "pkcs12",
210
- "-k", keychain_path,
211
- ]
212
- )
213
- subprocess.check_call(
214
- [
215
- "security", "set-key-partition-list",
216
- "-S", "apple-tool:,apple:,codesign:",
217
- "-s",
218
- "-k", keychain_pass,
219
- keychain_path,
220
- ]
221
- )
111
+ def _resolve_creds_dir() -> Path:
112
+ """Return the workspace creds/ root, honoring the CREDS_DIR override."""
113
+ override = optional_env("CREDS_DIR")
114
+ if override:
115
+ return Path(override)
116
+ workspace = optional_env("GITHUB_WORKSPACE") or os.getcwd()
117
+ return Path(workspace) / "creds"
222
118
 
223
119
 
224
- def _prepend_to_user_search_list(keychain_path: str) -> None:
225
- existing = subprocess.check_output(
226
- ["security", "list-keychains", "-d", "user"]
227
- ).decode()
228
- existing_list = [
229
- line.strip().strip('"')
230
- for line in existing.splitlines()
231
- if line.strip()
232
- ]
233
- new_list = [keychain_path] + [
234
- k for k in existing_list if k != keychain_path
235
- ]
236
- subprocess.check_call(
237
- ["security", "list-keychains", "-d", "user", "-s", *new_list]
238
- )
239
- subprocess.check_call(
240
- ["security", "default-keychain", "-s", keychain_path]
241
- )
120
+ def _resolve_pbx_team(effective_team: str, configured_team: str) -> str:
121
+ """Pick the team Xcode must see and warn on mismatch with config.
242
122
 
123
+ The ASC API key is bound to a single developer team. Every profile
124
+ it issues lives in THAT team, so Xcode's ``DEVELOPMENT_TEAM`` must
125
+ match it exactly — otherwise the profile can't be matched to the
126
+ target at archive time. If the ci.config.yaml team differs, warn
127
+ and use the profile's team as the source of truth.
128
+ """
129
+ pbx_team = effective_team or configured_team
130
+ if not pbx_team:
131
+ raise SystemExit(
132
+ "Unable to determine Apple developer team. The installed "
133
+ "provisioning profile did not expose a TeamIdentifier and no "
134
+ "TEAM_ID was provided. Set `app.team_id` in ci.config.yaml. "
135
+ "Find your team id at https://developer.apple.com/account -> "
136
+ "Membership (look for 'Team ID')."
137
+ )
138
+ if effective_team and configured_team and effective_team != configured_team:
139
+ print(
140
+ f"::warning::Config team {configured_team} differs from ASC API "
141
+ f"key's team {effective_team}; patching pbxproj with "
142
+ f"{effective_team} (the team that actually issued the "
143
+ "provisioning profiles)."
144
+ )
145
+ return pbx_team
243
146
 
244
- def setup_keychain(p12_path: Path, p12_pass: str) -> str:
245
- keychain_path = os.path.join(env("RUNNER_TEMP"), "ci.keychain-db")
246
- keychain_pass = "ci"
247
- _create_keychain(keychain_path, keychain_pass)
248
- _import_p12(keychain_path, keychain_pass, p12_path, p12_pass)
249
- _prepend_to_user_search_list(keychain_path)
250
- print(f"Keychain ready: {keychain_path}")
251
- return keychain_path
252
147
 
148
+ def _write_signing_map(
149
+ runner_temp: str, pbx_team: str, mappings: list
150
+ ) -> None:
151
+ """Persist mapping for the downstream Export IPA step.
152
+
153
+ ``ExportOptions.plist`` needs a ``provisioningProfiles`` dict and
154
+ the effective team that issued the profiles.
155
+ """
156
+ map_path = Path(runner_temp) / "signing_map.json"
157
+ map_path.write_text(
158
+ json.dumps(
159
+ {
160
+ "team_id": pbx_team,
161
+ "profiles": {bid: pname for bid, pname, _ in mappings},
162
+ },
163
+ indent=2,
164
+ )
165
+ )
166
+ print(f"Wrote signing map to {map_path}")
253
167
 
254
- def main() -> None:
255
- key_id = env("ASC_KEY_ID")
256
- issuer_id = env("ASC_ISSUER_ID")
257
- asc_key_path = env("ASC_KEY_PATH")
258
- runner_temp = env("RUNNER_TEMP")
259
- # TEAM_ID is optional: the ASC API key is bound to one developer team,
260
- # and every profile it issues inherits that team. provision_all_bundles
261
- # returns that ``effective_team`` read straight from the installed
262
- # profile's plist — which is the authoritative value Xcode will look
263
- # for in DEVELOPMENT_TEAM. Upstream (read_config.py) also tries to
264
- # derive team_id via GET-only ASC endpoints and pass it here as a
265
- # convenience for logging and xcconfig patching.
266
- team_id = optional_env("TEAM_ID")
267
168
 
169
+ def _resolve_project_path() -> str:
170
+ """Return the .xcodeproj path; reject WORKSPACE-only mode loudly."""
268
171
  project = optional_env("PROJECT")
269
- workspace = optional_env("WORKSPACE")
270
- if workspace and not project:
172
+ if optional_env("WORKSPACE") and not project:
271
173
  raise SystemExit(
272
174
  "prepare_signing: WORKSPACE-only mode is not supported; the "
273
175
  "underlying .xcodeproj must be passed via PROJECT so we can "
@@ -275,64 +177,46 @@ def main() -> None:
275
177
  )
276
178
  if not project:
277
179
  raise SystemExit("prepare_signing: PROJECT env var is required")
180
+ return project
278
181
 
279
- token = make_jwt(key_id, issuer_id, asc_key_path)
182
+
183
+ def main() -> None:
184
+ runner_temp = env("RUNNER_TEMP")
185
+ # TEAM_ID is optional — provision_all_bundles returns the effective
186
+ # team from the installed profile's plist; see _resolve_pbx_team.
187
+ team_id = optional_env("TEAM_ID")
188
+ project = _resolve_project_path()
189
+ token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
190
+ creds_dir = _resolve_creds_dir()
280
191
 
281
192
  targets = discover_signable_targets(project)
282
193
  bundle_ids = sorted({t["bundle_id"] for t in targets})
283
194
  print(f"Signable targets ({len(targets)}):")
284
195
  for t in targets:
285
- print(f" - {t['name']} -> {t['bundle_id']} ({len(t['config_ids'])} configs)")
286
-
287
- private_key, csr_b64 = generate_key_and_csr()
288
- cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
196
+ print(
197
+ f" - {t['name']} -> {t['bundle_id']} "
198
+ f"({len(t['config_ids'])} configs)"
199
+ )
289
200
 
290
- mappings, effective_team = provision_all_bundles(token, bundle_ids, cert_id)
201
+ cert_id, p12_bytes, p12_pass, cache_hit = _load_or_create_cert(
202
+ token, creds_dir
203
+ )
204
+ mappings, effective_team = provision_all_bundles(
205
+ token, bundle_ids, cert_id, creds_dir=creds_dir, cache_hit=cache_hit
206
+ )
291
207
  print("Profile map:")
292
208
  for bid, pname, uuid in mappings:
293
209
  print(f" {bid} -> {pname} ({uuid})")
294
210
 
295
- # The ASC API key is bound to a single developer team. Every profile it
296
- # issues lives in THAT team, so Xcode's DEVELOPMENT_TEAM setting must
297
- # match it exactly — otherwise the profile can't be matched to the
298
- # target at archive time. If the ci.config.yaml team differs, warn and
299
- # use the profile's team as the source of truth.
300
- pbx_team = effective_team or team_id
301
- if not pbx_team:
302
- raise SystemExit(
303
- "Unable to determine Apple developer team. The installed "
304
- "provisioning profile did not expose a TeamIdentifier and no "
305
- "TEAM_ID was provided. Set `app.team_id` in ci.config.yaml. "
306
- "Find your team id at https://developer.apple.com/account -> "
307
- "Membership (look for 'Team ID')."
308
- )
309
- if effective_team and team_id and effective_team != team_id:
310
- print(
311
- f"::warning::Config team {team_id} differs from ASC API key's "
312
- f"team {effective_team}; patching pbxproj with {effective_team} "
313
- "(the team that actually issued the provisioning profiles)."
314
- )
211
+ pbx_team = _resolve_pbx_team(effective_team, team_id)
315
212
  patch_project_signing(project, targets, pbx_team)
213
+ _write_signing_map(runner_temp, pbx_team, mappings)
316
214
 
317
- # Persist mapping for downstream Export IPA step (ExportOptions.plist
318
- # provisioningProfiles dict). Store the effective team alongside so
319
- # Export uses the same team Apple assigned to the profiles.
320
- map_path = Path(runner_temp) / "signing_map.json"
321
- map_path.write_text(
322
- json.dumps(
323
- {
324
- "team_id": pbx_team,
325
- "profiles": {bid: pname for bid, pname, _ in mappings},
326
- },
327
- indent=2,
328
- )
329
- )
330
- print(f"Wrote signing map to {map_path}")
331
-
332
- p12_pass = "ci"
215
+ # Stage p12 in $RUNNER_TEMP for the keychain importer (the cache copy
216
+ # under creds/cert.p12 stays untouched as the source of truth).
333
217
  p12_path = Path(runner_temp) / "cert.p12"
334
- write_p12(private_key, cert_der, p12_path, p12_pass)
335
- setup_keychain(p12_path, p12_pass)
218
+ p12_path.write_bytes(p12_bytes)
219
+ setup_keychain(p12_path, p12_pass, runner_temp)
336
220
 
337
221
 
338
222
  if __name__ == "__main__":
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ On-disk + plist-decode helpers for ``.mobileprovision`` files.
4
+
5
+ Split from ``profile_manager.py`` so the ASC-API-facing module stays
6
+ focused on lifecycle (create / delete / install) and this module owns
7
+ the file-format gymnastics (CMS envelope decode via ``security cms -D``,
8
+ plist date normalisation).
9
+
10
+ Nothing in this module talks to App Store Connect. Callers pass raw DER
11
+ bytes and receive parsed Python primitives.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import plistlib
17
+ import subprocess
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+
22
+ # Apple guarantees ``ExpirationDate`` in every issued profile; if it is
23
+ # missing or non-datetime the profile is malformed — treat as already-
24
+ # expired so callers never reuse it.
25
+ _EXPIRED_SENTINEL = datetime(1970, 1, 1, tzinfo=timezone.utc)
26
+
27
+
28
+ def decode_profile_plist(profile_der: bytes, scratch_dir: Path) -> dict:
29
+ """Run ``security cms -D`` against ``profile_der`` and parse the plist.
30
+
31
+ The decoded payload's only authoritative source is the macOS
32
+ ``security`` binary; ``plistlib`` alone can't parse the CMS envelope.
33
+ ``scratch_dir`` is used for a temporary file (``security`` only
34
+ accepts file paths, not stdin); it is created if missing.
35
+
36
+ May raise ``subprocess.CalledProcessError`` (decode failure),
37
+ ``ValueError`` (plist parse failure), or ``OSError`` (filesystem
38
+ failure). Callers that want graceful cache fallback must catch.
39
+ """
40
+ scratch_dir.mkdir(parents=True, exist_ok=True)
41
+ tmp_path = scratch_dir / "tmp.mobileprovision"
42
+ tmp_path.write_bytes(profile_der)
43
+ try:
44
+ decoded = subprocess.check_output(
45
+ ["security", "cms", "-D", "-i", str(tmp_path)]
46
+ )
47
+ finally:
48
+ tmp_path.unlink(missing_ok=True)
49
+ return plistlib.loads(decoded)
50
+
51
+
52
+ def ensure_utc(value) -> datetime:
53
+ """Normalise a plist datetime to a UTC-aware ``datetime``.
54
+
55
+ Plist dates parsed by ``plistlib`` come back as naive UTC; explicitly
56
+ attach ``timezone.utc`` so downstream comparisons stay timezone-safe.
57
+ Anything that is not a ``datetime`` returns the expired sentinel so
58
+ a malformed profile is never treated as reusable.
59
+ """
60
+ if not isinstance(value, datetime):
61
+ return _EXPIRED_SENTINEL
62
+ if value.tzinfo is None:
63
+ return value.replace(tzinfo=timezone.utc)
64
+ return value.astimezone(timezone.utc)