@daemux/store-automator 0.10.60 → 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/plugins/store-automator/agents/architect.md +1 -1
- package/plugins/store-automator/agents/product-manager.md +1 -1
- package/templates/CLAUDE.md.template +5 -5
- 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
|
@@ -134,7 +134,7 @@ Include in blueprint output:
|
|
|
134
134
|
|
|
135
135
|
## Large Task Batching
|
|
136
136
|
|
|
137
|
-
If a `.
|
|
137
|
+
If a `.tasks/` file path is provided, read ONLY that file for requirements.
|
|
138
138
|
Scan the codebase for already-implemented items. Pick 3-5 UNIMPLEMENTED
|
|
139
139
|
related requirements. Design only those. Report: "Batch: N of ~M remaining."
|
|
140
140
|
|
|
@@ -203,7 +203,7 @@ Simplicity is a feature:
|
|
|
203
203
|
|
|
204
204
|
## Large Task Completion Check
|
|
205
205
|
|
|
206
|
-
If a `.
|
|
206
|
+
If a `.tasks/` file path is provided, read it and compare all requirements against
|
|
207
207
|
the codebase. NEVER declare COMPLETE while unimplemented requirements remain.
|
|
208
208
|
When all requirements are implemented: delete the task file.
|
|
209
209
|
Output: "Remaining: N requirements" or "All requirements implemented -- task file deleted."
|
|
@@ -315,7 +315,7 @@ appstore-meta-creator generates texts. Fill fastlane/iap_config.json if needed.
|
|
|
315
315
|
app-designer designs marketing page in Stitch MCP. Develop web pages. Deploy via Cloudflare Pages (*.account-subdomain.workers.dev domain sufficient).
|
|
316
316
|
|
|
317
317
|
### Phase 5: Finalize and CI/CD
|
|
318
|
-
Create .gitignore (.
|
|
318
|
+
Create .gitignore (.tasks/, Flutter ignores; do NOT ignore *.g.dart). Push to private repo. GitHub Actions auto-triggers on push.
|
|
319
319
|
|
|
320
320
|
### Phase 6: First Publish
|
|
321
321
|
iOS: create app record locally first (`fastlane create_app_ios` + `upload_privacy_ios`, see "New App Setup" above), then CI is fully automated. Android: first build creates AAB + manual steps guide; subsequent builds fully automated.
|
|
@@ -411,15 +411,15 @@ Spawn each teammate with detailed prompt including:
|
|
|
411
411
|
|
|
412
412
|
For tasks with 5+ requirements:
|
|
413
413
|
|
|
414
|
-
1. **Save requirements** to `.
|
|
415
|
-
2. **Each session:** pass the exact file path (e.g., `.
|
|
414
|
+
1. **Save requirements** to `.tasks/{short-topic}.md` before starting any agent (create `.tasks/` directory if it doesn't exist)
|
|
415
|
+
2. **Each session:** pass the exact file path (e.g., `.tasks/auth-system.md`) to architect and product-manager
|
|
416
416
|
3. **After each batch:** `/clear` and continue — tell user which task file to reference
|
|
417
417
|
4. **Done when:** product-manager confirms zero remaining and deletes the task file
|
|
418
418
|
|
|
419
419
|
Rules:
|
|
420
|
-
- ALWAYS pass the exact `.
|
|
420
|
+
- ALWAYS pass the exact `.tasks/` file path when calling architect and product-manager agents
|
|
421
421
|
- Architect and product-manager will read ONLY the specified file — never guess the filename
|
|
422
|
-
- Add `.
|
|
422
|
+
- Add `.tasks/` to `.gitignore`
|
|
423
423
|
|
|
424
424
|
### Output Format and Continuation
|
|
425
425
|
|
|
@@ -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()
|