@daemux/store-automator 0.10.93 → 0.10.95
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/src/install.mjs +32 -10
- package/templates/github/IOS_NATIVE_CI_SETUP.md +188 -0
- package/templates/github/workflows/deploy.yml +23 -1
- package/templates/scripts/ci/ios-native/autoupdate_check.sh +119 -0
- package/templates/scripts/ci/ios-native/cert_factory.py +190 -0
- package/templates/scripts/ci/ios-native/commit_bot_changes.sh +60 -0
- package/templates/scripts/ci/ios-native/creds_store.py +328 -0
- package/templates/scripts/ci/ios-native/keychain.py +103 -0
- package/templates/scripts/ci/ios-native/prepare_signing.py +155 -271
- package/templates/scripts/ci/ios-native/profile_io.py +64 -0
- package/templates/scripts/ci/ios-native/profile_manager.py +147 -25
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +84 -27
|
@@ -2,51 +2,47 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Prepare iOS code signing on a fresh CI runner (manual-signing variant).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
the
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Writes nothing to stdout that would leak secrets.
|
|
5
|
+
Caching: the Apple Distribution cert + per-bundle provisioning profiles
|
|
6
|
+
live under the workspace's ``creds/`` directory and are reused across
|
|
7
|
+
runs. See ``creds_store.py`` for the on-disk layout and reuse rules.
|
|
8
|
+
This script orchestrates the load-or-regen flow:
|
|
9
|
+
|
|
10
|
+
1. Try to reuse the cached cert (decrypts + NotAfter > 30d +
|
|
11
|
+
``GET /certificates/{cert_id}`` = 200).
|
|
12
|
+
2. On miss, invalidate the cache and create a fresh cert via
|
|
13
|
+
``cert_factory`` (handling Apple's per-team cap of 2 by revoking
|
|
14
|
+
the OLDEST existing one).
|
|
15
|
+
3. Hand the cert id + cache_hit flag to ``provision_all_bundles``,
|
|
16
|
+
which decides per-bundle whether to reuse a cached profile or
|
|
17
|
+
create a new one and updates the manifest.
|
|
18
|
+
4. Orphan profiles (manifest entries for bundles no longer in the
|
|
19
|
+
target list) get GC'd from disk + manifest.
|
|
20
|
+
5. After successful prep the workspace ``creds/`` tree is dirty with
|
|
21
|
+
any refreshed artifacts; ``action.yml`` commits the diff back when
|
|
22
|
+
the run is on the default branch.
|
|
23
|
+
|
|
24
|
+
Sibling modules: ``cert_factory`` (key+CSR+ASC POST), ``keychain``
|
|
25
|
+
(throwaway runner keychain), ``profile_manager`` (per-bundle profile
|
|
26
|
+
lifecycle), ``profile_io`` (CMS decode), ``creds_store`` (on-disk cache).
|
|
27
|
+
|
|
28
|
+
Env inputs: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (key trio), PROJECT
|
|
29
|
+
(.xcodeproj), TEAM_ID (optional; derived from profile plist when empty),
|
|
30
|
+
RUNNER_TEMP, CREDS_DIR (optional override; defaults to
|
|
31
|
+
``$GITHUB_WORKSPACE/creds``). Writes nothing to stdout that would leak
|
|
32
|
+
secrets.
|
|
34
33
|
"""
|
|
35
34
|
|
|
36
35
|
from __future__ import annotations
|
|
37
36
|
|
|
38
|
-
import base64
|
|
39
37
|
import json
|
|
40
38
|
import os
|
|
41
|
-
import
|
|
39
|
+
from datetime import datetime, timedelta, timezone
|
|
42
40
|
from pathlib import Path
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
from
|
|
47
|
-
from
|
|
48
|
-
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
49
|
-
from cryptography.x509.oid import NameOID
|
|
42
|
+
import cert_factory
|
|
43
|
+
import creds_store
|
|
44
|
+
from asc_common import make_jwt
|
|
45
|
+
from keychain import setup_keychain
|
|
50
46
|
from pbxproj_editor import discover_signable_targets, patch_project_signing
|
|
51
47
|
from profile_manager import provision_all_bundles
|
|
52
48
|
|
|
@@ -59,215 +55,121 @@ def env(name: str) -> str:
|
|
|
59
55
|
|
|
60
56
|
|
|
61
57
|
def optional_env(name: str) -> str:
|
|
62
|
-
return os.environ.get(name, "")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
66
|
-
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
67
|
-
csr = (
|
|
68
|
-
x509.CertificateSigningRequestBuilder()
|
|
69
|
-
.subject_name(
|
|
70
|
-
x509.Name(
|
|
71
|
-
[
|
|
72
|
-
x509.NameAttribute(NameOID.COMMON_NAME, "Daemux CI"),
|
|
73
|
-
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
74
|
-
]
|
|
75
|
-
)
|
|
76
|
-
)
|
|
77
|
-
.sign(private_key, hashes.SHA256())
|
|
78
|
-
)
|
|
79
|
-
csr_pem = csr.public_bytes(serialization.Encoding.PEM)
|
|
80
|
-
csr_payload = b"".join(
|
|
81
|
-
line for line in csr_pem.splitlines() if not line.startswith(b"-----")
|
|
82
|
-
)
|
|
83
|
-
return private_key, csr_payload
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def oldest_distribution_cert_id(token: str) -> str | None:
|
|
87
|
-
"""Return the DISTRIBUTION cert with the EARLIEST expiration date.
|
|
58
|
+
return os.environ.get(name, "")
|
|
88
59
|
|
|
89
|
-
Apple's per-team distribution-cert cap is 2. When CI hits the cap we
|
|
90
|
-
must revoke one to make room for a fresh cert. We deliberately pick
|
|
91
|
-
the OLDEST cert (earliest expirationDate) because:
|
|
92
60
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
produces ITMS-90035 ("invalid signature ... signed with an ad-hoc
|
|
98
|
-
certificate, not a distribution certificate") and rejects the
|
|
99
|
-
build that was already accepted at upload time.
|
|
61
|
+
def _load_or_create_cert(
|
|
62
|
+
token: str, creds_dir: Path
|
|
63
|
+
) -> tuple[str, bytes, str, bool]:
|
|
64
|
+
"""Return ``(cert_id, p12_bytes, password, cache_hit)``.
|
|
100
65
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
* The OLDEST cert is the one most likely to be from a build whose
|
|
107
|
-
ASC processing has long since completed (each CI run only adds
|
|
108
|
-
builds; processing finishes in minutes). Revoking it is the
|
|
109
|
-
safest choice.
|
|
66
|
+
Cache hit when the on-disk cert decrypts, NotAfter > 30d away, and
|
|
67
|
+
Apple confirms the cert id is still alive. On miss the cache is
|
|
68
|
+
invalidated, a new cert is created (handling Apple's per-team cap),
|
|
69
|
+
and the cache is repopulated atomically.
|
|
110
70
|
"""
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if oldest_exp is None or exp < oldest_exp:
|
|
128
|
-
oldest_exp = exp
|
|
129
|
-
oldest_id = cert["id"]
|
|
130
|
-
return oldest_id
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
134
|
-
body = {
|
|
135
|
-
"data": {
|
|
136
|
-
"type": "certificates",
|
|
137
|
-
"attributes": {
|
|
138
|
-
"csrContent": csr_b64,
|
|
139
|
-
"certificateType": "DISTRIBUTION",
|
|
140
|
-
},
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
resp = request(
|
|
144
|
-
"POST", "/certificates", token, json_body=body, allow_status={409}
|
|
145
|
-
)
|
|
146
|
-
if resp.status_code == 409:
|
|
147
|
-
# Revoke the OLDEST cert, NOT the newest. The newest cert signed
|
|
148
|
-
# the previous build that may still be in ASC processing — revoking
|
|
149
|
-
# it during processing yields ITMS-90035 on the prior build.
|
|
150
|
-
# See oldest_distribution_cert_id() for the full rationale.
|
|
151
|
-
print("Distribution cert cap hit; revoking oldest existing cert")
|
|
152
|
-
target = oldest_distribution_cert_id(token)
|
|
153
|
-
if not target:
|
|
154
|
-
raise SystemExit(
|
|
155
|
-
"409 from cert create but no existing DISTRIBUTION cert "
|
|
156
|
-
"found to revoke"
|
|
71
|
+
cached = creds_store.load_cached_cert(creds_dir)
|
|
72
|
+
if cached and creds_store.verify_cert_alive(token, cached.cert_id):
|
|
73
|
+
print(
|
|
74
|
+
f"Reusing cached distribution cert {cached.cert_id} "
|
|
75
|
+
f"(NotAfter {cached.not_after.isoformat()})"
|
|
76
|
+
)
|
|
77
|
+
# Warn when inside T-60d..T-30d so operators see renewal is pending
|
|
78
|
+
# but auto-renew has not yet fired.
|
|
79
|
+
now = datetime.now(timezone.utc)
|
|
80
|
+
renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
|
|
81
|
+
warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
|
|
82
|
+
if renew_at < cached.not_after <= warn_at:
|
|
83
|
+
print(
|
|
84
|
+
f"::warning::Cert {cached.cert_id} expires in "
|
|
85
|
+
f"{(cached.not_after - now).days}d; auto-renew fires at "
|
|
86
|
+
f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
|
|
157
87
|
)
|
|
158
|
-
|
|
159
|
-
resp = request("POST", "/certificates", token, json_body=body)
|
|
160
|
-
data = resp.json()["data"]
|
|
161
|
-
cert_id = data["id"]
|
|
162
|
-
cert_der = base64.b64decode(data["attributes"]["certificateContent"])
|
|
163
|
-
print(f"Created DISTRIBUTION cert {cert_id}")
|
|
164
|
-
return cert_id, cert_der
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def write_p12(
|
|
168
|
-
private_key: rsa.RSAPrivateKey, cert_der: bytes, out_path: Path, passwd: str
|
|
169
|
-
) -> None:
|
|
170
|
-
cert = x509.load_der_x509_certificate(cert_der)
|
|
171
|
-
p12 = pkcs12.serialize_key_and_certificates(
|
|
172
|
-
name=b"Daemux CI",
|
|
173
|
-
key=private_key,
|
|
174
|
-
cert=cert,
|
|
175
|
-
cas=None,
|
|
176
|
-
encryption_algorithm=serialization.BestAvailableEncryption(
|
|
177
|
-
passwd.encode()
|
|
178
|
-
),
|
|
179
|
-
)
|
|
180
|
-
out_path.write_bytes(p12)
|
|
88
|
+
return cached.cert_id, cached.p12_bytes, cached.password, True
|
|
181
89
|
|
|
90
|
+
if cached:
|
|
91
|
+
print(
|
|
92
|
+
f"Cached cert {cached.cert_id} no longer alive on Apple; "
|
|
93
|
+
"invalidating cache and regenerating"
|
|
94
|
+
)
|
|
95
|
+
creds_store.invalidate_cache(creds_dir)
|
|
182
96
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
check=False,
|
|
187
|
-
capture_output=True,
|
|
188
|
-
)
|
|
189
|
-
subprocess.check_call(
|
|
190
|
-
["security", "create-keychain", "-p", keychain_pass, keychain_path]
|
|
97
|
+
private_key, csr_b64 = cert_factory.generate_key_and_csr()
|
|
98
|
+
cert_id, cert_der = cert_factory.create_distribution_cert(
|
|
99
|
+
token, csr_b64.decode()
|
|
191
100
|
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
|
|
101
|
+
password = creds_store.P12_PASSWORD
|
|
102
|
+
p12_bytes = cert_factory.serialize_p12(private_key, cert_der, password)
|
|
103
|
+
not_after = creds_store.cert_not_after_from_der(cert_der)
|
|
104
|
+
creds_store.write_cert_bundle(
|
|
105
|
+
creds_dir, cert_id, p12_bytes, password, not_after
|
|
197
106
|
)
|
|
107
|
+
print(f"Persisted fresh cert bundle to {creds_dir}")
|
|
108
|
+
return cert_id, p12_bytes, password, False
|
|
198
109
|
|
|
199
110
|
|
|
200
|
-
def
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
"-A",
|
|
208
|
-
"-t", "cert",
|
|
209
|
-
"-f", "pkcs12",
|
|
210
|
-
"-k", keychain_path,
|
|
211
|
-
]
|
|
212
|
-
)
|
|
213
|
-
subprocess.check_call(
|
|
214
|
-
[
|
|
215
|
-
"security", "set-key-partition-list",
|
|
216
|
-
"-S", "apple-tool:,apple:,codesign:",
|
|
217
|
-
"-s",
|
|
218
|
-
"-k", keychain_pass,
|
|
219
|
-
keychain_path,
|
|
220
|
-
]
|
|
221
|
-
)
|
|
111
|
+
def _resolve_creds_dir() -> Path:
|
|
112
|
+
"""Return the workspace creds/ root, honoring the CREDS_DIR override."""
|
|
113
|
+
override = optional_env("CREDS_DIR")
|
|
114
|
+
if override:
|
|
115
|
+
return Path(override)
|
|
116
|
+
workspace = optional_env("GITHUB_WORKSPACE") or os.getcwd()
|
|
117
|
+
return Path(workspace) / "creds"
|
|
222
118
|
|
|
223
119
|
|
|
224
|
-
def
|
|
225
|
-
|
|
226
|
-
["security", "list-keychains", "-d", "user"]
|
|
227
|
-
).decode()
|
|
228
|
-
existing_list = [
|
|
229
|
-
line.strip().strip('"')
|
|
230
|
-
for line in existing.splitlines()
|
|
231
|
-
if line.strip()
|
|
232
|
-
]
|
|
233
|
-
new_list = [keychain_path] + [
|
|
234
|
-
k for k in existing_list if k != keychain_path
|
|
235
|
-
]
|
|
236
|
-
subprocess.check_call(
|
|
237
|
-
["security", "list-keychains", "-d", "user", "-s", *new_list]
|
|
238
|
-
)
|
|
239
|
-
subprocess.check_call(
|
|
240
|
-
["security", "default-keychain", "-s", keychain_path]
|
|
241
|
-
)
|
|
120
|
+
def _resolve_pbx_team(effective_team: str, configured_team: str) -> str:
|
|
121
|
+
"""Pick the team Xcode must see and warn on mismatch with config.
|
|
242
122
|
|
|
123
|
+
The ASC API key is bound to a single developer team. Every profile
|
|
124
|
+
it issues lives in THAT team, so Xcode's ``DEVELOPMENT_TEAM`` must
|
|
125
|
+
match it exactly — otherwise the profile can't be matched to the
|
|
126
|
+
target at archive time. If the ci.config.yaml team differs, warn
|
|
127
|
+
and use the profile's team as the source of truth.
|
|
128
|
+
"""
|
|
129
|
+
pbx_team = effective_team or configured_team
|
|
130
|
+
if not pbx_team:
|
|
131
|
+
raise SystemExit(
|
|
132
|
+
"Unable to determine Apple developer team. The installed "
|
|
133
|
+
"provisioning profile did not expose a TeamIdentifier and no "
|
|
134
|
+
"TEAM_ID was provided. Set `app.team_id` in ci.config.yaml. "
|
|
135
|
+
"Find your team id at https://developer.apple.com/account -> "
|
|
136
|
+
"Membership (look for 'Team ID')."
|
|
137
|
+
)
|
|
138
|
+
if effective_team and configured_team and effective_team != configured_team:
|
|
139
|
+
print(
|
|
140
|
+
f"::warning::Config team {configured_team} differs from ASC API "
|
|
141
|
+
f"key's team {effective_team}; patching pbxproj with "
|
|
142
|
+
f"{effective_team} (the team that actually issued the "
|
|
143
|
+
"provisioning profiles)."
|
|
144
|
+
)
|
|
145
|
+
return pbx_team
|
|
243
146
|
|
|
244
|
-
def setup_keychain(p12_path: Path, p12_pass: str) -> str:
|
|
245
|
-
keychain_path = os.path.join(env("RUNNER_TEMP"), "ci.keychain-db")
|
|
246
|
-
keychain_pass = "ci"
|
|
247
|
-
_create_keychain(keychain_path, keychain_pass)
|
|
248
|
-
_import_p12(keychain_path, keychain_pass, p12_path, p12_pass)
|
|
249
|
-
_prepend_to_user_search_list(keychain_path)
|
|
250
|
-
print(f"Keychain ready: {keychain_path}")
|
|
251
|
-
return keychain_path
|
|
252
147
|
|
|
148
|
+
def _write_signing_map(
|
|
149
|
+
runner_temp: str, pbx_team: str, mappings: list
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Persist mapping for the downstream Export IPA step.
|
|
152
|
+
|
|
153
|
+
``ExportOptions.plist`` needs a ``provisioningProfiles`` dict and
|
|
154
|
+
the effective team that issued the profiles.
|
|
155
|
+
"""
|
|
156
|
+
map_path = Path(runner_temp) / "signing_map.json"
|
|
157
|
+
map_path.write_text(
|
|
158
|
+
json.dumps(
|
|
159
|
+
{
|
|
160
|
+
"team_id": pbx_team,
|
|
161
|
+
"profiles": {bid: pname for bid, pname, _ in mappings},
|
|
162
|
+
},
|
|
163
|
+
indent=2,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
print(f"Wrote signing map to {map_path}")
|
|
253
167
|
|
|
254
|
-
def main() -> None:
|
|
255
|
-
key_id = env("ASC_KEY_ID")
|
|
256
|
-
issuer_id = env("ASC_ISSUER_ID")
|
|
257
|
-
asc_key_path = env("ASC_KEY_PATH")
|
|
258
|
-
runner_temp = env("RUNNER_TEMP")
|
|
259
|
-
# TEAM_ID is optional: the ASC API key is bound to one developer team,
|
|
260
|
-
# and every profile it issues inherits that team. provision_all_bundles
|
|
261
|
-
# returns that ``effective_team`` read straight from the installed
|
|
262
|
-
# profile's plist — which is the authoritative value Xcode will look
|
|
263
|
-
# for in DEVELOPMENT_TEAM. Upstream (read_config.py) also tries to
|
|
264
|
-
# derive team_id via GET-only ASC endpoints and pass it here as a
|
|
265
|
-
# convenience for logging and xcconfig patching.
|
|
266
|
-
team_id = optional_env("TEAM_ID")
|
|
267
168
|
|
|
169
|
+
def _resolve_project_path() -> str:
|
|
170
|
+
"""Return the .xcodeproj path; reject WORKSPACE-only mode loudly."""
|
|
268
171
|
project = optional_env("PROJECT")
|
|
269
|
-
|
|
270
|
-
if workspace and not project:
|
|
172
|
+
if optional_env("WORKSPACE") and not project:
|
|
271
173
|
raise SystemExit(
|
|
272
174
|
"prepare_signing: WORKSPACE-only mode is not supported; the "
|
|
273
175
|
"underlying .xcodeproj must be passed via PROJECT so we can "
|
|
@@ -275,64 +177,46 @@ def main() -> None:
|
|
|
275
177
|
)
|
|
276
178
|
if not project:
|
|
277
179
|
raise SystemExit("prepare_signing: PROJECT env var is required")
|
|
180
|
+
return project
|
|
278
181
|
|
|
279
|
-
|
|
182
|
+
|
|
183
|
+
def main() -> None:
|
|
184
|
+
runner_temp = env("RUNNER_TEMP")
|
|
185
|
+
# TEAM_ID is optional — provision_all_bundles returns the effective
|
|
186
|
+
# team from the installed profile's plist; see _resolve_pbx_team.
|
|
187
|
+
team_id = optional_env("TEAM_ID")
|
|
188
|
+
project = _resolve_project_path()
|
|
189
|
+
token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
|
|
190
|
+
creds_dir = _resolve_creds_dir()
|
|
280
191
|
|
|
281
192
|
targets = discover_signable_targets(project)
|
|
282
193
|
bundle_ids = sorted({t["bundle_id"] for t in targets})
|
|
283
194
|
print(f"Signable targets ({len(targets)}):")
|
|
284
195
|
for t in targets:
|
|
285
|
-
print(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
196
|
+
print(
|
|
197
|
+
f" - {t['name']} -> {t['bundle_id']} "
|
|
198
|
+
f"({len(t['config_ids'])} configs)"
|
|
199
|
+
)
|
|
289
200
|
|
|
290
|
-
|
|
201
|
+
cert_id, p12_bytes, p12_pass, cache_hit = _load_or_create_cert(
|
|
202
|
+
token, creds_dir
|
|
203
|
+
)
|
|
204
|
+
mappings, effective_team = provision_all_bundles(
|
|
205
|
+
token, bundle_ids, cert_id, creds_dir=creds_dir, cache_hit=cache_hit
|
|
206
|
+
)
|
|
291
207
|
print("Profile map:")
|
|
292
208
|
for bid, pname, uuid in mappings:
|
|
293
209
|
print(f" {bid} -> {pname} ({uuid})")
|
|
294
210
|
|
|
295
|
-
|
|
296
|
-
# issues lives in THAT team, so Xcode's DEVELOPMENT_TEAM setting must
|
|
297
|
-
# match it exactly — otherwise the profile can't be matched to the
|
|
298
|
-
# target at archive time. If the ci.config.yaml team differs, warn and
|
|
299
|
-
# use the profile's team as the source of truth.
|
|
300
|
-
pbx_team = effective_team or team_id
|
|
301
|
-
if not pbx_team:
|
|
302
|
-
raise SystemExit(
|
|
303
|
-
"Unable to determine Apple developer team. The installed "
|
|
304
|
-
"provisioning profile did not expose a TeamIdentifier and no "
|
|
305
|
-
"TEAM_ID was provided. Set `app.team_id` in ci.config.yaml. "
|
|
306
|
-
"Find your team id at https://developer.apple.com/account -> "
|
|
307
|
-
"Membership (look for 'Team ID')."
|
|
308
|
-
)
|
|
309
|
-
if effective_team and team_id and effective_team != team_id:
|
|
310
|
-
print(
|
|
311
|
-
f"::warning::Config team {team_id} differs from ASC API key's "
|
|
312
|
-
f"team {effective_team}; patching pbxproj with {effective_team} "
|
|
313
|
-
"(the team that actually issued the provisioning profiles)."
|
|
314
|
-
)
|
|
211
|
+
pbx_team = _resolve_pbx_team(effective_team, team_id)
|
|
315
212
|
patch_project_signing(project, targets, pbx_team)
|
|
213
|
+
_write_signing_map(runner_temp, pbx_team, mappings)
|
|
316
214
|
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
# Export uses the same team Apple assigned to the profiles.
|
|
320
|
-
map_path = Path(runner_temp) / "signing_map.json"
|
|
321
|
-
map_path.write_text(
|
|
322
|
-
json.dumps(
|
|
323
|
-
{
|
|
324
|
-
"team_id": pbx_team,
|
|
325
|
-
"profiles": {bid: pname for bid, pname, _ in mappings},
|
|
326
|
-
},
|
|
327
|
-
indent=2,
|
|
328
|
-
)
|
|
329
|
-
)
|
|
330
|
-
print(f"Wrote signing map to {map_path}")
|
|
331
|
-
|
|
332
|
-
p12_pass = "ci"
|
|
215
|
+
# Stage p12 in $RUNNER_TEMP for the keychain importer (the cache copy
|
|
216
|
+
# under creds/cert.p12 stays untouched as the source of truth).
|
|
333
217
|
p12_path = Path(runner_temp) / "cert.p12"
|
|
334
|
-
|
|
335
|
-
setup_keychain(p12_path, p12_pass)
|
|
218
|
+
p12_path.write_bytes(p12_bytes)
|
|
219
|
+
setup_keychain(p12_path, p12_pass, runner_temp)
|
|
336
220
|
|
|
337
221
|
|
|
338
222
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
On-disk + plist-decode helpers for ``.mobileprovision`` files.
|
|
4
|
+
|
|
5
|
+
Split from ``profile_manager.py`` so the ASC-API-facing module stays
|
|
6
|
+
focused on lifecycle (create / delete / install) and this module owns
|
|
7
|
+
the file-format gymnastics (CMS envelope decode via ``security cms -D``,
|
|
8
|
+
plist date normalisation).
|
|
9
|
+
|
|
10
|
+
Nothing in this module talks to App Store Connect. Callers pass raw DER
|
|
11
|
+
bytes and receive parsed Python primitives.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import plistlib
|
|
17
|
+
import subprocess
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Apple guarantees ``ExpirationDate`` in every issued profile; if it is
|
|
23
|
+
# missing or non-datetime the profile is malformed — treat as already-
|
|
24
|
+
# expired so callers never reuse it.
|
|
25
|
+
_EXPIRED_SENTINEL = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decode_profile_plist(profile_der: bytes, scratch_dir: Path) -> dict:
|
|
29
|
+
"""Run ``security cms -D`` against ``profile_der`` and parse the plist.
|
|
30
|
+
|
|
31
|
+
The decoded payload's only authoritative source is the macOS
|
|
32
|
+
``security`` binary; ``plistlib`` alone can't parse the CMS envelope.
|
|
33
|
+
``scratch_dir`` is used for a temporary file (``security`` only
|
|
34
|
+
accepts file paths, not stdin); it is created if missing.
|
|
35
|
+
|
|
36
|
+
May raise ``subprocess.CalledProcessError`` (decode failure),
|
|
37
|
+
``ValueError`` (plist parse failure), or ``OSError`` (filesystem
|
|
38
|
+
failure). Callers that want graceful cache fallback must catch.
|
|
39
|
+
"""
|
|
40
|
+
scratch_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp_path = scratch_dir / "tmp.mobileprovision"
|
|
42
|
+
tmp_path.write_bytes(profile_der)
|
|
43
|
+
try:
|
|
44
|
+
decoded = subprocess.check_output(
|
|
45
|
+
["security", "cms", "-D", "-i", str(tmp_path)]
|
|
46
|
+
)
|
|
47
|
+
finally:
|
|
48
|
+
tmp_path.unlink(missing_ok=True)
|
|
49
|
+
return plistlib.loads(decoded)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_utc(value) -> datetime:
|
|
53
|
+
"""Normalise a plist datetime to a UTC-aware ``datetime``.
|
|
54
|
+
|
|
55
|
+
Plist dates parsed by ``plistlib`` come back as naive UTC; explicitly
|
|
56
|
+
attach ``timezone.utc`` so downstream comparisons stay timezone-safe.
|
|
57
|
+
Anything that is not a ``datetime`` returns the expired sentinel so
|
|
58
|
+
a malformed profile is never treated as reusable.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(value, datetime):
|
|
61
|
+
return _EXPIRED_SENTINEL
|
|
62
|
+
if value.tzinfo is None:
|
|
63
|
+
return value.replace(tzinfo=timezone.utc)
|
|
64
|
+
return value.astimezone(timezone.utc)
|