@daemux/store-automator 0.10.77 → 0.10.79
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.79"
|
|
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.79",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Prepare iOS code signing on a fresh CI runner (
|
|
3
|
+
Prepare iOS code signing on a fresh CI runner (manual-signing variant).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
`-allowProvisioningUpdates` against the App Store Connect API:
|
|
5
|
+
Steps:
|
|
7
6
|
|
|
8
|
-
1. Generate an RSA private key + CSR
|
|
7
|
+
1. Generate an RSA private key + CSR.
|
|
9
8
|
2. Create a new Apple Distribution certificate from the CSR; if the per-team
|
|
10
9
|
cap is hit (409), revoke the newest existing DISTRIBUTION cert and retry.
|
|
11
|
-
3.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
3. Enumerate PRODUCT_BUNDLE_IDENTIFIER for every signable target in the
|
|
11
|
+
Xcode project / workspace (main app + extensions).
|
|
12
|
+
4. Register each bundle ID on App Store Connect if missing.
|
|
13
|
+
5. Create one IOS_APP_STORE provisioning profile per bundle ID, named
|
|
14
|
+
``CI-<bundle_id>``, linked to the new cert (deleting any stale profile
|
|
15
|
+
with the same name so the reference matches the fresh cert).
|
|
16
|
+
6. Install every profile into ``~/Library/MobileDevice/Provisioning Profiles/``.
|
|
17
|
+
7. Import cert + private key into a temporary keychain placed at the head of
|
|
18
|
+
the user search list so codesign / xcodebuild can find it.
|
|
19
|
+
|
|
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.
|
|
18
25
|
|
|
19
26
|
Environment inputs:
|
|
20
|
-
ASC_KEY_ID
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
RUNNER_TEMP
|
|
27
|
+
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH - App Store Connect API key trio
|
|
28
|
+
PROJECT or WORKSPACE - Xcode project / workspace path
|
|
29
|
+
SCHEME, CONFIGURATION - Scheme + configuration to inspect
|
|
30
|
+
RUNNER_TEMP - GitHub Actions temp dir
|
|
24
31
|
|
|
25
32
|
Writes nothing to stdout that would leak secrets.
|
|
26
33
|
"""
|
|
@@ -28,6 +35,7 @@ Writes nothing to stdout that would leak secrets.
|
|
|
28
35
|
from __future__ import annotations
|
|
29
36
|
|
|
30
37
|
import base64
|
|
38
|
+
import json
|
|
31
39
|
import os
|
|
32
40
|
import subprocess
|
|
33
41
|
from pathlib import Path
|
|
@@ -38,6 +46,7 @@ from cryptography.hazmat.primitives import hashes, serialization
|
|
|
38
46
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
39
47
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
40
48
|
from cryptography.x509.oid import NameOID
|
|
49
|
+
from profile_manager import discover_bundle_ids, provision_all_bundles
|
|
41
50
|
|
|
42
51
|
|
|
43
52
|
def env(name: str) -> str:
|
|
@@ -47,6 +56,10 @@ def env(name: str) -> str:
|
|
|
47
56
|
return val
|
|
48
57
|
|
|
49
58
|
|
|
59
|
+
def optional_env(name: str) -> str:
|
|
60
|
+
return os.environ.get(name, "") or ""
|
|
61
|
+
|
|
62
|
+
|
|
50
63
|
def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
51
64
|
"""Return (private_key, csr_payload_b64_bytes)."""
|
|
52
65
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
@@ -55,14 +68,13 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
|
55
68
|
.subject_name(
|
|
56
69
|
x509.Name(
|
|
57
70
|
[
|
|
58
|
-
x509.NameAttribute(NameOID.COMMON_NAME, "
|
|
71
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "Daemux CI"),
|
|
59
72
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
60
73
|
]
|
|
61
74
|
)
|
|
62
75
|
)
|
|
63
76
|
.sign(private_key, hashes.SHA256())
|
|
64
77
|
)
|
|
65
|
-
# Apple wants the CSR PEM base64 (without headers)
|
|
66
78
|
csr_pem = csr.public_bytes(serialization.Encoding.PEM)
|
|
67
79
|
csr_payload = b"".join(
|
|
68
80
|
line for line in csr_pem.splitlines() if not line.startswith(b"-----")
|
|
@@ -85,8 +97,6 @@ def newest_distribution_cert_id(token: str) -> str | None:
|
|
|
85
97
|
newest_exp = ""
|
|
86
98
|
for cert in data.get("data", []):
|
|
87
99
|
attrs = cert.get("attributes") or {}
|
|
88
|
-
# Use expirationDate as a proxy for creation time (cert expiration
|
|
89
|
-
# is exactly creation + 1 year for Apple Distribution certs).
|
|
90
100
|
exp = attrs.get("expirationDate") or ""
|
|
91
101
|
if exp > newest_exp:
|
|
92
102
|
newest_exp = exp
|
|
@@ -94,17 +104,11 @@ def newest_distribution_cert_id(token: str) -> str | None:
|
|
|
94
104
|
return newest_id
|
|
95
105
|
|
|
96
106
|
|
|
97
|
-
def revoke_cert(token: str, cert_id: str) -> None:
|
|
98
|
-
print(f"Revoking distribution cert {cert_id}")
|
|
99
|
-
request("DELETE", f"/certificates/{cert_id}", token)
|
|
100
|
-
|
|
101
|
-
|
|
102
107
|
def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
103
108
|
"""Return (certificate_id, DER-encoded certificate bytes).
|
|
104
109
|
|
|
105
110
|
Apple enforces a per-team cap on active Distribution certs. If the POST
|
|
106
|
-
comes back 409
|
|
107
|
-
one and retry once. This is the CI-owned cert from a prior run.
|
|
111
|
+
comes back 409, revoke the newest existing one and retry once.
|
|
108
112
|
"""
|
|
109
113
|
body = {
|
|
110
114
|
"data": {
|
|
@@ -126,12 +130,11 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
|
126
130
|
"409 from cert create but no existing DISTRIBUTION cert "
|
|
127
131
|
"found to revoke"
|
|
128
132
|
)
|
|
129
|
-
|
|
133
|
+
request("DELETE", f"/certificates/{target}", token)
|
|
130
134
|
resp = request("POST", "/certificates", token, json_body=body)
|
|
131
135
|
data = resp.json()["data"]
|
|
132
136
|
cert_id = data["id"]
|
|
133
|
-
|
|
134
|
-
cert_der = base64.b64decode(cert_content_b64)
|
|
137
|
+
cert_der = base64.b64decode(data["attributes"]["certificateContent"])
|
|
135
138
|
print(f"Created DISTRIBUTION cert {cert_id}")
|
|
136
139
|
return cert_id, cert_der
|
|
137
140
|
|
|
@@ -141,7 +144,7 @@ def write_p12(
|
|
|
141
144
|
) -> None:
|
|
142
145
|
cert = x509.load_der_x509_certificate(cert_der)
|
|
143
146
|
p12 = pkcs12.serialize_key_and_certificates(
|
|
144
|
-
name=b"
|
|
147
|
+
name=b"Daemux CI",
|
|
145
148
|
key=private_key,
|
|
146
149
|
cert=cert,
|
|
147
150
|
cas=None,
|
|
@@ -153,7 +156,6 @@ def write_p12(
|
|
|
153
156
|
|
|
154
157
|
|
|
155
158
|
def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
|
|
156
|
-
"""Delete any stale keychain at this path, then create + unlock a fresh one."""
|
|
157
159
|
subprocess.run(
|
|
158
160
|
["security", "delete-keychain", keychain_path],
|
|
159
161
|
check=False,
|
|
@@ -173,18 +175,16 @@ def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
|
|
|
173
175
|
def _import_p12(
|
|
174
176
|
keychain_path: str, keychain_pass: str, p12_path: Path, p12_pass: str
|
|
175
177
|
) -> None:
|
|
176
|
-
"""Import p12 into the keychain and authorize codesign to use the key."""
|
|
177
178
|
subprocess.check_call(
|
|
178
179
|
[
|
|
179
180
|
"security", "import", str(p12_path),
|
|
180
181
|
"-P", p12_pass,
|
|
181
|
-
"-A",
|
|
182
|
+
"-A",
|
|
182
183
|
"-t", "cert",
|
|
183
184
|
"-f", "pkcs12",
|
|
184
185
|
"-k", keychain_path,
|
|
185
186
|
]
|
|
186
187
|
)
|
|
187
|
-
# Allow codesign to use the key without prompts (modern macOS)
|
|
188
188
|
subprocess.check_call(
|
|
189
189
|
[
|
|
190
190
|
"security", "set-key-partition-list",
|
|
@@ -197,7 +197,6 @@ def _import_p12(
|
|
|
197
197
|
|
|
198
198
|
|
|
199
199
|
def _prepend_to_user_search_list(keychain_path: str) -> None:
|
|
200
|
-
"""Put `keychain_path` at the head of the user search list + make it default."""
|
|
201
200
|
existing = subprocess.check_output(
|
|
202
201
|
["security", "list-keychains", "-d", "user"]
|
|
203
202
|
).decode()
|
|
@@ -233,14 +232,37 @@ def main() -> None:
|
|
|
233
232
|
asc_key_path = env("ASC_KEY_PATH")
|
|
234
233
|
runner_temp = env("RUNNER_TEMP")
|
|
235
234
|
|
|
235
|
+
project = optional_env("PROJECT")
|
|
236
|
+
workspace = optional_env("WORKSPACE")
|
|
237
|
+
scheme = env("SCHEME")
|
|
238
|
+
configuration = env("CONFIGURATION")
|
|
239
|
+
if not project and not workspace:
|
|
240
|
+
raise SystemExit(
|
|
241
|
+
"prepare_signing: either PROJECT or WORKSPACE env var must be set"
|
|
242
|
+
)
|
|
243
|
+
|
|
236
244
|
token = make_jwt(key_id, issuer_id, asc_key_path)
|
|
237
245
|
|
|
246
|
+
bundle_ids = discover_bundle_ids(project, workspace, scheme, configuration)
|
|
247
|
+
print(f"Bundle ids to provision ({len(bundle_ids)}): {bundle_ids}")
|
|
248
|
+
|
|
238
249
|
private_key, csr_b64 = generate_key_and_csr()
|
|
239
|
-
|
|
250
|
+
cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
|
|
251
|
+
|
|
252
|
+
mappings = provision_all_bundles(token, bundle_ids, cert_id)
|
|
253
|
+
print("Profile map:")
|
|
254
|
+
for bid, pname, uuid in mappings:
|
|
255
|
+
print(f" {bid} -> {pname} ({uuid})")
|
|
256
|
+
|
|
257
|
+
# Persist the bundle_id -> profile_name map so later steps (exportArchive)
|
|
258
|
+
# can build ExportOptions.plist's `provisioningProfiles` dict without
|
|
259
|
+
# re-deriving it. JSON keeps it trivial to parse from bash/python.
|
|
260
|
+
map_path = Path(runner_temp) / "signing_map.json"
|
|
261
|
+
map_path.write_text(
|
|
262
|
+
json.dumps({bid: pname for bid, pname, _ in mappings}, indent=2)
|
|
263
|
+
)
|
|
264
|
+
print(f"Wrote signing map to {map_path}")
|
|
240
265
|
|
|
241
|
-
# Provisioning profile(s) are created on-demand by xcodebuild via
|
|
242
|
-
# `-allowProvisioningUpdates` — we only need the signing identity
|
|
243
|
-
# (cert + private key) to be present in a keychain Xcode can see.
|
|
244
266
|
p12_pass = "ci"
|
|
245
267
|
p12_path = Path(runner_temp) / "cert.p12"
|
|
246
268
|
write_p12(private_key, cert_der, p12_path, p12_pass)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Provisioning profile + bundleId helpers for multi-target iOS projects.
|
|
4
|
+
|
|
5
|
+
Handles:
|
|
6
|
+
|
|
7
|
+
* Enumerating every PRODUCT_BUNDLE_IDENTIFIER used by signable targets in an
|
|
8
|
+
Xcode project / workspace (main app + extensions like Network Extensions,
|
|
9
|
+
Widgets, WatchKit apps, etc).
|
|
10
|
+
* Ensuring each bundle ID is registered on App Store Connect.
|
|
11
|
+
* Creating one IOS_APP_STORE provisioning profile per bundle ID, named
|
|
12
|
+
``CI-<bundle_id>``, linked to the just-issued distribution cert. If a
|
|
13
|
+
profile with that name already exists, it is deleted first so we always
|
|
14
|
+
end up with a profile that references the fresh cert.
|
|
15
|
+
* Installing every profile into the standard Xcode profile directory.
|
|
16
|
+
|
|
17
|
+
Kept separate from ``prepare_signing.py`` so both files stay well under the
|
|
18
|
+
400-line cap.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import plistlib
|
|
26
|
+
import subprocess
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from asc_common import get_json, request
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PROFILE_PREFIX = "CI-"
|
|
33
|
+
PROFILES_DIR = Path.home() / "Library/MobileDevice/Provisioning Profiles"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
# Bundle ID discovery #
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
|
|
40
|
+
def discover_bundle_ids(
|
|
41
|
+
project: str,
|
|
42
|
+
workspace: str,
|
|
43
|
+
scheme: str,
|
|
44
|
+
configuration: str,
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
"""Return the sorted, de-duplicated list of signable bundle IDs.
|
|
47
|
+
|
|
48
|
+
Uses ``xcodebuild -showBuildSettings -json`` which returns one settings
|
|
49
|
+
dictionary per target in the dependency graph for the given scheme. We
|
|
50
|
+
keep every ``PRODUCT_BUNDLE_IDENTIFIER`` that looks real (non-empty,
|
|
51
|
+
not a placeholder, contains at least one dot).
|
|
52
|
+
"""
|
|
53
|
+
proj_args = ["-workspace", workspace] if workspace else ["-project", project]
|
|
54
|
+
cmd = [
|
|
55
|
+
"xcodebuild",
|
|
56
|
+
*proj_args,
|
|
57
|
+
"-scheme", scheme,
|
|
58
|
+
"-configuration", configuration,
|
|
59
|
+
"-showBuildSettings",
|
|
60
|
+
"-json",
|
|
61
|
+
]
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
cmd, capture_output=True, text=True, check=True,
|
|
64
|
+
)
|
|
65
|
+
settings_list = json.loads(result.stdout or "[]")
|
|
66
|
+
found: set[str] = set()
|
|
67
|
+
for entry in settings_list:
|
|
68
|
+
attrs = entry.get("buildSettings") or {}
|
|
69
|
+
bundle_id = (attrs.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
|
|
70
|
+
if _is_real_bundle_id(bundle_id):
|
|
71
|
+
found.add(bundle_id)
|
|
72
|
+
if not found:
|
|
73
|
+
raise SystemExit(
|
|
74
|
+
"discover_bundle_ids: no PRODUCT_BUNDLE_IDENTIFIER values found "
|
|
75
|
+
f"for scheme {scheme!r} (configuration={configuration!r})"
|
|
76
|
+
)
|
|
77
|
+
return sorted(found)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_real_bundle_id(value: str) -> bool:
|
|
81
|
+
if not value:
|
|
82
|
+
return False
|
|
83
|
+
if "$(" in value or "${" in value:
|
|
84
|
+
return False
|
|
85
|
+
if "." not in value:
|
|
86
|
+
return False
|
|
87
|
+
# Xcode emits these when a target has no explicit identifier.
|
|
88
|
+
if value.lower().startswith("com.apple."):
|
|
89
|
+
return False
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
# ASC bundle ID registration #
|
|
95
|
+
# --------------------------------------------------------------------------- #
|
|
96
|
+
|
|
97
|
+
def ensure_bundle_id(token: str, identifier: str) -> str:
|
|
98
|
+
"""Return the ASC primary key for ``identifier``; register if missing."""
|
|
99
|
+
data = get_json(
|
|
100
|
+
"/bundleIds",
|
|
101
|
+
token,
|
|
102
|
+
params={"filter[identifier]": identifier, "limit": "5"},
|
|
103
|
+
)
|
|
104
|
+
for item in data.get("data", []):
|
|
105
|
+
if item["attributes"]["identifier"] == identifier:
|
|
106
|
+
return item["id"]
|
|
107
|
+
return _register_bundle_id(token, identifier)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _register_bundle_id(token: str, identifier: str) -> str:
|
|
111
|
+
body = {
|
|
112
|
+
"data": {
|
|
113
|
+
"type": "bundleIds",
|
|
114
|
+
"attributes": {
|
|
115
|
+
"identifier": identifier,
|
|
116
|
+
"name": identifier.replace(".", "-"),
|
|
117
|
+
"platform": "IOS",
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
resp = request("POST", "/bundleIds", token, json_body=body)
|
|
122
|
+
bundle_pk = resp.json()["data"]["id"]
|
|
123
|
+
print(f"Registered new bundle id {identifier!r} (pk={bundle_pk})")
|
|
124
|
+
return bundle_pk
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# Profile lifecycle #
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
|
|
131
|
+
def profile_name_for(bundle_id: str) -> str:
|
|
132
|
+
"""Return the deterministic CI profile name for a bundle id."""
|
|
133
|
+
return f"{PROFILE_PREFIX}{bundle_id}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def delete_profile_by_name(token: str, name: str) -> None:
|
|
137
|
+
"""Delete every existing profile with the given name (paginated scan)."""
|
|
138
|
+
deleted_any = False
|
|
139
|
+
next_path: str | None = "/profiles?limit=200"
|
|
140
|
+
while next_path:
|
|
141
|
+
data = get_json(next_path, token)
|
|
142
|
+
for p in data.get("data", []):
|
|
143
|
+
if (p["attributes"].get("name") or "") == name:
|
|
144
|
+
pid = p["id"]
|
|
145
|
+
print(f"Deleting stale profile {pid} ({name})")
|
|
146
|
+
request("DELETE", f"/profiles/{pid}", token)
|
|
147
|
+
deleted_any = True
|
|
148
|
+
next_link = (data.get("links") or {}).get("next")
|
|
149
|
+
if not next_link:
|
|
150
|
+
break
|
|
151
|
+
# Convert absolute next URL to path component the helper expects.
|
|
152
|
+
idx = next_link.find("/v1")
|
|
153
|
+
next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
|
|
154
|
+
if not deleted_any:
|
|
155
|
+
print(f"No existing profile named {name!r}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_profile(
|
|
159
|
+
token: str, name: str, bundle_pk: str, cert_id: str
|
|
160
|
+
) -> bytes:
|
|
161
|
+
"""Create an IOS_APP_STORE profile and return its raw (CMS-signed) bytes."""
|
|
162
|
+
body = {
|
|
163
|
+
"data": {
|
|
164
|
+
"type": "profiles",
|
|
165
|
+
"attributes": {
|
|
166
|
+
"name": name,
|
|
167
|
+
"profileType": "IOS_APP_STORE",
|
|
168
|
+
},
|
|
169
|
+
"relationships": {
|
|
170
|
+
"bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}},
|
|
171
|
+
"certificates": {
|
|
172
|
+
"data": [{"type": "certificates", "id": cert_id}]
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
resp = request("POST", "/profiles", token, json_body=body)
|
|
178
|
+
payload = resp.json()["data"]["attributes"]["profileContent"]
|
|
179
|
+
return base64.b64decode(payload)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def install_profile(profile_der: bytes) -> str:
|
|
183
|
+
"""Write the .mobileprovision into Xcode's profile dir; return UUID."""
|
|
184
|
+
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
tmp_path = PROFILES_DIR / "tmp.mobileprovision"
|
|
186
|
+
tmp_path.write_bytes(profile_der)
|
|
187
|
+
decoded = subprocess.check_output(
|
|
188
|
+
["security", "cms", "-D", "-i", str(tmp_path)]
|
|
189
|
+
)
|
|
190
|
+
uuid = plistlib.loads(decoded)["UUID"]
|
|
191
|
+
final_path = PROFILES_DIR / f"{uuid}.mobileprovision"
|
|
192
|
+
tmp_path.rename(final_path)
|
|
193
|
+
return uuid
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --------------------------------------------------------------------------- #
|
|
197
|
+
# Orchestration #
|
|
198
|
+
# --------------------------------------------------------------------------- #
|
|
199
|
+
|
|
200
|
+
def provision_all_bundles(
|
|
201
|
+
token: str,
|
|
202
|
+
bundle_ids: list[str],
|
|
203
|
+
cert_id: str,
|
|
204
|
+
) -> list[tuple[str, str, str]]:
|
|
205
|
+
"""Create + install a CI profile for each bundle id.
|
|
206
|
+
|
|
207
|
+
Returns a list of ``(bundle_id, profile_name, uuid)`` tuples in input
|
|
208
|
+
order so callers can log the full mapping.
|
|
209
|
+
"""
|
|
210
|
+
results: list[tuple[str, str, str]] = []
|
|
211
|
+
for bid in bundle_ids:
|
|
212
|
+
name = profile_name_for(bid)
|
|
213
|
+
bundle_pk = ensure_bundle_id(token, bid)
|
|
214
|
+
delete_profile_by_name(token, name)
|
|
215
|
+
profile_der = create_profile(token, name, bundle_pk, cert_id)
|
|
216
|
+
uuid = install_profile(profile_der)
|
|
217
|
+
print(f"Installed provisioning profile {name} -> {uuid} ({bid})")
|
|
218
|
+
results.append((bid, name, uuid))
|
|
219
|
+
return results
|