@daemux/store-automator 0.10.93 → 0.10.94
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 +73 -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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Persistent cache for the Apple Distribution cert + per-bundle provisioning
|
|
4
|
+
profiles, stored under the workspace's ``creds/`` directory.
|
|
5
|
+
|
|
6
|
+
CI no longer regenerates a fresh cert and N profiles on every run. Apple
|
|
7
|
+
caps each team at 2 distribution certs and 7-day-rolling profile churn
|
|
8
|
+
triggers throttling, so re-using the same identity across runs is the
|
|
9
|
+
only sane long-term strategy. We keep:
|
|
10
|
+
|
|
11
|
+
creds/
|
|
12
|
+
cert.p12 PKCS12 with cert + private key
|
|
13
|
+
cert.meta.json {cert_id, not_after, p12_password,
|
|
14
|
+
created_at}
|
|
15
|
+
profiles.manifest.json {cert_id, profiles:[
|
|
16
|
+
{bundle_id, name, uuid, filename,
|
|
17
|
+
expiration}]}
|
|
18
|
+
profiles/<uuid>.mobileprovision one per signable bundle
|
|
19
|
+
|
|
20
|
+
Reuse rules (encoded in :func:`load_cached_cert` and
|
|
21
|
+
:func:`find_reusable_profile`):
|
|
22
|
+
|
|
23
|
+
* Cert is reusable iff cert.p12 exists, decrypts with the stored
|
|
24
|
+
password, NotAfter is more than 30 days away, AND
|
|
25
|
+
GET /certificates/{cert_id} returns 200 (Apple hasn't revoked it).
|
|
26
|
+
* Profile is reusable iff the cert is reusable AND the manifest entry
|
|
27
|
+
has matching cert_id AND expiration is more than 30 days away AND
|
|
28
|
+
the .mobileprovision file is on disk.
|
|
29
|
+
|
|
30
|
+
Anything outside reuse triggers regen + cache replacement (atomic).
|
|
31
|
+
|
|
32
|
+
`p12_password` stays ``"ci"`` deliberately. Encryption-at-rest of the
|
|
33
|
+
PKCS12 is decorative when the ``.p8`` ASC API key sits next to it in
|
|
34
|
+
plaintext — anyone who can read one can read the other. The repo MUST
|
|
35
|
+
stay private; the cert lives there for warm-cache reasons, not secrecy.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from datetime import datetime, timedelta, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from asc_common import request
|
|
48
|
+
from cryptography import x509
|
|
49
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Auto-renew when the cert (or any profile) is within this many days of
|
|
53
|
+
# expiring. Apple distribution certs are valid for 1 year and profiles
|
|
54
|
+
# for 1 year, so 30 days is plenty of headroom for CI to roll.
|
|
55
|
+
RENEW_THRESHOLD_DAYS = 30
|
|
56
|
+
|
|
57
|
+
# Surface a non-fatal warning when the cert is within this many days of
|
|
58
|
+
# expiry but not yet inside the auto-renew window. Gives operators
|
|
59
|
+
# visibility before the renew actually fires.
|
|
60
|
+
WARN_THRESHOLD_DAYS = 60
|
|
61
|
+
|
|
62
|
+
# DESIGN DECISION (intentional, not a bug):
|
|
63
|
+
# P12 password is a hardcoded literal because encryption-at-rest is
|
|
64
|
+
# decorative in this storage model: the .p12 lives next to the .p8 ASC
|
|
65
|
+
# API key in the same private repo. Both are private keys; protecting one
|
|
66
|
+
# with a sourced password while the other sits in plaintext alongside
|
|
67
|
+
# adds bug surface (password drift between meta.json and actual encryption)
|
|
68
|
+
# without raising the security floor. If this storage model changes (e.g.
|
|
69
|
+
# .p8 moves to GH Secrets), revisit and source this from a secret too.
|
|
70
|
+
P12_PASSWORD = "ci"
|
|
71
|
+
|
|
72
|
+
# File-name layout under creds/. Centralised so callers never compute
|
|
73
|
+
# their own paths and the layout stays a single fact in one place.
|
|
74
|
+
_CERT_P12 = "cert.p12"
|
|
75
|
+
_CERT_META = "cert.meta.json"
|
|
76
|
+
_MANIFEST = "profiles.manifest.json"
|
|
77
|
+
_PROFILES_SUBDIR = "profiles"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class CachedCert:
|
|
82
|
+
cert_id: str
|
|
83
|
+
not_after: datetime
|
|
84
|
+
p12_bytes: bytes
|
|
85
|
+
password: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ProfileEntry:
|
|
90
|
+
bundle_id: str
|
|
91
|
+
name: str
|
|
92
|
+
uuid: str
|
|
93
|
+
filename: str
|
|
94
|
+
expiration: datetime
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- #
|
|
98
|
+
# Internal helpers (atomic IO + datetime parsing) #
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
|
|
101
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
104
|
+
tmp.write_bytes(data)
|
|
105
|
+
os.replace(tmp, path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_iso(value: str) -> datetime:
|
|
109
|
+
"""Parse an ISO-8601 datetime; tolerate trailing 'Z' for UTC."""
|
|
110
|
+
if value.endswith("Z"):
|
|
111
|
+
value = value[:-1] + "+00:00"
|
|
112
|
+
parsed = datetime.fromisoformat(value)
|
|
113
|
+
if parsed.tzinfo is None:
|
|
114
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
115
|
+
return parsed
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _now_utc() -> datetime:
|
|
119
|
+
return datetime.now(timezone.utc)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --------------------------------------------------------------------------- #
|
|
123
|
+
# Path computations (pure) #
|
|
124
|
+
# --------------------------------------------------------------------------- #
|
|
125
|
+
|
|
126
|
+
def profile_path(creds_dir: Path, uuid: str) -> Path:
|
|
127
|
+
"""Return the on-disk path for a cached profile by UUID (pure)."""
|
|
128
|
+
return creds_dir / _PROFILES_SUBDIR / f"{uuid}.mobileprovision"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------- #
|
|
132
|
+
# Cert cache #
|
|
133
|
+
# --------------------------------------------------------------------------- #
|
|
134
|
+
|
|
135
|
+
def load_cached_cert(creds_dir: Path) -> CachedCert | None:
|
|
136
|
+
"""Return the cached cert iff p12 + meta exist and decrypt cleanly.
|
|
137
|
+
|
|
138
|
+
Returns ``None`` (and logs a ``::warning::``) on any of:
|
|
139
|
+
* missing p12 or meta file
|
|
140
|
+
* meta JSON corrupt / missing required keys
|
|
141
|
+
* p12 fails to decrypt with the stored password
|
|
142
|
+
* cert NotAfter is within RENEW_THRESHOLD_DAYS
|
|
143
|
+
|
|
144
|
+
A returned :class:`CachedCert` is NOT yet validated against Apple —
|
|
145
|
+
the caller must still confirm the cert id is alive via
|
|
146
|
+
:func:`verify_cert_alive`.
|
|
147
|
+
"""
|
|
148
|
+
p12 = creds_dir / _CERT_P12
|
|
149
|
+
meta = creds_dir / _CERT_META
|
|
150
|
+
if not p12.exists() or not meta.exists():
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
meta_raw = json.loads(meta.read_text())
|
|
155
|
+
cert_id = meta_raw["cert_id"]
|
|
156
|
+
not_after = _parse_iso(meta_raw["not_after"])
|
|
157
|
+
password = meta_raw.get("p12_password") or P12_PASSWORD
|
|
158
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
159
|
+
print(f"::warning::cert.meta.json unreadable ({exc}); regenerating cert")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
p12_bytes = p12.read_bytes()
|
|
163
|
+
try:
|
|
164
|
+
pkcs12.load_key_and_certificates(p12_bytes, password.encode())
|
|
165
|
+
except (ValueError, TypeError) as exc:
|
|
166
|
+
print(f"::warning::cert.p12 decrypt failed ({exc}); regenerating cert")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
deadline = _now_utc() + timedelta(days=RENEW_THRESHOLD_DAYS)
|
|
170
|
+
if not_after <= deadline:
|
|
171
|
+
print(
|
|
172
|
+
f"::warning::Cached cert {cert_id} expires {not_after.isoformat()} "
|
|
173
|
+
f"(within {RENEW_THRESHOLD_DAYS}d); regenerating"
|
|
174
|
+
)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
return CachedCert(
|
|
178
|
+
cert_id=cert_id,
|
|
179
|
+
not_after=not_after,
|
|
180
|
+
p12_bytes=p12_bytes,
|
|
181
|
+
password=password,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def verify_cert_alive(token: str, cert_id: str) -> bool:
|
|
186
|
+
"""Return True iff GET /certificates/{cert_id} returns 200.
|
|
187
|
+
|
|
188
|
+
A 404 means Apple revoked it (manually or via the cap-rotation done
|
|
189
|
+
by ``oldest_distribution_cert_id``); any other non-200 is treated
|
|
190
|
+
conservatively as not-alive so we regenerate.
|
|
191
|
+
"""
|
|
192
|
+
resp = request(
|
|
193
|
+
"GET",
|
|
194
|
+
f"/certificates/{cert_id}",
|
|
195
|
+
token,
|
|
196
|
+
allow_status={404},
|
|
197
|
+
)
|
|
198
|
+
return resp.status_code == 200
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def write_cert_bundle(
|
|
202
|
+
creds_dir: Path,
|
|
203
|
+
cert_id: str,
|
|
204
|
+
p12_bytes: bytes,
|
|
205
|
+
password: str,
|
|
206
|
+
not_after: datetime,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Persist the cert bundle atomically (p12 + meta)."""
|
|
209
|
+
creds_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
_atomic_write(creds_dir / _CERT_P12, p12_bytes)
|
|
211
|
+
meta = {
|
|
212
|
+
"cert_id": cert_id,
|
|
213
|
+
"not_after": not_after.astimezone(timezone.utc).isoformat(),
|
|
214
|
+
# Password persisted alongside the cert it unlocks — see DESIGN
|
|
215
|
+
# DECISION block at P12_PASSWORD definition above for the
|
|
216
|
+
# rationale (encryption-at-rest is decorative when the .p8 lives
|
|
217
|
+
# in plaintext alongside in the same private repo).
|
|
218
|
+
"p12_password": password,
|
|
219
|
+
"created_at": _now_utc().isoformat(),
|
|
220
|
+
}
|
|
221
|
+
_atomic_write(creds_dir / _CERT_META, json.dumps(meta, indent=2).encode())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --------------------------------------------------------------------------- #
|
|
225
|
+
# Profile manifest #
|
|
226
|
+
# --------------------------------------------------------------------------- #
|
|
227
|
+
|
|
228
|
+
def load_profile_manifest(creds_dir: Path) -> dict:
|
|
229
|
+
"""Return the parsed manifest, or an empty skeleton if missing/corrupt."""
|
|
230
|
+
path = creds_dir / _MANIFEST
|
|
231
|
+
if not path.exists():
|
|
232
|
+
return {"cert_id": None, "profiles": []}
|
|
233
|
+
try:
|
|
234
|
+
data = json.loads(path.read_text())
|
|
235
|
+
except (OSError, ValueError) as exc:
|
|
236
|
+
print(f"::warning::profiles.manifest.json unreadable ({exc}); resetting")
|
|
237
|
+
return {"cert_id": None, "profiles": []}
|
|
238
|
+
data.setdefault("cert_id", None)
|
|
239
|
+
data.setdefault("profiles", [])
|
|
240
|
+
return data
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def write_profile_manifest(creds_dir: Path, manifest: dict) -> None:
|
|
244
|
+
"""Persist the manifest atomically with profiles sorted by bundle_id."""
|
|
245
|
+
sorted_profiles = sorted(
|
|
246
|
+
manifest.get("profiles", []),
|
|
247
|
+
key=lambda entry: entry.get("bundle_id", ""),
|
|
248
|
+
)
|
|
249
|
+
payload = {
|
|
250
|
+
"cert_id": manifest.get("cert_id"),
|
|
251
|
+
"profiles": sorted_profiles,
|
|
252
|
+
}
|
|
253
|
+
_atomic_write(
|
|
254
|
+
creds_dir / _MANIFEST, json.dumps(payload, indent=2).encode()
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def find_reusable_profile(
|
|
259
|
+
manifest: dict,
|
|
260
|
+
bundle_id: str,
|
|
261
|
+
cert_id: str,
|
|
262
|
+
creds_dir: Path,
|
|
263
|
+
) -> ProfileEntry | None:
|
|
264
|
+
"""Return a reusable :class:`ProfileEntry` for ``bundle_id`` or None.
|
|
265
|
+
|
|
266
|
+
Reusable requires: manifest cert_id matches current cert_id, an
|
|
267
|
+
entry exists for the bundle, expiration is past the renew
|
|
268
|
+
threshold, and the .mobileprovision file is on disk.
|
|
269
|
+
"""
|
|
270
|
+
if manifest.get("cert_id") != cert_id:
|
|
271
|
+
return None
|
|
272
|
+
deadline = _now_utc() + timedelta(days=RENEW_THRESHOLD_DAYS)
|
|
273
|
+
for raw in manifest.get("profiles", []):
|
|
274
|
+
if raw.get("bundle_id") != bundle_id:
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
expiration = _parse_iso(raw["expiration"])
|
|
278
|
+
uuid = raw["uuid"]
|
|
279
|
+
except (KeyError, ValueError):
|
|
280
|
+
return None
|
|
281
|
+
if expiration <= deadline:
|
|
282
|
+
return None
|
|
283
|
+
if not profile_path(creds_dir, uuid).exists():
|
|
284
|
+
return None
|
|
285
|
+
return ProfileEntry(
|
|
286
|
+
bundle_id=bundle_id,
|
|
287
|
+
name=raw.get("name", ""),
|
|
288
|
+
uuid=uuid,
|
|
289
|
+
filename=raw.get("filename", f"{uuid}.mobileprovision"),
|
|
290
|
+
expiration=expiration,
|
|
291
|
+
)
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --------------------------------------------------------------------------- #
|
|
296
|
+
# Cache invalidation + cross-module utilities #
|
|
297
|
+
# --------------------------------------------------------------------------- #
|
|
298
|
+
|
|
299
|
+
def invalidate_cache(creds_dir: Path) -> None:
|
|
300
|
+
"""Remove all cache artifacts under ``creds_dir`` (best-effort)."""
|
|
301
|
+
for name in (_CERT_P12, _CERT_META, _MANIFEST):
|
|
302
|
+
(creds_dir / name).unlink(missing_ok=True)
|
|
303
|
+
pdir = creds_dir / _PROFILES_SUBDIR
|
|
304
|
+
if pdir.is_dir():
|
|
305
|
+
for entry in pdir.iterdir():
|
|
306
|
+
if entry.suffix == ".mobileprovision":
|
|
307
|
+
entry.unlink(missing_ok=True)
|
|
308
|
+
print(f"Invalidated signing cache under {creds_dir}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def write_cached_profile(creds_dir: Path, uuid: str, profile_der: bytes) -> None:
|
|
312
|
+
"""Persist a fresh .mobileprovision atomically under ``profiles/``."""
|
|
313
|
+
_atomic_write(profile_path(creds_dir, uuid), profile_der)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cert_not_after_from_der(cert_der: bytes) -> datetime:
|
|
317
|
+
"""Extract NotAfter from a DER-encoded certificate, normalised to UTC.
|
|
318
|
+
|
|
319
|
+
Prefers ``not_valid_after_utc`` (cryptography >= 42); falls back to
|
|
320
|
+
the deprecated naive-UTC ``not_valid_after`` for older runtimes. Also
|
|
321
|
+
used by the prepare_signing entrypoint to decide T-60d warnings on
|
|
322
|
+
cached certs without re-loading the PKCS12.
|
|
323
|
+
"""
|
|
324
|
+
cert = x509.load_der_x509_certificate(cert_der)
|
|
325
|
+
try:
|
|
326
|
+
return cert.not_valid_after_utc
|
|
327
|
+
except AttributeError:
|
|
328
|
+
return cert.not_valid_after.replace(tzinfo=timezone.utc)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Throwaway-keychain provisioning for codesign / xcodebuild on a CI runner.
|
|
4
|
+
|
|
5
|
+
Creates a temporary keychain in ``$RUNNER_TEMP``, imports a PKCS12 cert
|
|
6
|
+
into it, and prepends it to the user's keychain search list so
|
|
7
|
+
``codesign`` and ``xcodebuild`` find the identity at archive time. The
|
|
8
|
+
keychain is purposely short-lived (default 6h auto-lock) and disposable
|
|
9
|
+
— each CI run re-creates it from scratch.
|
|
10
|
+
|
|
11
|
+
The keychain password (``"ci"``) is intentionally hardcoded: the
|
|
12
|
+
keychain never leaves the runner (it lives in ``$RUNNER_TEMP`` which
|
|
13
|
+
GitHub deletes when the job ends), so encrypting it with a sourced
|
|
14
|
+
password adds bug surface (drift between import + unlock calls) without
|
|
15
|
+
raising the security floor. This is distinct from the persisted P12
|
|
16
|
+
password documented in ``creds_store.P12_PASSWORD``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_KEYCHAIN_FILENAME = "ci.keychain-db"
|
|
27
|
+
_KEYCHAIN_PASS = "ci"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
|
|
31
|
+
subprocess.run(
|
|
32
|
+
["security", "delete-keychain", keychain_path],
|
|
33
|
+
check=False,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
)
|
|
36
|
+
subprocess.check_call(
|
|
37
|
+
["security", "create-keychain", "-p", keychain_pass, keychain_path]
|
|
38
|
+
)
|
|
39
|
+
subprocess.check_call(
|
|
40
|
+
["security", "set-keychain-settings", "-lut", "21600", keychain_path]
|
|
41
|
+
)
|
|
42
|
+
subprocess.check_call(
|
|
43
|
+
["security", "unlock-keychain", "-p", keychain_pass, keychain_path]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _import_p12(
|
|
48
|
+
keychain_path: str, keychain_pass: str, p12_path: Path, p12_pass: str
|
|
49
|
+
) -> None:
|
|
50
|
+
subprocess.check_call(
|
|
51
|
+
[
|
|
52
|
+
"security", "import", str(p12_path),
|
|
53
|
+
"-P", p12_pass,
|
|
54
|
+
"-A",
|
|
55
|
+
"-t", "cert",
|
|
56
|
+
"-f", "pkcs12",
|
|
57
|
+
"-k", keychain_path,
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
subprocess.check_call(
|
|
61
|
+
[
|
|
62
|
+
"security", "set-key-partition-list",
|
|
63
|
+
"-S", "apple-tool:,apple:,codesign:",
|
|
64
|
+
"-s",
|
|
65
|
+
"-k", keychain_pass,
|
|
66
|
+
keychain_path,
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prepend_to_user_search_list(keychain_path: str) -> None:
|
|
72
|
+
existing = subprocess.check_output(
|
|
73
|
+
["security", "list-keychains", "-d", "user"]
|
|
74
|
+
).decode()
|
|
75
|
+
existing_list = [
|
|
76
|
+
line.strip().strip('"')
|
|
77
|
+
for line in existing.splitlines()
|
|
78
|
+
if line.strip()
|
|
79
|
+
]
|
|
80
|
+
new_list = [keychain_path] + [
|
|
81
|
+
k for k in existing_list if k != keychain_path
|
|
82
|
+
]
|
|
83
|
+
subprocess.check_call(
|
|
84
|
+
["security", "list-keychains", "-d", "user", "-s", *new_list]
|
|
85
|
+
)
|
|
86
|
+
subprocess.check_call(
|
|
87
|
+
["security", "default-keychain", "-s", keychain_path]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def setup_keychain(p12_path: Path, p12_pass: str, runner_temp: str) -> str:
|
|
92
|
+
"""Create + populate the throwaway CI keychain; return its path.
|
|
93
|
+
|
|
94
|
+
``runner_temp`` is the GitHub Actions temp directory (``$RUNNER_TEMP``);
|
|
95
|
+
the keychain lives there so it auto-cleans when the job finishes.
|
|
96
|
+
Returns the keychain path so callers can reference it for diagnostics.
|
|
97
|
+
"""
|
|
98
|
+
keychain_path = os.path.join(runner_temp, _KEYCHAIN_FILENAME)
|
|
99
|
+
_create_keychain(keychain_path, _KEYCHAIN_PASS)
|
|
100
|
+
_import_p12(keychain_path, _KEYCHAIN_PASS, p12_path, p12_pass)
|
|
101
|
+
_prepend_to_user_search_list(keychain_path)
|
|
102
|
+
print(f"Keychain ready: {keychain_path}")
|
|
103
|
+
return keychain_path
|