@daemux/store-automator 0.10.81 → 0.10.83

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.81"
8
+ "version": "0.10.83"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "store-automator",
13
13
  "source": "./plugins/store-automator",
14
14
  "description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
15
- "version": "0.10.81",
15
+ "version": "0.10.83",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.81",
3
+ "version": "0.10.83",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.81",
3
+ "version": "0.10.83",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Xcode ``project.pbxproj`` helpers.
4
+
5
+ Parses native targets and patches their XCBuildConfiguration buildSettings
6
+ blocks with manual signing values, while keeping the file's original
7
+ OpenStep format (comments, ordering, the !$*UTF8*$! header) intact. SwiftPM
8
+ and other non-native targets are ignored so their build settings stay on
9
+ whatever defaults Xcode / SwiftPM picked.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+
20
+ PROFILE_PREFIX = "CI-"
21
+
22
+ # Product types that must be signed with a provisioning profile. App
23
+ # extensions share the common ``com.apple.product-type.app-extension``
24
+ # prefix (Message Filter, Widgets, NetworkExtension, WatchKit, etc).
25
+ _SIGNABLE_PRODUCT_TYPES = {
26
+ "com.apple.product-type.application",
27
+ "com.apple.product-type.application.on-demand-install-capable",
28
+ "com.apple.product-type.application.watchapp2",
29
+ "com.apple.product-type.watchkit2-extension",
30
+ }
31
+ _SIGNABLE_PRODUCT_PREFIXES = (
32
+ "com.apple.product-type.app-extension",
33
+ )
34
+
35
+ _SIGNING_KEYS = {
36
+ "CODE_SIGN_STYLE": "Manual",
37
+ "CODE_SIGN_IDENTITY": '"Apple Distribution"',
38
+ }
39
+ _STRIP_KEYS = ("PROVISIONING_PROFILE",)
40
+
41
+ _SETTING_LINE = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")
42
+
43
+
44
+ # --------------------------------------------------------------------------- #
45
+ # Parsing #
46
+ # --------------------------------------------------------------------------- #
47
+
48
+ def _load_pbxproj(project_path: str) -> dict:
49
+ pbx = Path(project_path) / "project.pbxproj"
50
+ out = subprocess.check_output(
51
+ ["plutil", "-convert", "json", "-o", "-", str(pbx)]
52
+ )
53
+ return json.loads(out)
54
+
55
+
56
+ def _is_signable_product_type(product_type: str) -> bool:
57
+ if product_type in _SIGNABLE_PRODUCT_TYPES:
58
+ return True
59
+ return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
60
+
61
+
62
+ def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
63
+ """Return the first non-empty bundle id across the given configs."""
64
+ for cid in config_ids:
65
+ cfg = objects.get(cid) or {}
66
+ settings = cfg.get("buildSettings") or {}
67
+ bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
68
+ if bid and "$(" not in bid and "${" not in bid:
69
+ return bid
70
+ return ""
71
+
72
+
73
+ def discover_signable_targets(project_path: str) -> list[dict]:
74
+ """Return one entry per signable native target.
75
+
76
+ Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
77
+ where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
78
+ ``buildSettings`` dict we need to patch (typically Debug + Release).
79
+ """
80
+ pbx = _load_pbxproj(project_path)
81
+ objects = pbx["objects"]
82
+ targets: list[dict] = []
83
+ for obj in objects.values():
84
+ if obj.get("isa") != "PBXNativeTarget":
85
+ continue
86
+ if not _is_signable_product_type(obj.get("productType") or ""):
87
+ continue
88
+ name = obj.get("name") or "<unknown>"
89
+ config_list = objects.get(obj.get("buildConfigurationList")) or {}
90
+ config_ids = list(config_list.get("buildConfigurations") or [])
91
+ bundle_id = _bundle_id_from_configs(objects, config_ids)
92
+ if not bundle_id:
93
+ print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
94
+ continue
95
+ targets.append(
96
+ {"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
97
+ )
98
+ if not targets:
99
+ raise SystemExit(
100
+ f"discover_signable_targets: no signable targets found in "
101
+ f"{project_path}"
102
+ )
103
+ return targets
104
+
105
+
106
+ # --------------------------------------------------------------------------- #
107
+ # Mutation #
108
+ # --------------------------------------------------------------------------- #
109
+
110
+ def patch_project_signing(
111
+ project_path: str,
112
+ targets: list[dict],
113
+ team_id: str,
114
+ ) -> None:
115
+ """Set manual signing + per-target profile name for every target.
116
+
117
+ Edits the pbxproj as text so formatting (comments, ordering, header)
118
+ survives. Scope is strictly the XCBuildConfiguration blocks referenced
119
+ by the ``targets`` list. SwiftPM / resource-bundle configs stay put.
120
+ """
121
+ pbx_path = Path(project_path) / "project.pbxproj"
122
+ text = pbx_path.read_text(encoding="utf-8")
123
+ for target in targets:
124
+ profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
125
+ for cid in target["config_ids"]:
126
+ text = _apply_signing_to_config(text, cid, team_id, profile_name)
127
+ print(
128
+ f"Patched {target['name']!r} -> {profile_name} "
129
+ f"(configs={len(target['config_ids'])})"
130
+ )
131
+ pbx_path.write_text(text, encoding="utf-8")
132
+ subprocess.check_call(["plutil", "-lint", str(pbx_path)])
133
+
134
+
135
+ def _apply_signing_to_config(
136
+ text: str, config_id: str, team_id: str, profile_name: str
137
+ ) -> str:
138
+ start = text.find(f"\t\t{config_id} ")
139
+ if start < 0:
140
+ start = text.find(f"\t\t{config_id}\t")
141
+ if start < 0:
142
+ start = text.find(f"{config_id} = {{")
143
+ if start < 0:
144
+ raise SystemExit(
145
+ f"patch_project_signing: config {config_id} not found"
146
+ )
147
+ key = "buildSettings = {"
148
+ s_idx = text.find(key, start)
149
+ if s_idx < 0:
150
+ raise SystemExit(
151
+ f"patch_project_signing: buildSettings not found for {config_id}"
152
+ )
153
+ end = _match_brace(text, s_idx + len(key) - 1)
154
+ if end < 0:
155
+ raise SystemExit(
156
+ f"patch_project_signing: unbalanced buildSettings for {config_id}"
157
+ )
158
+ inner = text[s_idx + len(key): end]
159
+ patched = _patch_inner_settings(inner, team_id, profile_name)
160
+ return text[: s_idx + len(key)] + patched + text[end:]
161
+
162
+
163
+ def _match_brace(text: str, open_idx: int) -> int:
164
+ """Return the index of the ``}`` that closes the ``{`` at ``open_idx``."""
165
+ depth = 0
166
+ i = open_idx
167
+ while i < len(text):
168
+ ch = text[i]
169
+ if ch == "{":
170
+ depth += 1
171
+ elif ch == "}":
172
+ depth -= 1
173
+ if depth == 0:
174
+ return i
175
+ i += 1
176
+ return -1
177
+
178
+
179
+ def _patch_inner_settings(
180
+ inner: str, team_id: str, profile_name: str
181
+ ) -> str:
182
+ """Rewrite build-setting lines inside one buildSettings block."""
183
+ values = dict(_SIGNING_KEYS)
184
+ values["DEVELOPMENT_TEAM"] = team_id
185
+ values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'
186
+
187
+ lines = inner.splitlines(keepends=True)
188
+ emitted: set[str] = set()
189
+ out: list[str] = []
190
+ for line in lines:
191
+ m = _SETTING_LINE.match(line)
192
+ if not m:
193
+ out.append(line)
194
+ continue
195
+ indent, key_name = m.group(1), m.group(2)
196
+ if key_name in _STRIP_KEYS:
197
+ continue
198
+ if key_name in values:
199
+ out.append(f"{indent}{key_name} = {values[key_name]};\n")
200
+ emitted.add(key_name)
201
+ continue
202
+ out.append(line)
203
+
204
+ missing = [k for k in values if k not in emitted]
205
+ if missing:
206
+ indent = "\t\t\t\t"
207
+ for line in lines:
208
+ m = _SETTING_LINE.match(line)
209
+ if m:
210
+ indent = m.group(1)
211
+ break
212
+ tail = out[-1] if out else ""
213
+ new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
214
+ if tail.strip() == "":
215
+ out = out[:-1] + new_lines + [tail]
216
+ else:
217
+ out = out + new_lines
218
+ return "".join(out)
@@ -44,11 +44,8 @@ from cryptography.hazmat.primitives import hashes, serialization
44
44
  from cryptography.hazmat.primitives.asymmetric import rsa
45
45
  from cryptography.hazmat.primitives.serialization import pkcs12
46
46
  from cryptography.x509.oid import NameOID
47
- from profile_manager import (
48
- discover_signable_targets,
49
- patch_project_signing,
50
- provision_all_bundles,
51
- )
47
+ from pbxproj_editor import discover_signable_targets, patch_project_signing
48
+ from profile_manager import provision_all_bundles
52
49
 
53
50
 
54
51
  def env(name: str) -> str:
@@ -250,18 +247,37 @@ def main() -> None:
250
247
  private_key, csr_b64 = generate_key_and_csr()
251
248
  cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
252
249
 
253
- mappings = provision_all_bundles(token, bundle_ids, cert_id)
250
+ mappings, effective_team = provision_all_bundles(token, bundle_ids, cert_id)
254
251
  print("Profile map:")
255
252
  for bid, pname, uuid in mappings:
256
253
  print(f" {bid} -> {pname} ({uuid})")
257
254
 
258
- patch_project_signing(project, targets, team_id)
255
+ # The ASC API key is bound to a single developer team. Every profile it
256
+ # issues lives in THAT team, so Xcode's DEVELOPMENT_TEAM setting must
257
+ # match it exactly — otherwise the profile can't be matched to the
258
+ # target at archive time. If the ci.config.yaml team differs, warn and
259
+ # use the profile's team as the source of truth.
260
+ pbx_team = effective_team or team_id
261
+ if effective_team and effective_team != team_id:
262
+ print(
263
+ f"::warning::Config team {team_id} differs from ASC API key's "
264
+ f"team {effective_team}; patching pbxproj with {effective_team} "
265
+ "(the team that actually issued the provisioning profiles)."
266
+ )
267
+ patch_project_signing(project, targets, pbx_team)
259
268
 
260
269
  # Persist mapping for downstream Export IPA step (ExportOptions.plist
261
- # provisioningProfiles dict).
270
+ # provisioningProfiles dict). Store the effective team alongside so
271
+ # Export uses the same team Apple assigned to the profiles.
262
272
  map_path = Path(runner_temp) / "signing_map.json"
263
273
  map_path.write_text(
264
- json.dumps({bid: pname for bid, pname, _ in mappings}, indent=2)
274
+ json.dumps(
275
+ {
276
+ "team_id": pbx_team,
277
+ "profiles": {bid: pname for bid, pname, _ in mappings},
278
+ },
279
+ indent=2,
280
+ )
265
281
  )
266
282
  print(f"Wrote signing map to {map_path}")
267
283
 
@@ -1,260 +1,38 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Provisioning profile + bundleId helpers for multi-target iOS projects.
3
+ Provisioning profile lifecycle helpers for the iOS native TestFlight action.
4
4
 
5
- Handles:
5
+ Talks to the App Store Connect API to:
6
6
 
7
- * Enumerating every PRODUCT_BUNDLE_IDENTIFIER used by signable targets in an
8
- Xcode project. We parse ``project.pbxproj`` directly (via ``plutil -convert
9
- json``) rather than rely on ``xcodebuild -showBuildSettings``, which only
10
- covers what a given scheme builds. Parsing the pbxproj lets us find every
11
- native app + extension target without tripping over SwiftPM resource
12
- bundles (whose product types are not signable).
13
- * Ensuring each bundle ID is registered on App Store Connect.
14
- * Creating one IOS_APP_STORE provisioning profile per bundle ID, named
15
- ``CI-<bundle_id>``, linked to the just-issued distribution cert. If a
16
- profile with that name already exists, it is deleted first so we always
17
- end up with a profile that references the fresh cert.
18
- * Installing every profile into the standard Xcode profile directory.
19
- * Patching the pbxproj in place so each signable target's build settings use
20
- manual signing + the freshly-created profile. SwiftPM / resource bundle
21
- targets (which cannot carry provisioning profiles) are left untouched.
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).
22
13
 
