@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/scripts/ci/ios-native/pbxproj_editor.py +218 -0
- package/templates/scripts/ci/ios-native/prepare_signing.py +25 -9
- package/templates/scripts/ci/ios-native/profile_manager.py +38 -250
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
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.
|
|
15
|
+
"version": "0.10.83",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -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
|
|
48
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
3
|
+
Provisioning profile lifecycle helpers for the iOS native TestFlight action.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Talks to the App Store Connect API to:
|
|
6
6
|
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
#
|
|
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={
|
|
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
|
|
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
|