@daemux/store-automator 0.10.79 → 0.10.81
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.
|
|
8
|
+
"version": "0.10.81"
|
|
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.81",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -7,26 +7,24 @@ Steps:
|
|
|
7
7
|
1. Generate an RSA private key + CSR.
|
|
8
8
|
2. Create a new Apple Distribution certificate from the CSR; if the per-team
|
|
9
9
|
cap is hit (409), revoke the newest existing DISTRIBUTION cert and retry.
|
|
10
|
-
3.
|
|
11
|
-
|
|
10
|
+
3. Discover every signable native target in the Xcode project (main app +
|
|
11
|
+
extensions like Network Extensions, Widgets, WatchKit apps). SwiftPM
|
|
12
|
+
resource bundles are skipped — they can't be signed manually.
|
|
12
13
|
4. Register each bundle ID on App Store Connect if missing.
|
|
13
14
|
5. Create one IOS_APP_STORE provisioning profile per bundle ID, named
|
|
14
15
|
``CI-<bundle_id>``, linked to the new cert (deleting any stale profile
|
|
15
16
|
with the same name so the reference matches the fresh cert).
|
|
16
17
|
6. Install every profile into ``~/Library/MobileDevice/Provisioning Profiles/``.
|
|
17
|
-
7.
|
|
18
|
+
7. Patch the pbxproj in place so each signable target's build settings
|
|
19
|
+
select manual signing + the matching CI profile. SwiftPM targets are
|
|
20
|
+
left alone — they keep their default automatic (no-sign) config.
|
|
21
|
+
8. Import cert + private key into a temporary keychain placed at the head of
|
|
18
22
|
the user search list so codesign / xcodebuild can find it.
|
|
19
23
|
|
|
20
|
-
Pairs with a temporary xcconfig that sets
|
|
21
|
-
``PROVISIONING_PROFILE_SPECIFIER = CI-$(PRODUCT_BUNDLE_IDENTIFIER)``, which
|
|
22
|
-
Xcode resolves per target at build time. This lets apps with Network
|
|
23
|
-
Extensions (and any other aux targets) sign manually in CI without
|
|
24
|
-
per-project pbxproj edits.
|
|
25
|
-
|
|
26
24
|
Environment inputs:
|
|
27
25
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH - App Store Connect API key trio
|
|
28
|
-
PROJECT
|
|
29
|
-
|
|
26
|
+
PROJECT - Xcode project path (.xcodeproj)
|
|
27
|
+
TEAM_ID - Apple developer team identifier
|
|
30
28
|
RUNNER_TEMP - GitHub Actions temp dir
|
|
31
29
|
|
|
32
30
|
Writes nothing to stdout that would leak secrets.
|
|
@@ -46,7 +44,11 @@ from cryptography.hazmat.primitives import hashes, serialization
|
|
|
46
44
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
47
45
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
48
46
|
from cryptography.x509.oid import NameOID
|
|
49
|
-
from profile_manager import
|
|
47
|
+
from profile_manager import (
|
|
48
|
+
discover_signable_targets,
|
|
49
|
+
patch_project_signing,
|
|
50
|
+
provision_all_bundles,
|
|
51
|
+
)
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def env(name: str) -> str:
|
|
@@ -61,7 +63,6 @@ def optional_env(name: str) -> str:
|
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
64
|
-
"""Return (private_key, csr_payload_b64_bytes)."""
|
|
65
66
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
66
67
|
csr = (
|
|
67
68
|
x509.CertificateSigningRequestBuilder()
|
|
@@ -83,7 +84,6 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def newest_distribution_cert_id(token: str) -> str | None:
|
|
86
|
-
"""Return the ID of the most-recently-created DISTRIBUTION cert, or None."""
|
|
87
87
|
data = get_json(
|
|
88
88
|
"/certificates",
|
|
89
89
|
token,
|
|
@@ -105,11 +105,6 @@ def newest_distribution_cert_id(token: str) -> str | None:
|
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
108
|
-
"""Return (certificate_id, DER-encoded certificate bytes).
|
|
109
|
-
|
|
110
|
-
Apple enforces a per-team cap on active Distribution certs. If the POST
|
|
111
|
-
comes back 409, revoke the newest existing one and retry once.
|
|
112
|
-
"""
|
|
113
108
|
body = {
|
|
114
109
|
"data": {
|
|
115
110
|
"type": "certificates",
|
|
@@ -231,20 +226,26 @@ def main() -> None:
|
|
|
231
226
|
issuer_id = env("ASC_ISSUER_ID")
|
|
232
227
|
asc_key_path = env("ASC_KEY_PATH")
|
|
233
228
|
runner_temp = env("RUNNER_TEMP")
|
|
229
|
+
team_id = env("TEAM_ID")
|
|
234
230
|
|
|
235
231
|
project = optional_env("PROJECT")
|
|
236
232
|
workspace = optional_env("WORKSPACE")
|
|
237
|
-
|
|
238
|
-
configuration = env("CONFIGURATION")
|
|
239
|
-
if not project and not workspace:
|
|
233
|
+
if workspace and not project:
|
|
240
234
|
raise SystemExit(
|
|
241
|
-
"prepare_signing:
|
|
235
|
+
"prepare_signing: WORKSPACE-only mode is not supported; the "
|
|
236
|
+
"underlying .xcodeproj must be passed via PROJECT so we can "
|
|
237
|
+
"patch its signing settings."
|
|
242
238
|
)
|
|
239
|
+
if not project:
|
|
240
|
+
raise SystemExit("prepare_signing: PROJECT env var is required")
|
|
243
241
|
|
|
244
242
|
token = make_jwt(key_id, issuer_id, asc_key_path)
|
|
245
243
|
|
|
246
|
-
|
|
247
|
-
|
|
244
|
+
targets = discover_signable_targets(project)
|
|
245
|
+
bundle_ids = sorted({t["bundle_id"] for t in targets})
|
|
246
|
+
print(f"Signable targets ({len(targets)}):")
|
|
247
|
+
for t in targets:
|
|
248
|
+
print(f" - {t['name']} -> {t['bundle_id']} ({len(t['config_ids'])} configs)")
|
|
248
249
|
|
|
249
250
|
private_key, csr_b64 = generate_key_and_csr()
|
|
250
251
|
cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
|
|
@@ -254,9 +255,10 @@ def main() -> None:
|
|
|
254
255
|
for bid, pname, uuid in mappings:
|
|
255
256
|
print(f" {bid} -> {pname} ({uuid})")
|
|
256
257
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
#
|
|
258
|
+
patch_project_signing(project, targets, team_id)
|
|
259
|
+
|
|
260
|
+
# Persist mapping for downstream Export IPA step (ExportOptions.plist
|
|
261
|
+
# provisioningProfiles dict).
|
|
260
262
|
map_path = Path(runner_temp) / "signing_map.json"
|
|
261
263
|
map_path.write_text(
|
|
262
264
|
json.dumps({bid: pname for bid, pname, _ in mappings}, indent=2)
|
|
@@ -5,14 +5,20 @@ Provisioning profile + bundleId helpers for multi-target iOS projects.
|
|
|
5
5
|
Handles:
|
|
6
6
|
|
|
7
7
|
* Enumerating every PRODUCT_BUNDLE_IDENTIFIER used by signable targets in an
|
|
8
|
-
Xcode project
|
|
9
|
-
|
|
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).
|
|
10
13
|
* Ensuring each bundle ID is registered on App Store Connect.
|
|
11
14
|
* Creating one IOS_APP_STORE provisioning profile per bundle ID, named
|
|
12
15
|
``CI-<bundle_id>``, linked to the just-issued distribution cert. If a
|
|
13
16
|
profile with that name already exists, it is deleted first so we always
|
|
14
17
|
end up with a profile that references the fresh cert.
|
|
15
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.
|
|
16
22
|
|
|
17
23
|
Kept separate from ``prepare_signing.py`` so both files stay well under the
|
|
18
24
|
400-line cap.
|
|
@@ -23,6 +29,7 @@ from __future__ import annotations
|
|
|
23
29
|
import base64
|
|
24
30
|
import json
|
|
25
31
|
import plistlib
|
|
32
|
+
import re
|
|
26
33
|
import subprocess
|
|
27
34
|
from pathlib import Path
|
|
28
35
|
|
|
@@ -32,62 +39,222 @@ from asc_common import get_json, request
|
|
|
32
39
|
PROFILE_PREFIX = "CI-"
|
|
33
40
|
PROFILES_DIR = Path.home() / "Library/MobileDevice/Provisioning Profiles"
|
|
34
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
|
+
|
|
35
55
|
|
|
36
56
|
# --------------------------------------------------------------------------- #
|
|
37
|
-
#
|
|
57
|
+
# pbxproj parsing #
|
|
38
58
|
# --------------------------------------------------------------------------- #
|
|
39
59
|
|
|
40
|
-
def
|
|
41
|
-
project
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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).
|
|
52
79
|
"""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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:
|
|
73
101
|
raise SystemExit(
|
|
74
|
-
"
|
|
75
|
-
f"for scheme {scheme!r} (configuration={configuration!r})"
|
|
102
|
+
f"discover_signable_targets: no signable targets found in {project_path}"
|
|
76
103
|
)
|
|
77
|
-
return
|
|
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 ""
|
|
78
116
|
|
|
79
117
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
|
91
258
|
|
|
92
259
|
|
|
93
260
|
# --------------------------------------------------------------------------- #
|
|
@@ -95,7 +262,6 @@ def _is_real_bundle_id(value: str) -> bool:
|
|
|
95
262
|
# --------------------------------------------------------------------------- #
|
|
96
263
|
|
|
97
264
|
def ensure_bundle_id(token: str, identifier: str) -> str:
|
|
98
|
-
"""Return the ASC primary key for ``identifier``; register if missing."""
|
|
99
265
|
data = get_json(
|
|
100
266
|
"/bundleIds",
|
|
101
267
|
token,
|
|
@@ -129,12 +295,10 @@ def _register_bundle_id(token: str, identifier: str) -> str:
|
|
|
129
295
|
# --------------------------------------------------------------------------- #
|
|
130
296
|
|
|
131
297
|
def profile_name_for(bundle_id: str) -> str:
|
|
132
|
-
"""Return the deterministic CI profile name for a bundle id."""
|
|
133
298
|
return f"{PROFILE_PREFIX}{bundle_id}"
|
|
134
299
|
|
|
135
300
|
|
|
136
301
|
def delete_profile_by_name(token: str, name: str) -> None:
|
|
137
|
-
"""Delete every existing profile with the given name (paginated scan)."""
|
|
138
302
|
deleted_any = False
|
|
139
303
|
next_path: str | None = "/profiles?limit=200"
|
|
140
304
|
while next_path:
|
|
@@ -148,7 +312,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
|
|
|
148
312
|
next_link = (data.get("links") or {}).get("next")
|
|
149
313
|
if not next_link:
|
|
150
314
|
break
|
|
151
|
-
# Convert absolute next URL to path component the helper expects.
|
|
152
315
|
idx = next_link.find("/v1")
|
|
153
316
|
next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
|
|
154
317
|
if not deleted_any:
|
|
@@ -158,7 +321,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
|
|
|
158
321
|
def create_profile(
|
|
159
322
|
token: str, name: str, bundle_pk: str, cert_id: str
|
|
160
323
|
) -> bytes:
|
|
161
|
-
"""Create an IOS_APP_STORE profile and return its raw (CMS-signed) bytes."""
|
|
162
324
|
body = {
|
|
163
325
|
"data": {
|
|
164
326
|
"type": "profiles",
|
|
@@ -180,7 +342,6 @@ def create_profile(
|
|
|
180
342
|
|
|
181
343
|
|
|
182
344
|
def install_profile(profile_der: bytes) -> str:
|
|
183
|
-
"""Write the .mobileprovision into Xcode's profile dir; return UUID."""
|
|
184
345
|
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|
185
346
|
tmp_path = PROFILES_DIR / "tmp.mobileprovision"
|
|
186
347
|
tmp_path.write_bytes(profile_der)
|
|
@@ -204,8 +365,7 @@ def provision_all_bundles(
|
|
|
204
365
|
) -> list[tuple[str, str, str]]:
|
|
205
366
|
"""Create + install a CI profile for each bundle id.
|
|
206
367
|
|
|
207
|
-
Returns a list of ``(bundle_id, profile_name, uuid)`` tuples
|
|
208
|
-
order so callers can log the full mapping.
|
|
368
|
+
Returns a list of ``(bundle_id, profile_name, uuid)`` tuples.
|
|
209
369
|
"""
|
|
210
370
|
results: list[tuple[str, str, str]] = []
|
|
211
371
|
for bid in bundle_ids:
|