23
- Kept separate from ``prepare_signing.py`` so both files stay well under the
24
- 400-line cap.
14
+ Xcode pbxproj editing lives in ``pbxproj_editor.py``; this file is
15
+ ASC-facing only.
25
16
  """
26
17
 
27
18
  from __future__ import annotations
28
19
 
29
20
  import base64
30
- import json
31
21
  import plistlib
32
- import re
33
22
  import subprocess
34
23
  from pathlib import Path
35
24
 
36
25
  from asc_common import get_json, request
26
+ from pbxproj_editor import PROFILE_PREFIX
37
27
 
38
28
 
39
- PROFILE_PREFIX = "CI-"
40
- PROFILES_DIR = Path.home() / "Library/MobileDevice/Provisioning Profiles"
41
-
42
- # Product types that must be signed with a provisioning profile. App
43
- # extensions share the common ``com.apple.product-type.app-extension`` prefix
44
- # (messages, widgets, NetworkExtension, WatchKit, etc).
45
- _SIGNABLE_PRODUCT_TYPES = {
46
- "com.apple.product-type.application",
47
- "com.apple.product-type.application.on-demand-install-capable",
48
- "com.apple.product-type.application.watchapp2",
49
- "com.apple.product-type.watchkit2-extension",
50
- }
51
- _SIGNABLE_PRODUCT_PREFIXES = (
52
- "com.apple.product-type.app-extension",
53
- )
54
-
55
-
56
- # --------------------------------------------------------------------------- #
57
- # pbxproj parsing #
58
- # --------------------------------------------------------------------------- #
59
-
60
- def _load_pbxproj(project_path: str) -> dict:
61
- pbx = Path(project_path) / "project.pbxproj"
62
- out = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(pbx)])
63
- return json.loads(out)
64
-
65
-
66
- def _is_signable_product_type(product_type: str) -> bool:
67
- if product_type in _SIGNABLE_PRODUCT_TYPES:
68
- return True
69
- return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
70
-
71
-
72
- def discover_signable_targets(project_path: str) -> list[dict]:
73
- """Return one entry per signable native target.
74
-
75
- Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
76
- where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
77
- ``buildSettings`` dict we need to patch (one per build configuration —
78
- typically Debug + Release).
79
- """
80
- pbx = _load_pbxproj(project_path)
81
- objects = pbx["objects"]
82
- targets: list[dict] = []
83
- for _obj_id, obj in objects.items():
84
- if obj.get("isa") != "PBXNativeTarget":
85
- continue
86
- product_type = obj.get("productType") or ""
87
- if not _is_signable_product_type(product_type):
88
- continue
89
- name = obj.get("name") or "<unknown>"
90
- config_list_id = obj.get("buildConfigurationList")
91
- config_list = objects.get(config_list_id) or {}
92
- config_ids = list(config_list.get("buildConfigurations") or [])
93
- bundle_id = _bundle_id_from_configs(objects, config_ids)
94
- if not bundle_id:
95
- print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
96
- continue
97
- targets.append(
98
- {"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
99
- )
100
- if not targets:
101
- raise SystemExit(
102
- f"discover_signable_targets: no signable targets found in {project_path}"
103
- )
104
- return targets
105
-
106
-
107
- def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
108
- """Return the first non-empty bundle id across the given configs."""
109
- for cid in config_ids:
110
- cfg = objects.get(cid) or {}
111
- settings = cfg.get("buildSettings") or {}
112
- bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
113
- if bid and "$(" not in bid and "${" not in bid:
114
- return bid
115
- return ""
116
-
117
-
118
- # --------------------------------------------------------------------------- #
119
- # pbxproj mutation #
120
- # --------------------------------------------------------------------------- #
121
-
122
- def patch_project_signing(
123
- project_path: str,
124
- targets: list[dict],
125
- team_id: str,
126
- ) -> None:
127
- """Set manual signing + per-target profile name for every signable target.
128
-
129
- The pbxproj stays in its original OpenStep format — Xcode is very
130
- opinionated about that file and any conversion (to XML/JSON) risks
131
- re-ordering keys or dropping comments. Instead, we edit the specific
132
- ``XCBuildConfiguration`` blocks by their UUID and inject/replace the
133
- handful of keys we care about. Targets outside ``targets`` (most
134
- importantly SwiftPM / resource-bundle configs) are untouched.
135
- """
136
- pbx_path = Path(project_path) / "project.pbxproj"
137
- text = pbx_path.read_text(encoding="utf-8")
138
-
139
- for target in targets:
140
- profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
141
- for cid in target["config_ids"]:
142
- text = _apply_signing_to_config(text, cid, team_id, profile_name)
143
- print(
144
- f"Patched {target['name']!r} -> {profile_name} "
145
- f"(configs={len(target['config_ids'])})"
146
- )
147
-
148
- pbx_path.write_text(text, encoding="utf-8")
149
- # Sanity check — plutil -lint rejects malformed output so CI fails
150
- # fast before ``xcodebuild`` tries (and mangles) the file.
151
- subprocess.check_call(["plutil", "-lint", str(pbx_path)])
152
-
153
-
154
- def _apply_signing_to_config(
155
- text: str, config_id: str, team_id: str, profile_name: str
156
- ) -> str:
157
- """Return ``text`` with the given XCBuildConfiguration's buildSettings
158
- patched for manual signing. Raises ``SystemExit`` if the config block
159
- can't be located (indicates a pbxproj format we don't understand).
160
- """
161
- start = text.find(f"\t\t{config_id} ")
162
- if start < 0:
163
- start = text.find(f"\t\t{config_id}\t")
164
- if start < 0:
165
- start = text.find(f"{config_id} = {{")
166
- if start < 0:
167
- raise SystemExit(
168
- f"patch_project_signing: config {config_id} not found in pbxproj"
169
- )
170
- settings_key = "buildSettings = {"
171
- s_idx = text.find(settings_key, start)
172
- if s_idx < 0:
173
- raise SystemExit(
174
- f"patch_project_signing: buildSettings not found for {config_id}"
175
- )
176
- # Find matching closing '};' for buildSettings — brace-balance forward.
177
- depth = 0
178
- i = s_idx + len(settings_key) - 1 # position at the opening '{'
179
- end = -1
180
- while i < len(text):
181
- ch = text[i]
182
- if ch == "{":
183
- depth += 1
184
- elif ch == "}":
185
- depth -= 1
186
- if depth == 0:
187
- end = i
188
- break
189
- i += 1
190
- if end < 0:
191
- raise SystemExit(
192
- f"patch_project_signing: unbalanced buildSettings for {config_id}"
193
- )
194
- inner = text[s_idx + len(settings_key): end]
195
- patched = _patch_inner_settings(inner, team_id, profile_name)
196
- return text[: s_idx + len(settings_key)] + patched + text[end:]
197
-
198
-
199
- _SIGNING_KEYS = {
200
- "CODE_SIGN_STYLE": "Manual",
201
- "CODE_SIGN_IDENTITY": '"Apple Distribution"',
202
- }
203
-
204
- _STRIP_KEYS = ("PROVISIONING_PROFILE",)
205
-
206
-
207
- def _patch_inner_settings(
208
- inner: str, team_id: str, profile_name: str
209
- ) -> str:
210
- """Rewrite build setting key/value lines inside one buildSettings block."""
211
- # Always-set values
212
- values = dict(_SIGNING_KEYS)
213
- values["DEVELOPMENT_TEAM"] = team_id
214
- values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'
215
-
216
- lines = inner.splitlines(keepends=True)
217
- emitted_keys: set[str] = set()
218
- out: list[str] = []
219
- # Each setting line looks like (whitespace-prefixed):
220
- # KEY = VALUE;
221
- # We match leading indent, the key, `= `, the rest.
222
- pattern = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")
223
-
224
- for line in lines:
225
- m = pattern.match(line)
226
- if not m:
227
- out.append(line)
228
- continue
229
- indent, key, _old_value = m.group(1), m.group(2), m.group(3)
230
- if key in _STRIP_KEYS:
231
- # Drop these keys entirely.
232
- continue
233
- if key in values:
234
- out.append(f"{indent}{key} = {values[key]};\n")
235
- emitted_keys.add(key)
236
- continue
237
- out.append(line)
238
-
239
- # Any key we wanted to set but didn't find — append just before close.
240
- missing = [k for k in values if k not in emitted_keys]
241
- if missing:
242
- # Determine indent from the first KEY= line we can find, else use
243
- # two tabs (the pbxproj default).
244
- indent = "\t\t\t\t"
245
- for line in lines:
246
- m = pattern.match(line)
247
- if m:
248
- indent = m.group(1)
249
- break
250
- tail = out[-1] if out else ""
251
- # Ensure we inject before the trailing whitespace on the last line.
252
- new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
253
- if tail.strip() == "":
254
- out = out[:-1] + new_lines + [tail]
255
- else:
256
- out = out + new_lines
257
- return "".join(out)
29
+ # Xcode 16+ moved the canonical profile directory. Older Xcode releases
30
+ # used ~/Library/MobileDevice/Provisioning Profiles/. Write to BOTH so the
31
+ # same script works on macos-14 (Xcode 15) and macos-15 (Xcode 26).
32
+ _PROFILE_DIRS = [
33
+ Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles",
34
+ Path.home() / "Library/MobileDevice/Provisioning Profiles",
35
+ ]
258
36
 
259
37
 
260
38
  # --------------------------------------------------------------------------- #
@@ -262,6 +40,7 @@ def _patch_inner_settings(
262
40
  # --------------------------------------------------------------------------- #
263
41
 
264
42
  def ensure_bundle_id(token: str, identifier: str) -> str:
43
+ """Return the ASC primary key for ``identifier``; register if missing."""
265
44
  data = get_json(
266
45
  "/bundleIds",
267
46
  token,
@@ -299,6 +78,7 @@ def profile_name_for(bundle_id: str) -> str:
299
78
 
300
79
 
301
80
  def delete_profile_by_name(token: str, name: str) -> None:
81
+ """Delete every existing profile with the given name (paginated scan)."""
302
82
  deleted_any = False
303
83
  next_path: str | None = "/profiles?limit=200"
304
84
  while next_path:
@@ -341,17 +121,37 @@ def create_profile(
341
121
  return base64.b64decode(payload)
342
122
 
343
123
 
344
- def install_profile(profile_der: bytes) -> str:
345
- PROFILES_DIR.mkdir(parents=True, exist_ok=True)
346
- tmp_path = PROFILES_DIR / "tmp.mobileprovision"
124
+ def install_profile(profile_der: bytes) -> tuple[str, str]:
125
+ """Install the raw profile into every Xcode-visible directory.
126
+
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.
132
+ """
133
+ primary = _PROFILE_DIRS[0]
134
+ primary.mkdir(parents=True, exist_ok=True)
135
+ tmp_path = primary / "tmp.mobileprovision"
347
136
  tmp_path.write_bytes(profile_der)
348
137
  decoded = subprocess.check_output(
349
138
  ["security", "cms", "-D", "-i", str(tmp_path)]
350
139
  )
351
- uuid = plistlib.loads(decoded)["UUID"]
352
- final_path = PROFILES_DIR / f"{uuid}.mobileprovision"
353
- tmp_path.rename(final_path)
354
- return uuid
140
+ plist = plistlib.loads(decoded)
141
+ uuid = plist["UUID"]
142
+ team_ids = plist.get("TeamIdentifier") or []
143
+ team_id = team_ids[0] if team_ids else ""
144
+ profile_name = plist.get("Name") or "<unknown>"
145
+ final_name = f"{uuid}.mobileprovision"
146
+ for directory in _PROFILE_DIRS:
147
+ directory.mkdir(parents=True, exist_ok=True)
148
+ (directory / final_name).write_bytes(profile_der)
149
+ tmp_path.unlink(missing_ok=True)
150
+ print(
151
+ f" profile {profile_name!r}: uuid={uuid} team={team_id} "
152
+ f"dirs={[str(d) for d in _PROFILE_DIRS]}"
153
+ )
154
+ return uuid, team_id
355
155
 
356
156
 
357
157
  # --------------------------------------------------------------------------- #
@@ -362,18 +162,24 @@ def provision_all_bundles(
362
162
  token: str,
363
163
  bundle_ids: list[str],
364
164
  cert_id: str,
365
- ) -> list[tuple[str, str, str]]:
165
+ ) -> tuple[list[tuple[str, str, str]], str]:
366
166
  """Create + install a CI profile for each bundle id.
367
167
 
368
- Returns a list of ``(bundle_id, profile_name, uuid)`` tuples.
168
+ Returns ``(mappings, team_id)`` where mappings is a list of
169
+ ``(bundle_id, profile_name, uuid)`` tuples and ``team_id`` is the
170
+ team Apple assigned to the profiles (shared — the ASC API key binds
171
+ every profile it issues to a single team).
369
172
  """
370
173
  results: list[tuple[str, str, str]] = []
174
+ effective_team = ""
371
175
  for bid in bundle_ids:
372
176
  name = profile_name_for(bid)
373
177
  bundle_pk = ensure_bundle_id(token, bid)
374
178
  delete_profile_by_name(token, name)
375
179
  profile_der = create_profile(token, name, bundle_pk, cert_id)
376
- uuid = install_profile(profile_der)
180
+ uuid, team_id = install_profile(profile_der)
181
+ if team_id:
182
+ effective_team = team_id
377
183
  print(f"Installed provisioning profile {name} -> {uuid} ({bid})")
378
184
  results.append((bid, name, uuid))
379
- return results
185
+ return results, effective_team