@daemux/store-automator 0.10.82 → 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.82"
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.82",
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.82",
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.82",
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,274 +1,46 @@
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
-
41
- # Xcode 16+ moved the canonical profile directory. Older Xcode releases used
42
- # ~/Library/MobileDevice/Provisioning Profiles/. We write to BOTH so the same
43
- # script works on macos-14 (Xcode 15) and macos-15 (Xcode 26).
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).
44
32
  _PROFILE_DIRS = [
45
33
  Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles",
46
34
  Path.home() / "Library/MobileDevice/Provisioning Profiles",
47
35
  ]
48
36
 
49
- # Product types that must be signed with a provisioning profile. App
50
- # extensions share the common ``com.apple.product-type.app-extension`` prefix
51
- # (messages, widgets, NetworkExtension, WatchKit, etc).
52
- _SIGNABLE_PRODUCT_TYPES = {
53
- "com.apple.product-type.application",
54
- "com.apple.product-type.application.on-demand-install-capable",
55
- "com.apple.product-type.application.watchapp2",
56
- "com.apple.product-type.watchkit2-extension",
57
- }
58
- _SIGNABLE_PRODUCT_PREFIXES = (
59
- "com.apple.product-type.app-extension",
60
- )
61
-
62
-
63
- # --------------------------------------------------------------------------- #
64
- # pbxproj parsing #
65
- # --------------------------------------------------------------------------- #
66
-
67
- def _load_pbxproj(project_path: str) -> dict:
68
- pbx = Path(project_path) / "project.pbxproj"
69
- out = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(pbx)])
70
- return json.loads(out)
71
-
72
-
73
- def _is_signable_product_type(product_type: str) -> bool:
74
- if product_type in _SIGNABLE_PRODUCT_TYPES:
75
- return True
76
- return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
77
-
78
-
79
- def discover_signable_targets(project_path: str) -> list[dict]:
80
- """Return one entry per signable native target.
81
-
82
- Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
83
- where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
84
- ``buildSettings`` dict we need to patch (one per build configuration —
85
- typically Debug + Release).
86
- """
87
- pbx = _load_pbxproj(project_path)
88
- objects = pbx["objects"]
89
- targets: list[dict] = []
90
- for _obj_id, obj in objects.items():
91
- if obj.get("isa") != "PBXNativeTarget":
92
- continue
93
- product_type = obj.get("productType") or ""
94
- if not _is_signable_product_type(product_type):
95
- continue
96
- name = obj.get("name") or "<unknown>"
97
- config_list_id = obj.get("buildConfigurationList")
98
- config_list = objects.get(config_list_id) or {}
99
- config_ids = list(config_list.get("buildConfigurations") or [])
100
- bundle_id = _bundle_id_from_configs(objects, config_ids)
101
- if not bundle_id:
102
- print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
103
- continue
104
- targets.append(
105
- {"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
106
- )
107
- if not targets:
108
- raise SystemExit(
109
- f"discover_signable_targets: no signable targets found in {project_path}"
110
- )
111
- return targets
112
-
113
-
114
- def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
115
- """Return the first non-empty bundle id across the given configs."""
116
- for cid in config_ids:
117
- cfg = objects.get(cid) or {}
118
- settings = cfg.get("buildSettings") or {}
119
- bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
120
- if bid and "$(" not in bid and "${" not in bid:
121
- return bid
122
- return ""
123
-
124
-
125
- # --------------------------------------------------------------------------- #
126
- # pbxproj mutation #
127
- # --------------------------------------------------------------------------- #
128
-
129
- def patch_project_signing(
130
- project_path: str,
131
- targets: list[dict],
132
- team_id: str,
133
- ) -> None:
134
- """Set manual signing + per-target profile name for every signable target.
135
-
136
- The pbxproj stays in its original OpenStep format — Xcode is very
137
- opinionated about that file and any conversion (to XML/JSON) risks
138
- re-ordering keys or dropping comments. Instead, we edit the specific
139
- ``XCBuildConfiguration`` blocks by their UUID and inject/replace the
140
- handful of keys we care about. Targets outside ``targets`` (most
141
- importantly SwiftPM / resource-bundle configs) are untouched.
142
- """
143
- pbx_path = Path(project_path) / "project.pbxproj"
144
- text = pbx_path.read_text(encoding="utf-8")
145
-
146
- for target in targets:
147
- profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
148
- for cid in target["config_ids"]:
149
- text = _apply_signing_to_config(text, cid, team_id, profile_name)
150
- print(
151
- f"Patched {target['name']!r} -> {profile_name} "
152
- f"(configs={len(target['config_ids'])})"
153
- )
154
-
155
- pbx_path.write_text(text, encoding="utf-8")
156
- # Sanity check — plutil -lint rejects malformed output so CI fails
157
- # fast before ``xcodebuild`` tries (and mangles) the file.
158
- subprocess.check_call(["plutil", "-lint", str(pbx_path)])
159
-
160
-
161
- def _apply_signing_to_config(
162
- text: str, config_id: str, team_id: str, profile_name: str
163
- ) -> str:
164
- """Return ``text`` with the given XCBuildConfiguration's buildSettings
165
- patched for manual signing. Raises ``SystemExit`` if the config block
166
- can't be located (indicates a pbxproj format we don't understand).
167
- """
168
- start = text.find(f"\t\t{config_id} ")
169
- if start < 0:
170
- start = text.find(f"\t\t{config_id}\t")
171
- if start < 0:
172
- start = text.find(f"{config_id} = {{")
173
- if start < 0:
174
- raise SystemExit(
175
- f"patch_project_signing: config {config_id} not found in pbxproj"
176
- )
177
- settings_key = "buildSettings = {"
178
- s_idx = text.find(settings_key, start)
179
- if s_idx < 0:
180
- raise SystemExit(
181
- f"patch_project_signing: buildSettings not found for {config_id}"
182
- )
183
- # Find matching closing '};' for buildSettings — brace-balance forward.
184
- depth = 0
185
- i = s_idx + len(settings_key) - 1 # position at the opening '{'
186
- end = -1
187
- while i < len(text):
188
- ch = text[i]
189
- if ch == "{":
190
- depth += 1
191
- elif ch == "}":
192
- depth -= 1
193
- if depth == 0:
194
- end = i
195
- break
196
- i += 1
197
- if end < 0:
198
- raise SystemExit(
199
- f"patch_project_signing: unbalanced buildSettings for {config_id}"
200
- )
201
- inner = text[s_idx + len(settings_key): end]
202
- patched = _patch_inner_settings(inner, team_id, profile_name)
203
- return text[: s_idx + len(settings_key)] + patched + text[end:]
204
-
205
-
206
- _SIGNING_KEYS = {
207
- "CODE_SIGN_STYLE": "Manual",
208
- "CODE_SIGN_IDENTITY": '"Apple Distribution"',
209
- }
210
-
211
- _STRIP_KEYS = ("PROVISIONING_PROFILE",)
212
-
213
-
214
- def _patch_inner_settings(
215
- inner: str, team_id: str, profile_name: str
216
- ) -> str:
217
- """Rewrite build setting key/value lines inside one buildSettings block."""
218
- # Always-set values
219
- values = dict(_SIGNING_KEYS)
220
- values["DEVELOPMENT_TEAM"] = team_id
221
- values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'
222
-
223
- lines = inner.splitlines(keepends=True)
224
- emitted_keys: set[str] = set()
225
- out: list[str] = []
226
- # Each setting line looks like (whitespace-prefixed):
227
- # KEY = VALUE;
228
- # We match leading indent, the key, `= `, the rest.
229
- pattern = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")
230
-
231
- for line in lines:
232
- m = pattern.match(line)
233
- if not m:
234
- out.append(line)
235
- continue
236
- indent, key, _old_value = m.group(1), m.group(2), m.group(3)
237
- if key in _STRIP_KEYS:
238
- # Drop these keys entirely.
239
- continue
240
- if key in values:
241
- out.append(f"{indent}{key} = {values[key]};\n")
242
- emitted_keys.add(key)
243
- continue
244
- out.append(line)
245
-
246
- # Any key we wanted to set but didn't find — append just before close.
247
- missing = [k for k in values if k not in emitted_keys]
248
- if missing:
249
- # Determine indent from the first KEY= line we can find, else use
250
- # two tabs (the pbxproj default).
251
- indent = "\t\t\t\t"
252
- for line in lines:
253
- m = pattern.match(line)
254
- if m:
255
- indent = m.group(1)
256
- break
257
- tail = out[-1] if out else ""
258
- # Ensure we inject before the trailing whitespace on the last line.
259
- new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
260
- if tail.strip() == "":
261
- out = out[:-1] + new_lines + [tail]
262
- else:
263
- out = out + new_lines
264
- return "".join(out)
265
-
266
37
 
267
38
  # --------------------------------------------------------------------------- #
268
39
  # ASC bundle ID registration #
269
40
  # --------------------------------------------------------------------------- #
270
41
 
271
42
  def ensure_bundle_id(token: str, identifier: str) -> str:
43
+ """Return the ASC primary key for ``identifier``; register if missing."""
272
44
  data = get_json(
273
45
  "/bundleIds",
274
46
  token,
@@ -306,6 +78,7 @@ def profile_name_for(bundle_id: str) -> str:
306
78
 
307
79
 
308
80
  def delete_profile_by_name(token: str, name: str) -> None:
81
+ """Delete every existing profile with the given name (paginated scan)."""
309
82
  deleted_any = False
310
83
  next_path: str | None = "/profiles?limit=200"
311
84
  while next_path:
@@ -348,7 +121,15 @@ def create_profile(
348
121
  return base64.b64decode(payload)
349
122
 
350
123
 
351
- def install_profile(profile_der: bytes) -> str:
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
+ """
352
133
  primary = _PROFILE_DIRS[0]
353
134
  primary.mkdir(parents=True, exist_ok=True)
354
135
  tmp_path = primary / "tmp.mobileprovision"
@@ -359,6 +140,7 @@ def install_profile(profile_der: bytes) -> str:
359
140
  plist = plistlib.loads(decoded)
360
141
  uuid = plist["UUID"]
361
142
  team_ids = plist.get("TeamIdentifier") or []
143
+ team_id = team_ids[0] if team_ids else ""
362
144
  profile_name = plist.get("Name") or "<unknown>"
363
145
  final_name = f"{uuid}.mobileprovision"
364
146
  for directory in _PROFILE_DIRS:
@@ -366,10 +148,10 @@ def install_profile(profile_der: bytes) -> str:
366
148
  (directory / final_name).write_bytes(profile_der)
367
149
  tmp_path.unlink(missing_ok=True)
368
150
  print(
369
- f" profile {profile_name!r}: uuid={uuid} team={team_ids} "
151
+ f" profile {profile_name!r}: uuid={uuid} team={team_id} "
370
152
  f"dirs={[str(d) for d in _PROFILE_DIRS]}"
371
153
  )
372
- return uuid
154
+ return uuid, team_id
373
155
 
374
156
 
375
157
  # --------------------------------------------------------------------------- #
@@ -380,18 +162,24 @@ def provision_all_bundles(
380
162
  token: str,
381
163
  bundle_ids: list[str],
382
164
  cert_id: str,
383
- ) -> list[tuple[str, str, str]]:
165
+ ) -> tuple[list[tuple[str, str, str]], str]:
384
166
  """Create + install a CI profile for each bundle id.
385
167
 
386
- 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).
387
172
  """
388
173
  results: list[tuple[str, str, str]] = []
174
+ effective_team = ""
389
175
  for bid in bundle_ids:
390
176
  name = profile_name_for(bid)
391
177
  bundle_pk = ensure_bundle_id(token, bid)
392
178
  delete_profile_by_name(token, name)
393
179
  profile_der = create_profile(token, name, bundle_pk, cert_id)
394
- uuid = install_profile(profile_der)
180
+ uuid, team_id = install_profile(profile_der)
181
+ if team_id:
182
+ effective_team = team_id
395
183
  print(f"Installed provisioning profile {name} -> {uuid} ({bid})")
396
184
  results.append((bid, name, uuid))
397
- return results
185
+ return results, effective_team