gowalk-cicd 1.0.0

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.
Files changed (84) hide show
  1. package/CLAUDE.md +94 -0
  2. package/README.md +417 -0
  3. package/action/.daemux-version +1 -0
  4. package/action/MIGRATION.md +232 -0
  5. package/action/action.yml +973 -0
  6. package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
  7. package/action/prompts/generate_descriptions.prompt.yml +59 -0
  8. package/action/scripts/app_context_scanner.py +239 -0
  9. package/action/scripts/asc_build_history.py +284 -0
  10. package/action/scripts/asc_common.py +246 -0
  11. package/action/scripts/asc_metadata_applier.py +368 -0
  12. package/action/scripts/asc_metadata_detector.py +250 -0
  13. package/action/scripts/asc_version_create.py +365 -0
  14. package/action/scripts/asc_version_fetch.py +274 -0
  15. package/action/scripts/asc_version_reuse.py +177 -0
  16. package/action/scripts/auto_detect.py +391 -0
  17. package/action/scripts/autoupdate_check.sh +118 -0
  18. package/action/scripts/cert_factory.py +190 -0
  19. package/action/scripts/cfg_io.py +27 -0
  20. package/action/scripts/cfg_resolve.py +147 -0
  21. package/action/scripts/commit_bot_changes.sh +82 -0
  22. package/action/scripts/creds_store.py +328 -0
  23. package/action/scripts/keychain.py +103 -0
  24. package/action/scripts/lookup_app_id.py +51 -0
  25. package/action/scripts/manage_marketing_version.py +337 -0
  26. package/action/scripts/metadata_constants.py +102 -0
  27. package/action/scripts/mmv_decide_create.py +181 -0
  28. package/action/scripts/mmv_floor_check.py +265 -0
  29. package/action/scripts/next_build_number.py +195 -0
  30. package/action/scripts/pbxproj_editor.py +220 -0
  31. package/action/scripts/prepare_signing.py +223 -0
  32. package/action/scripts/profile_io.py +64 -0
  33. package/action/scripts/profile_manager.py +307 -0
  34. package/action/scripts/read_config.py +315 -0
  35. package/action/scripts/resolve_marketing_version.py +173 -0
  36. package/action/scripts/set_app_store_whats_new.py +317 -0
  37. package/action/scripts/team_resolver.py +130 -0
  38. package/action/scripts/test_app_context_scanner.py +167 -0
  39. package/action/scripts/test_asc_build_history.py +221 -0
  40. package/action/scripts/test_asc_builds_prerelease.py +219 -0
  41. package/action/scripts/test_asc_common_timeouts.py +121 -0
  42. package/action/scripts/test_asc_metadata_applier.py +850 -0
  43. package/action/scripts/test_asc_metadata_detector.py +445 -0
  44. package/action/scripts/test_auto_detect.py +432 -0
  45. package/action/scripts/test_cfg_resolve_credentials.py +48 -0
  46. package/action/scripts/test_compute_next_version.py +95 -0
  47. package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
  48. package/action/scripts/test_compute_next_version_strictness.py +67 -0
  49. package/action/scripts/test_fetch_versions.py +163 -0
  50. package/action/scripts/test_ground_truth_floor.py +104 -0
  51. package/action/scripts/test_manage_marketing_version.py +134 -0
  52. package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
  53. package/action/scripts/test_manage_marketing_version_floor.py +170 -0
  54. package/action/scripts/test_manage_marketing_version_match.py +187 -0
  55. package/action/scripts/test_mmv_409_classifier.py +231 -0
  56. package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
  57. package/action/scripts/test_mmv_decide_create.py +119 -0
  58. package/action/scripts/test_mmv_floor_check.py +158 -0
  59. package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
  60. package/action/scripts/test_mmv_helpers.py +57 -0
  61. package/action/scripts/test_next_build_number.py +174 -0
  62. package/action/scripts/test_read_config_auto_detect.py +257 -0
  63. package/action/scripts/test_resolve_marketing_version.py +298 -0
  64. package/action/scripts/test_reuse_stale_editable.py +182 -0
  65. package/action/scripts/test_set_app_store_whats_new.py +71 -0
  66. package/action/scripts/test_team_fallback_wiring.py +157 -0
  67. package/action/scripts/test_team_resolver.py +249 -0
  68. package/action/scripts/tests_common.py +167 -0
  69. package/action/scripts/version_utils.py +373 -0
  70. package/android-action/.daemux-version +1 -0
  71. package/android-action/action.yml +92 -0
  72. package/android-action/scripts/android_config.py +130 -0
  73. package/android-action/scripts/bitrise_deploy.py +160 -0
  74. package/android-action/scripts/find_bundle.py +25 -0
  75. package/android-action/scripts/play_preflight.py +69 -0
  76. package/android-action/scripts/resolve_android.py +70 -0
  77. package/android-action/scripts/sign_bundle.py +70 -0
  78. package/android-action/scripts/test_android_config.py +97 -0
  79. package/android-action/scripts/test_bitrise_deploy.py +124 -0
  80. package/android-action/scripts/test_sign_bundle.py +82 -0
  81. package/bin/cli.mjs +63 -0
  82. package/package.json +57 -0
  83. package/src/install.mjs +208 -0
  84. package/templates/deploy.yml +150 -0
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Prepare iOS code signing on a fresh CI runner (manual-signing variant).
4
+
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.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+ from datetime import datetime, timedelta, timezone
40
+ from pathlib import Path
41
+
42
+ import cert_factory
43
+ import creds_store
44
+ from asc_common import make_jwt
45
+ from keychain import setup_keychain
46
+ from pbxproj_editor import discover_signable_targets, patch_project_signing
47
+ from profile_manager import provision_all_bundles
48
+
49
+
50
+ def env(name: str) -> str:
51
+ val = os.environ.get(name)
52
+ if not val:
53
+ raise SystemExit(f"missing env var: {name}")
54
+ return val
55
+
56
+
57
+ def optional_env(name: str) -> str:
58
+ return os.environ.get(name, "")
59
+
60
+
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)``.
65
+
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.
70
+ """
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."
87
+ )
88
+ return cached.cert_id, cached.p12_bytes, cached.password, True
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)
96
+
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()
100
+ )
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
106
+ )
107
+ print(f"Persisted fresh cert bundle to {creds_dir}")
108
+ return cert_id, p12_bytes, password, False
109
+
110
+
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"
118
+
119
+
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.
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
146
+
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}")
167
+
168
+
169
+ def _resolve_project_path() -> str:
170
+ """Return the .xcodeproj path; reject WORKSPACE-only mode loudly."""
171
+ project = optional_env("PROJECT")
172
+ if optional_env("WORKSPACE") and not project:
173
+ raise SystemExit(
174
+ "prepare_signing: WORKSPACE-only mode is not supported; the "
175
+ "underlying .xcodeproj must be passed via PROJECT so we can "
176
+ "patch its signing settings."
177
+ )
178
+ if not project:
179
+ raise SystemExit("prepare_signing: PROJECT env var is required")
180
+ return project
181
+
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()
191
+
192
+ targets = discover_signable_targets(project)
193
+ bundle_ids = sorted({t["bundle_id"] for t in targets})
194
+ print(f"Signable targets ({len(targets)}):")
195
+ for t in targets:
196
+ print(
197
+ f" - {t['name']} -> {t['bundle_id']} "
198
+ f"({len(t['config_ids'])} configs)"
199
+ )
200
+
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
+ )
207
+ print("Profile map:")
208
+ for bid, pname, uuid in mappings:
209
+ print(f" {bid} -> {pname} ({uuid})")
210
+
211
+ pbx_team = _resolve_pbx_team(effective_team, team_id)
212
+ patch_project_signing(project, targets, pbx_team)
213
+ _write_signing_map(runner_temp, pbx_team, mappings)
214
+
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).
217
+ p12_path = Path(runner_temp) / "cert.p12"
218
+ p12_path.write_bytes(p12_bytes)
219
+ setup_keychain(p12_path, p12_pass, runner_temp)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ 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)
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Provisioning profile lifecycle helpers for the iOS native TestFlight action.
4
+
5
+ Talks to the App Store Connect API to:
6
+
7
+ * Register a bundle ID if it doesn't exist yet (``ensure_bundle_id``).
8
+ * Delete any stale profile with the same name and create a fresh one that
9
+ references the just-issued distribution cert (``delete_profile_by_name``,
10
+ ``create_profile``).
11
+ * Install the resulting ``.mobileprovision`` into every Xcode profile
12
+ directory (Xcode 15 and Xcode 16+ use different paths).
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
+
35
+ Xcode pbxproj editing lives in ``pbxproj_editor.py``; this file is
36
+ ASC-facing only.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import base64
42
+ import subprocess
43
+ from datetime import datetime
44
+ from pathlib import Path
45
+
46
+ import creds_store
47
+ from asc_common import get_json, request
48
+ from pbxproj_editor import PROFILE_PREFIX
49
+ from profile_io import decode_profile_plist, ensure_utc
50
+
51
+
52
+ # Xcode 16+ moved the canonical profile directory. Older Xcode releases
53
+ # used ~/Library/MobileDevice/Provisioning Profiles/. Write to BOTH so the
54
+ # same script works on macos-14 (Xcode 15) and macos-15 (Xcode 26).
55
+ _PROFILE_DIRS = [
56
+ Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles",
57
+ Path.home() / "Library/MobileDevice/Provisioning Profiles",
58
+ ]
59
+
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # ASC bundle ID registration #
63
+ # --------------------------------------------------------------------------- #
64
+
65
+ def ensure_bundle_id(token: str, identifier: str) -> str:
66
+ """Return the ASC primary key for ``identifier``; register if missing."""
67
+ data = get_json(
68
+ "/bundleIds",
69
+ token,
70
+ params={"filter[identifier]": identifier, "limit": "5"},
71
+ )
72
+ for item in data.get("data", []):
73
+ if item["attributes"]["identifier"] == identifier:
74
+ return item["id"]
75
+ return _register_bundle_id(token, identifier)
76
+
77
+
78
+ def _register_bundle_id(token: str, identifier: str) -> str:
79
+ body = {
80
+ "data": {
81
+ "type": "bundleIds",
82
+ "attributes": {
83
+ "identifier": identifier,
84
+ "name": identifier.replace(".", "-"),
85
+ "platform": "IOS",
86
+ },
87
+ }
88
+ }
89
+ resp = request("POST", "/bundleIds", token, json_body=body)
90
+ bundle_pk = resp.json()["data"]["id"]
91
+ print(f"Registered new bundle id {identifier!r} (pk={bundle_pk})")
92
+ return bundle_pk
93
+
94
+
95
+ # --------------------------------------------------------------------------- #
96
+ # Profile lifecycle #
97
+ # --------------------------------------------------------------------------- #
98
+
99
+ def profile_name_for(bundle_id: str) -> str:
100
+ return f"{PROFILE_PREFIX}{bundle_id}"
101
+
102
+
103
+ def delete_profile_by_name(token: str, name: str) -> None:
104
+ """Delete every existing profile with the given name (paginated scan)."""
105
+ deleted_any = False
106
+ next_path: str | None = "/profiles?limit=200"
107
+ while next_path:
108
+ data = get_json(next_path, token)
109
+ for p in data.get("data", []):
110
+ if (p["attributes"].get("name") or "") == name:
111
+ pid = p["id"]
112
+ print(f"Deleting stale profile {pid} ({name})")
113
+ request("DELETE", f"/profiles/{pid}", token)
114
+ deleted_any = True
115
+ next_link = (data.get("links") or {}).get("next")
116
+ if not next_link:
117
+ break
118
+ idx = next_link.find("/v1")
119
+ next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
120
+ if not deleted_any:
121
+ print(f"No existing profile named {name!r}")
122
+
123
+
124
+ def create_profile(
125
+ token: str, name: str, bundle_pk: str, cert_id: str
126
+ ) -> bytes:
127
+ body = {
128
+ "data": {
129
+ "type": "profiles",
130
+ "attributes": {
131
+ "name": name,
132
+ "profileType": "IOS_APP_STORE",
133
+ },
134
+ "relationships": {
135
+ "bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}},
136
+ "certificates": {
137
+ "data": [{"type": "certificates", "id": cert_id}]
138
+ },
139
+ },
140
+ }
141
+ }
142
+ resp = request("POST", "/profiles", token, json_body=body)
143
+ payload = resp.json()["data"]["attributes"]["profileContent"]
144
+ return base64.b64decode(payload)
145
+
146
+
147
+ def install_profile(profile_der: bytes) -> tuple[str, str, datetime]:
148
+ """Install the raw profile into every Xcode-visible directory.
149
+
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.
155
+ """
156
+ plist = decode_profile_plist(profile_der, _PROFILE_DIRS[0])
157
+ uuid = plist["UUID"]
158
+ team_ids = plist.get("TeamIdentifier") or []
159
+ team_id = team_ids[0] if team_ids else ""
160
+ profile_name = plist.get("Name") or "<unknown>"
161
+ expiration = ensure_utc(plist.get("ExpirationDate"))
162
+ final_name = f"{uuid}.mobileprovision"
163
+ for directory in _PROFILE_DIRS:
164
+ directory.mkdir(parents=True, exist_ok=True)
165
+ (directory / final_name).write_bytes(profile_der)
166
+ print(
167
+ f" profile {profile_name!r}: uuid={uuid} team={team_id} "
168
+ f"exp={expiration.isoformat()} dirs={[str(d) for d in _PROFILE_DIRS]}"
169
+ )
170
+ return uuid, team_id, expiration
171
+
172
+
173
+ # --------------------------------------------------------------------------- #
174
+ # Orchestration #
175
+ # --------------------------------------------------------------------------- #
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
+
262
+ def provision_all_bundles(
263
+ token: str,
264
+ bundle_ids: list[str],
265
+ cert_id: str,
266
+ *,
267
+ creds_dir: Path,
268
+ cache_hit: bool,
269
+ ) -> tuple[list[tuple[str, str, str]], str]:
270
+ """Create + install a CI profile for each bundle id, with caching.
271
+
272
+ Returns ``(mappings, team_id)`` where mappings is a list of
273
+ ``(bundle_id, profile_name, uuid)`` tuples and ``team_id`` is the
274
+ team Apple assigned to the profiles (shared — the ASC API key binds
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.
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
+
291
+ results: list[tuple[str, str, str]] = []
292
+ new_entries: list[dict] = []
293
+ effective_team = ""
294
+ for bid in bundle_ids:
295
+ name, uuid, team_id, entry = _provision_bundle(
296
+ token, bid, cert_id, creds_dir, manifest, can_reuse
297
+ )
298
+ if team_id:
299
+ effective_team = team_id
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
+ )
307
+ return results, effective_team