@daemux/store-automator 0.10.61 → 0.10.62
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/github/workflows/ios-native-release.yml +39 -0
- package/templates/scripts/ci/ios-native/next_build_number.py +73 -0
- package/templates/scripts/ci/ios-native/next_marketing_version.py +93 -0
- package/templates/scripts/ci/ios-native/prepare_signing.py +361 -0
|
@@ -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.62"
|
|
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.62",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: iOS Deploy
|
|
2
|
+
|
|
3
|
+
# Native-Swift iOS release pipeline. Archives the app and uploads it to
|
|
4
|
+
# TestFlight via the App Store Connect API.
|
|
5
|
+
#
|
|
6
|
+
# Required repository secrets:
|
|
7
|
+
# ASC_KEY_ID App Store Connect API key id (e.g. "5NBDY6YXJ6")
|
|
8
|
+
# ASC_ISSUER_ID App Store Connect API issuer id (uuid)
|
|
9
|
+
# ASC_KEY_P8 Full contents of the AuthKey_*.p8 file
|
|
10
|
+
#
|
|
11
|
+
# Edit the `with:` block below to match your project.
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
branches: [main]
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
|
|
18
|
+
concurrency:
|
|
19
|
+
group: ios-deploy-${{ github.ref }}
|
|
20
|
+
cancel-in-progress: false
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
deploy:
|
|
24
|
+
runs-on: macos-latest
|
|
25
|
+
timeout-minutes: 60
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
|
|
29
|
+
- uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
|
|
30
|
+
with:
|
|
31
|
+
project: MyApp.xcodeproj
|
|
32
|
+
scheme: MyApp
|
|
33
|
+
bundle-id: com.example.myapp
|
|
34
|
+
team-id: ABCDE12345
|
|
35
|
+
app-store-apple-id: "1234567890"
|
|
36
|
+
env:
|
|
37
|
+
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
|
38
|
+
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
|
39
|
+
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Query App Store Connect for the highest-numbered build (across all states,
|
|
4
|
+
all platforms) for this app and print `<highest + 1>` to stdout.
|
|
5
|
+
|
|
6
|
+
If no builds exist yet, print 1.
|
|
7
|
+
|
|
8
|
+
Environment:
|
|
9
|
+
ASC_KEY_ID - API key ID
|
|
10
|
+
ASC_ISSUER_ID - API issuer ID
|
|
11
|
+
ASC_KEY_PATH - Path to the .p8 key file
|
|
12
|
+
APP_STORE_APPLE_ID - App Store numeric app id
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import jwt
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def env(name: str) -> str:
|
|
26
|
+
val = os.environ.get(name)
|
|
27
|
+
if not val:
|
|
28
|
+
raise SystemExit(f"missing env var: {name}")
|
|
29
|
+
return val
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
key_id = env("ASC_KEY_ID")
|
|
34
|
+
issuer_id = env("ASC_ISSUER_ID")
|
|
35
|
+
key_path = env("ASC_KEY_PATH")
|
|
36
|
+
app_id = env("APP_STORE_APPLE_ID")
|
|
37
|
+
|
|
38
|
+
with open(key_path) as f:
|
|
39
|
+
key = f.read()
|
|
40
|
+
|
|
41
|
+
now = int(time.time())
|
|
42
|
+
token = jwt.encode(
|
|
43
|
+
{"iss": issuer_id, "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"},
|
|
44
|
+
key,
|
|
45
|
+
algorithm="ES256",
|
|
46
|
+
headers={"kid": key_id, "typ": "JWT"},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Sort by -version requests highest version first; limit 200 is plenty.
|
|
50
|
+
url = (
|
|
51
|
+
"https://api.appstoreconnect.apple.com/v1/builds"
|
|
52
|
+
f"?filter[app]={app_id}&sort=-version&limit=200"
|
|
53
|
+
)
|
|
54
|
+
resp = requests.get(url, headers={"Authorization": f"Bearer {token}"})
|
|
55
|
+
if resp.status_code >= 400:
|
|
56
|
+
print(f"ASC builds list failed: {resp.status_code}\n{resp.text[:500]}", file=sys.stderr)
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
highest = 0
|
|
60
|
+
for b in resp.json().get("data", []):
|
|
61
|
+
ver = b.get("attributes", {}).get("version")
|
|
62
|
+
try:
|
|
63
|
+
n = int(ver)
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
continue
|
|
66
|
+
if n > highest:
|
|
67
|
+
highest = n
|
|
68
|
+
|
|
69
|
+
print(highest + 1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
main()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Compute the next CFBundleShortVersionString for this app.
|
|
4
|
+
|
|
5
|
+
Strategy:
|
|
6
|
+
* Look at all existing pre-release (TestFlight) and App Store versions.
|
|
7
|
+
* Take the highest semantic-version triple and bump its patch component.
|
|
8
|
+
* If no versions exist yet, return 1.0.0.
|
|
9
|
+
|
|
10
|
+
Prints the chosen version to stdout.
|
|
11
|
+
|
|
12
|
+
Environment:
|
|
13
|
+
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import jwt
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def env(name: str) -> str:
|
|
28
|
+
v = os.environ.get(name)
|
|
29
|
+
if not v:
|
|
30
|
+
raise SystemExit(f"missing env var: {name}")
|
|
31
|
+
return v
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_sem(v: str) -> tuple[int, int, int] | None:
|
|
38
|
+
m = SEM_RE.match(v or "")
|
|
39
|
+
if not m:
|
|
40
|
+
return None
|
|
41
|
+
major = int(m.group(1))
|
|
42
|
+
minor = int(m.group(2))
|
|
43
|
+
patch = int(m.group(3) or 0)
|
|
44
|
+
return major, minor, patch
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> None:
|
|
48
|
+
key_id = env("ASC_KEY_ID")
|
|
49
|
+
issuer_id = env("ASC_ISSUER_ID")
|
|
50
|
+
key_path = env("ASC_KEY_PATH")
|
|
51
|
+
app_id = env("APP_STORE_APPLE_ID")
|
|
52
|
+
|
|
53
|
+
with open(key_path) as f:
|
|
54
|
+
key = f.read()
|
|
55
|
+
now = int(time.time())
|
|
56
|
+
token = jwt.encode(
|
|
57
|
+
{"iss": issuer_id, "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"},
|
|
58
|
+
key,
|
|
59
|
+
algorithm="ES256",
|
|
60
|
+
headers={"kid": key_id, "typ": "JWT"},
|
|
61
|
+
)
|
|
62
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
63
|
+
|
|
64
|
+
highest: tuple[int, int, int] = (0, 0, 0)
|
|
65
|
+
|
|
66
|
+
for endpoint, field in (
|
|
67
|
+
(f"/v1/apps/{app_id}/preReleaseVersions?limit=200", "version"),
|
|
68
|
+
(f"/v1/apps/{app_id}/appStoreVersions?limit=200", "versionString"),
|
|
69
|
+
):
|
|
70
|
+
url = f"https://api.appstoreconnect.apple.com{endpoint}"
|
|
71
|
+
resp = requests.get(url, headers=headers)
|
|
72
|
+
if resp.status_code >= 400:
|
|
73
|
+
print(
|
|
74
|
+
f"ASC GET {endpoint} failed: {resp.status_code}\n{resp.text[:500]}",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
raise SystemExit(1)
|
|
78
|
+
for item in resp.json().get("data", []):
|
|
79
|
+
v = item.get("attributes", {}).get(field)
|
|
80
|
+
parsed = parse_sem(v)
|
|
81
|
+
if parsed and parsed > highest:
|
|
82
|
+
highest = parsed
|
|
83
|
+
|
|
84
|
+
if highest == (0, 0, 0):
|
|
85
|
+
print("1.0.0")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
major, minor, patch = highest
|
|
89
|
+
print(f"{major}.{minor}.{patch + 1}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Prepare iOS code signing on a fresh CI runner.
|
|
4
|
+
|
|
5
|
+
Uses the App Store Connect API (auth via P8 key) to:
|
|
6
|
+
1. Generate an RSA private key + CSR
|
|
7
|
+
2. Revoke any previously-created CI Apple Distribution certs (marker match)
|
|
8
|
+
3. Create a new Apple Distribution certificate from the CSR
|
|
9
|
+
4. Ensure a provisioning profile with a known name exists for the bundle ID,
|
|
10
|
+
linked to the new cert. If it exists, delete+recreate to refresh it.
|
|
11
|
+
5. Install the profile into ~/Library/MobileDevice/Provisioning Profiles/
|
|
12
|
+
6. Import cert + private key into a dedicated temporary keychain and put
|
|
13
|
+
that keychain on the search list so codesign / xcodebuild can find it.
|
|
14
|
+
|
|
15
|
+
Environment inputs:
|
|
16
|
+
ASC_KEY_ID - App Store Connect API key ID
|
|
17
|
+
ASC_ISSUER_ID - App Store Connect API issuer ID
|
|
18
|
+
ASC_KEY_PATH - Path to the .p8 private key file
|
|
19
|
+
TEAM_ID - Apple developer team ID
|
|
20
|
+
BUNDLE_ID - App bundle identifier
|
|
21
|
+
PROFILE_NAME - Desired provisioning profile name
|
|
22
|
+
RUNNER_TEMP - GitHub Actions temp dir (for keychain + intermediates)
|
|
23
|
+
|
|
24
|
+
Writes nothing to stdout that would leak secrets.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import jwt
|
|
38
|
+
import requests
|
|
39
|
+
from cryptography import x509
|
|
40
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
41
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
42
|
+
from cryptography.x509.oid import NameOID
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
|
|
46
|
+
CERT_MARKER = "ScudoVPN-CI"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def env(name: str) -> str:
|
|
50
|
+
val = os.environ.get(name)
|
|
51
|
+
if not val:
|
|
52
|
+
raise SystemExit(f"missing env var: {name}")
|
|
53
|
+
return val
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
|
|
57
|
+
with open(key_path, "r") as f:
|
|
58
|
+
key = f.read()
|
|
59
|
+
now = int(time.time())
|
|
60
|
+
payload = {
|
|
61
|
+
"iss": issuer_id,
|
|
62
|
+
"iat": now,
|
|
63
|
+
"exp": now + 600,
|
|
64
|
+
"aud": "appstoreconnect-v1",
|
|
65
|
+
}
|
|
66
|
+
return jwt.encode(
|
|
67
|
+
payload, key, algorithm="ES256", headers={"kid": key_id, "typ": "JWT"}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def api(method: str, path: str, token: str, **kwargs) -> requests.Response:
|
|
72
|
+
url = path if path.startswith("http") else f"{ASC_BASE}{path}"
|
|
73
|
+
headers = kwargs.pop("headers", {})
|
|
74
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
75
|
+
if "json" in kwargs:
|
|
76
|
+
headers["Content-Type"] = "application/json"
|
|
77
|
+
resp = requests.request(method, url, headers=headers, **kwargs)
|
|
78
|
+
if resp.status_code >= 400:
|
|
79
|
+
raise SystemExit(
|
|
80
|
+
f"ASC API {method} {path} failed: {resp.status_code}\n{resp.text[:2000]}"
|
|
81
|
+
)
|
|
82
|
+
return resp
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes, bytes]:
|
|
86
|
+
"""Return (private_key, private_key_pem, csr_der)."""
|
|
87
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
88
|
+
csr = (
|
|
89
|
+
x509.CertificateSigningRequestBuilder()
|
|
90
|
+
.subject_name(
|
|
91
|
+
x509.Name(
|
|
92
|
+
[
|
|
93
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "ScudoVPN CI"),
|
|
94
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
.sign(private_key, hashes.SHA256())
|
|
99
|
+
)
|
|
100
|
+
priv_pem = private_key.private_bytes(
|
|
101
|
+
encoding=serialization.Encoding.PEM,
|
|
102
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
103
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
104
|
+
)
|
|
105
|
+
# Apple wants the CSR PEM base64 (without headers)
|
|
106
|
+
csr_pem = csr.public_bytes(serialization.Encoding.PEM)
|
|
107
|
+
csr_payload = b"".join(
|
|
108
|
+
line for line in csr_pem.splitlines() if not line.startswith(b"-----")
|
|
109
|
+
)
|
|
110
|
+
return private_key, priv_pem, csr_payload
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def newest_distribution_cert_id(token: str) -> str | None:
|
|
114
|
+
"""Return the ID of the most-recently-created DISTRIBUTION cert, or None."""
|
|
115
|
+
resp = api(
|
|
116
|
+
"GET",
|
|
117
|
+
"/certificates?limit=200&sort=-id&filter[certificateType]=DISTRIBUTION",
|
|
118
|
+
token,
|
|
119
|
+
)
|
|
120
|
+
newest_id = None
|
|
121
|
+
newest_exp = ""
|
|
122
|
+
for cert in resp.json().get("data", []):
|
|
123
|
+
attrs = cert.get("attributes") or {}
|
|
124
|
+
# Use expirationDate as a proxy for creation time (cert expiration
|
|
125
|
+
# is exactly creation + 1 year for Apple Distribution certs).
|
|
126
|
+
exp = attrs.get("expirationDate") or ""
|
|
127
|
+
if exp > newest_exp:
|
|
128
|
+
newest_exp = exp
|
|
129
|
+
newest_id = cert["id"]
|
|
130
|
+
return newest_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def revoke_cert(token: str, cert_id: str) -> None:
|
|
134
|
+
print(f"Revoking distribution cert {cert_id}")
|
|
135
|
+
api("DELETE", f"/certificates/{cert_id}", token)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
139
|
+
"""Return (certificate_id, DER-encoded certificate bytes).
|
|
140
|
+
|
|
141
|
+
Apple enforces a per-team cap on active Distribution certs. If the POST
|
|
142
|
+
comes back 409 (already have one or pending), revoke the newest existing
|
|
143
|
+
one and retry once. This is the CI-owned cert from a prior run.
|
|
144
|
+
"""
|
|
145
|
+
body = {
|
|
146
|
+
"data": {
|
|
147
|
+
"type": "certificates",
|
|
148
|
+
"attributes": {
|
|
149
|
+
"csrContent": csr_b64,
|
|
150
|
+
"certificateType": "DISTRIBUTION",
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
url = f"{ASC_BASE}/certificates"
|
|
155
|
+
headers = {
|
|
156
|
+
"Authorization": f"Bearer {token}",
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
}
|
|
159
|
+
resp = requests.post(url, headers=headers, json=body)
|
|
160
|
+
if resp.status_code == 409:
|
|
161
|
+
print("Distribution cert cap hit; revoking newest existing cert")
|
|
162
|
+
target = newest_distribution_cert_id(token)
|
|
163
|
+
if not target:
|
|
164
|
+
raise SystemExit(
|
|
165
|
+
"409 from cert create but no existing DISTRIBUTION cert "
|
|
166
|
+
"found to revoke"
|
|
167
|
+
)
|
|
168
|
+
revoke_cert(token, target)
|
|
169
|
+
resp = requests.post(url, headers=headers, json=body)
|
|
170
|
+
if resp.status_code >= 400:
|
|
171
|
+
raise SystemExit(
|
|
172
|
+
f"ASC API POST /certificates failed: {resp.status_code}\n"
|
|
173
|
+
f"{resp.text[:2000]}"
|
|
174
|
+
)
|
|
175
|
+
data = resp.json()["data"]
|
|
176
|
+
cert_id = data["id"]
|
|
177
|
+
cert_content_b64 = data["attributes"]["certificateContent"]
|
|
178
|
+
cert_der = base64.b64decode(cert_content_b64)
|
|
179
|
+
print(f"Created DISTRIBUTION cert {cert_id}")
|
|
180
|
+
return cert_id, cert_der
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def find_bundle_id(token: str, identifier: str) -> str:
|
|
184
|
+
resp = api(
|
|
185
|
+
"GET", f"/bundleIds?filter[identifier]={identifier}&limit=5", token
|
|
186
|
+
)
|
|
187
|
+
for item in resp.json().get("data", []):
|
|
188
|
+
if item["attributes"]["identifier"] == identifier:
|
|
189
|
+
return item["id"]
|
|
190
|
+
raise SystemExit(f"bundle id {identifier!r} not found in ASC")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def delete_profile_by_name(token: str, name: str) -> None:
|
|
194
|
+
resp = api("GET", "/profiles?limit=200", token)
|
|
195
|
+
for p in resp.json().get("data", []):
|
|
196
|
+
if (p["attributes"].get("name") or "") == name:
|
|
197
|
+
pid = p["id"]
|
|
198
|
+
print(f"Deleting existing profile {pid} ({name})")
|
|
199
|
+
api("DELETE", f"/profiles/{pid}", token)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def create_profile(
|
|
203
|
+
token: str, name: str, bundle_id_pk: str, cert_id: str
|
|
204
|
+
) -> bytes:
|
|
205
|
+
body = {
|
|
206
|
+
"data": {
|
|
207
|
+
"type": "profiles",
|
|
208
|
+
"attributes": {
|
|
209
|
+
"name": name,
|
|
210
|
+
"profileType": "IOS_APP_STORE",
|
|
211
|
+
},
|
|
212
|
+
"relationships": {
|
|
213
|
+
"bundleId": {"data": {"type": "bundleIds", "id": bundle_id_pk}},
|
|
214
|
+
"certificates": {
|
|
215
|
+
"data": [{"type": "certificates", "id": cert_id}]
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
resp = api("POST", "/profiles", token, json=body)
|
|
221
|
+
data = resp.json()["data"]
|
|
222
|
+
profile_content_b64 = data["attributes"]["profileContent"]
|
|
223
|
+
return base64.b64decode(profile_content_b64)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def install_profile(profile_der: bytes) -> str:
|
|
227
|
+
"""Write the .mobileprovision file and return its uuid."""
|
|
228
|
+
# Extract UUID from profile (it's CMS-signed plist)
|
|
229
|
+
# Use `security cms -D -i <path>` to decode.
|
|
230
|
+
profiles_dir = Path.home() / "Library/MobileDevice/Provisioning Profiles"
|
|
231
|
+
profiles_dir.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
tmp_path = profiles_dir / "tmp.mobileprovision"
|
|
233
|
+
tmp_path.write_bytes(profile_der)
|
|
234
|
+
decoded = subprocess.check_output(
|
|
235
|
+
["security", "cms", "-D", "-i", str(tmp_path)]
|
|
236
|
+
)
|
|
237
|
+
import plistlib
|
|
238
|
+
|
|
239
|
+
plist = plistlib.loads(decoded)
|
|
240
|
+
uuid = plist["UUID"]
|
|
241
|
+
final_path = profiles_dir / f"{uuid}.mobileprovision"
|
|
242
|
+
tmp_path.rename(final_path)
|
|
243
|
+
print(f"Installed provisioning profile {uuid} at {final_path}")
|
|
244
|
+
return uuid
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def write_p12(
|
|
248
|
+
private_key: rsa.RSAPrivateKey, cert_der: bytes, out_path: Path, passwd: str
|
|
249
|
+
) -> None:
|
|
250
|
+
cert = x509.load_der_x509_certificate(cert_der)
|
|
251
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
252
|
+
|
|
253
|
+
p12 = pkcs12.serialize_key_and_certificates(
|
|
254
|
+
name=b"ScudoVPN CI",
|
|
255
|
+
key=private_key,
|
|
256
|
+
cert=cert,
|
|
257
|
+
cas=None,
|
|
258
|
+
encryption_algorithm=serialization.BestAvailableEncryption(
|
|
259
|
+
passwd.encode()
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
out_path.write_bytes(p12)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def setup_keychain(p12_path: Path, p12_pass: str) -> str:
|
|
266
|
+
runner_temp = env("RUNNER_TEMP")
|
|
267
|
+
keychain_path = os.path.join(runner_temp, "ci.keychain-db")
|
|
268
|
+
keychain_pass = "ci"
|
|
269
|
+
# Remove stale
|
|
270
|
+
subprocess.run(
|
|
271
|
+
["security", "delete-keychain", keychain_path],
|
|
272
|
+
check=False,
|
|
273
|
+
capture_output=True,
|
|
274
|
+
)
|
|
275
|
+
subprocess.check_call(
|
|
276
|
+
["security", "create-keychain", "-p", keychain_pass, keychain_path]
|
|
277
|
+
)
|
|
278
|
+
subprocess.check_call(
|
|
279
|
+
["security", "set-keychain-settings", "-lut", "21600", keychain_path]
|
|
280
|
+
)
|
|
281
|
+
subprocess.check_call(
|
|
282
|
+
["security", "unlock-keychain", "-p", keychain_pass, keychain_path]
|
|
283
|
+
)
|
|
284
|
+
# Import the p12
|
|
285
|
+
subprocess.check_call(
|
|
286
|
+
[
|
|
287
|
+
"security",
|
|
288
|
+
"import",
|
|
289
|
+
str(p12_path),
|
|
290
|
+
"-P",
|
|
291
|
+
p12_pass,
|
|
292
|
+
"-A", # allow any app to read (simplest for CI)
|
|
293
|
+
"-t",
|
|
294
|
+
"cert",
|
|
295
|
+
"-f",
|
|
296
|
+
"pkcs12",
|
|
297
|
+
"-k",
|
|
298
|
+
keychain_path,
|
|
299
|
+
]
|
|
300
|
+
)
|
|
301
|
+
# Allow codesign to use the key without prompts (modern macOS)
|
|
302
|
+
subprocess.check_call(
|
|
303
|
+
[
|
|
304
|
+
"security",
|
|
305
|
+
"set-key-partition-list",
|
|
306
|
+
"-S",
|
|
307
|
+
"apple-tool:,apple:,codesign:",
|
|
308
|
+
"-s",
|
|
309
|
+
"-k",
|
|
310
|
+
keychain_pass,
|
|
311
|
+
keychain_path,
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
# Add to default search list (in addition to login + System)
|
|
315
|
+
existing = subprocess.check_output(
|
|
316
|
+
["security", "list-keychains", "-d", "user"]
|
|
317
|
+
).decode()
|
|
318
|
+
existing_list = [
|
|
319
|
+
line.strip().strip('"')
|
|
320
|
+
for line in existing.splitlines()
|
|
321
|
+
if line.strip()
|
|
322
|
+
]
|
|
323
|
+
new_list = [keychain_path] + [
|
|
324
|
+
k for k in existing_list if k != keychain_path
|
|
325
|
+
]
|
|
326
|
+
subprocess.check_call(
|
|
327
|
+
["security", "list-keychains", "-d", "user", "-s", *new_list]
|
|
328
|
+
)
|
|
329
|
+
subprocess.check_call(
|
|
330
|
+
["security", "default-keychain", "-s", keychain_path]
|
|
331
|
+
)
|
|
332
|
+
print(f"Keychain ready: {keychain_path}")
|
|
333
|
+
return keychain_path
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main() -> None:
|
|
337
|
+
key_id = env("ASC_KEY_ID")
|
|
338
|
+
issuer_id = env("ASC_ISSUER_ID")
|
|
339
|
+
asc_key_path = env("ASC_KEY_PATH")
|
|
340
|
+
bundle_id = env("BUNDLE_ID")
|
|
341
|
+
profile_name = env("PROFILE_NAME")
|
|
342
|
+
runner_temp = env("RUNNER_TEMP")
|
|
343
|
+
|
|
344
|
+
token = make_jwt(key_id, issuer_id, asc_key_path)
|
|
345
|
+
|
|
346
|
+
private_key, priv_pem, csr_b64 = generate_key_and_csr()
|
|
347
|
+
cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
|
|
348
|
+
|
|
349
|
+
bundle_pk = find_bundle_id(token, bundle_id)
|
|
350
|
+
delete_profile_by_name(token, profile_name)
|
|
351
|
+
profile_der = create_profile(token, profile_name, bundle_pk, cert_id)
|
|
352
|
+
install_profile(profile_der)
|
|
353
|
+
|
|
354
|
+
p12_pass = "ci"
|
|
355
|
+
p12_path = Path(runner_temp) / "cert.p12"
|
|
356
|
+
write_p12(private_key, cert_der, p12_path, p12_pass)
|
|
357
|
+
setup_keychain(p12_path, p12_pass)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
main()
|