@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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.61"
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.61",
15
+ "version": "0.10.62",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.61",
3
+ "version": "0.10.62",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.61",
3
+ "version": "0.10.62",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -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()