@daemux/store-automator 0.10.66 → 0.10.67

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.66"
8
+ "version": "0.10.67"
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.66",
15
+ "version": "0.10.67",
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.66",
3
+ "version": "0.10.67",
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.66",
3
+ "version": "0.10.67",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared helpers for App Store Connect API calls.
4
+
5
+ Provides JWT generation, a retrying HTTP client, and state-classification
6
+ constants used by multiple scripts in this action.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import time
13
+ from typing import Any
14
+
15
+ import jwt
16
+ import requests
17
+
18
+
19
+ ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
20
+
21
+ # App Store version states that allow editing the current draft.
22
+ EDITABLE_STATES = {
23
+ "PREPARE_FOR_SUBMISSION",
24
+ "WAITING_FOR_REVIEW",
25
+ "IN_REVIEW",
26
+ "REJECTED",
27
+ "METADATA_REJECTED",
28
+ "INVALID_BINARY",
29
+ "DEVELOPER_REJECTED",
30
+ }
31
+
32
+ # Terminal states: the version is locked and a new one must be created.
33
+ TERMINAL_STATES = {
34
+ "READY_FOR_SALE",
35
+ "PROCESSING_FOR_APP_STORE",
36
+ "PENDING_APPLE_RELEASE",
37
+ "REPLACED_WITH_NEW_VERSION",
38
+ "REMOVED_FROM_SALE",
39
+ "NOT_APPLICABLE",
40
+ }
41
+
42
+ # Blocking states: the version is approved but awaiting developer action.
43
+ BLOCKING_STATES = {"PENDING_DEVELOPER_RELEASE"}
44
+
45
+
46
+ # Retry policy for App Store Connect API calls.
47
+ #
48
+ # Apple's API intermittently returns 5xx under load and uses 429 for throttling.
49
+ # These are retryable; non-listed 4xx responses are not (retrying just produces
50
+ # the same failure).
51
+ _RETRY_STATUSES = {429, 500, 502, 503, 504}
52
+ _RETRY_BACKOFFS = (2, 8, 30) # seconds
53
+
54
+
55
+ def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
56
+ """Return an ES256 JWT valid for 20 minutes, audience appstoreconnect-v1."""
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 + 1200,
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 request(
72
+ method: str,
73
+ path: str,
74
+ token: str,
75
+ *,
76
+ json_body: Any = None,
77
+ params: dict | None = None,
78
+ allow_status: set[int] | None = None,
79
+ max_attempts: int = 3,
80
+ ) -> requests.Response:
81
+ """HTTP request with retry on 429/5xx.
82
+
83
+ `path` is the portion after `/v1` (e.g. "/apps/123/appStoreVersions").
84
+ `allow_status` lists non-2xx statuses the caller wants returned without
85
+ raising (useful for 409 conflict handling).
86
+ Raises SystemExit(1) on non-retryable failure with a clear stderr message.
87
+ """
88
+ url = f"{ASC_BASE}{path}"
89
+ headers = {"Authorization": f"Bearer {token}"}
90
+ if json_body is not None:
91
+ headers["Content-Type"] = "application/json"
92
+
93
+ backoffs = _RETRY_BACKOFFS[: max(0, max_attempts - 1)]
94
+ total_attempts = len(backoffs) + 1
95
+ resp: requests.Response | None = None
96
+
97
+ for attempt in range(total_attempts):
98
+ try:
99
+ resp = requests.request(
100
+ method, url, headers=headers, params=params, json=json_body
101
+ )
102
+ except requests.RequestException as exc:
103
+ if attempt < total_attempts - 1:
104
+ delay = backoffs[attempt]
105
+ print(
106
+ f"ASC {method} {path} network error ({exc!r}); "
107
+ f"retrying in {delay}s ({attempt + 1}/{total_attempts - 1})",
108
+ file=sys.stderr,
109
+ )
110
+ time.sleep(delay)
111
+ continue
112
+ raise SystemExit(
113
+ f"ASC {method} {path} network error after {total_attempts} "
114
+ f"attempts: {exc!r}"
115
+ )
116
+
117
+ if resp.status_code < 400:
118
+ return resp
119
+
120
+ if allow_status and resp.status_code in allow_status:
121
+ return resp
122
+
123
+ if resp.status_code in _RETRY_STATUSES and attempt < total_attempts - 1:
124
+ delay = backoffs[attempt]
125
+ print(
126
+ f"ASC {method} {path} returned {resp.status_code}; "
127
+ f"retrying in {delay}s ({attempt + 1}/{total_attempts - 1})",
128
+ file=sys.stderr,
129
+ )
130
+ time.sleep(delay)
131
+ continue
132
+
133
+ break
134
+
135
+ assert resp is not None
136
+ raise SystemExit(
137
+ f"ASC {method} {path} failed: {resp.status_code}\n{resp.text[:2000]}"
138
+ )
139
+
140
+
141
+ def get_json(path: str, token: str, *, params: dict | None = None) -> dict:
142
+ """GET `path` and return the decoded JSON body."""
143
+ return request("GET", path, token, params=params).json()
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Resolve the App Store Connect version slot for this run.
4
+
5
+ Reads MARKETING_VERSION from the Xcode project (via env) and decides whether
6
+ to REUSE an existing editable draft on ASC or CREATE a new one. Never
7
+ auto-bumps the version — the developer pins intent in Xcode, CI honours it.
8
+
9
+ Environment:
10
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID
11
+ PROJECT_MARKETING_VERSION — e.g. "1.2.3" or "1.2" (normalized to 3-part)
12
+
13
+ Stdout: single-line JSON
14
+ {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
15
+
16
+ Stderr: human-readable log + ::error:: lines for GitHub Actions.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import sys
25
+
26
+ from asc_common import (
27
+ BLOCKING_STATES,
28
+ EDITABLE_STATES,
29
+ TERMINAL_STATES,
30
+ get_json,
31
+ make_jwt,
32
+ request,
33
+ )
34
+
35
+
36
+ SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
37
+
38
+
39
+ def env(name: str) -> str:
40
+ val = os.environ.get(name)
41
+ if not val:
42
+ print(f"::error::Missing required env var: {name}", file=sys.stderr)
43
+ raise SystemExit(1)
44
+ return val
45
+
46
+
47
+ def normalize(v: str) -> str:
48
+ m = SEM_RE.match(v)
49
+ if not m:
50
+ print(
51
+ f"::error::PROJECT_MARKETING_VERSION ({v!r}) is not a valid "
52
+ f"semantic version (expected MAJOR.MINOR[.PATCH]).",
53
+ file=sys.stderr,
54
+ )
55
+ raise SystemExit(1)
56
+ major, minor, patch = m.group(1), m.group(2), m.group(3) or "0"
57
+ return f"{major}.{minor}.{patch}"
58
+
59
+
60
+ def to_tuple(v: str) -> tuple[int, int, int]:
61
+ parts = v.split(".")
62
+ while len(parts) < 3:
63
+ parts.append("0")
64
+ return int(parts[0]), int(parts[1]), int(parts[2])
65
+
66
+
67
+ def fetch_versions(app_id: str, token: str) -> list[dict]:
68
+ """Return list of {'versionString','state','id'} for IOS versions."""
69
+ data = get_json(
70
+ f"/apps/{app_id}/appStoreVersions",
71
+ token,
72
+ params={
73
+ "limit": 200,
74
+ "sort": "-createdDate",
75
+ "filter[platform]": "IOS",
76
+ },
77
+ )
78
+ out: list[dict] = []
79
+ for item in data.get("data", []):
80
+ attrs = item.get("attributes") or {}
81
+ out.append(
82
+ {
83
+ "versionString": attrs.get("versionString") or "",
84
+ "state": attrs.get("appStoreState") or "",
85
+ "id": item.get("id") or "",
86
+ }
87
+ )
88
+ return out
89
+
90
+
91
+ def highest_terminal(versions: list[dict]) -> tuple[int, int, int] | None:
92
+ best: tuple[int, int, int] | None = None
93
+ for v in versions:
94
+ if v["state"] not in TERMINAL_STATES:
95
+ continue
96
+ parsed = SEM_RE.match(v["versionString"])
97
+ if not parsed:
98
+ continue
99
+ t = to_tuple(v["versionString"])
100
+ if best is None or t > best:
101
+ best = t
102
+ return best
103
+
104
+
105
+ def fail_terminal(v: str, state: str) -> None:
106
+ print(
107
+ f"::error::Version {v} is already {state} on App Store Connect. "
108
+ f"Bump MARKETING_VERSION in the Xcode project to publish a new "
109
+ f"release.",
110
+ file=sys.stderr,
111
+ )
112
+ raise SystemExit(1)
113
+
114
+
115
+ def fail_blocking(v: str) -> None:
116
+ print(
117
+ f"::error::Version {v} is PENDING_DEVELOPER_RELEASE on App Store "
118
+ f"Connect. Release it manually (or bump MARKETING_VERSION in the "
119
+ f"Xcode project), then retry.",
120
+ file=sys.stderr,
121
+ )
122
+ raise SystemExit(1)
123
+
124
+
125
+ def fail_not_higher(v: str, published: tuple[int, int, int]) -> None:
126
+ p = ".".join(str(x) for x in published)
127
+ print(
128
+ f"::error::MARKETING_VERSION ({v}) is not higher than the published "
129
+ f"version ({p}) on App Store Connect. Bump MARKETING_VERSION in the "
130
+ f"Xcode project.",
131
+ file=sys.stderr,
132
+ )
133
+ raise SystemExit(1)
134
+
135
+
136
+ def create_version(app_id: str, version: str, token: str) -> str:
137
+ body = {
138
+ "data": {
139
+ "type": "appStoreVersions",
140
+ "attributes": {
141
+ "platform": "IOS",
142
+ "versionString": version,
143
+ "releaseType": "AFTER_APPROVAL",
144
+ },
145
+ "relationships": {
146
+ "app": {"data": {"type": "apps", "id": app_id}},
147
+ },
148
+ }
149
+ }
150
+ resp = request(
151
+ "POST",
152
+ "/appStoreVersions",
153
+ token,
154
+ json_body=body,
155
+ allow_status={409},
156
+ )
157
+ if resp.status_code == 409:
158
+ # Race / eventual consistency: re-query and treat as REUSE if editable.
159
+ versions = fetch_versions(app_id, token)
160
+ match = next(
161
+ (v for v in versions if v["versionString"] == version), None
162
+ )
163
+ if match and match["state"] in EDITABLE_STATES:
164
+ print(
165
+ f"[decision] REUSE {version} (state={match['state']}, "
166
+ f"id={match['id']}) — recovered from 409 on create",
167
+ file=sys.stderr,
168
+ )
169
+ return match["id"]
170
+ if match and match["state"] in BLOCKING_STATES:
171
+ fail_blocking(version)
172
+ if match:
173
+ fail_terminal(version, match["state"])
174
+ raise SystemExit(
175
+ f"POST /appStoreVersions returned 409 but no matching version "
176
+ f"{version} was found on re-query:\n{resp.text[:500]}"
177
+ )
178
+ new_id = resp.json()["data"]["id"]
179
+ print(
180
+ f"[decision] CREATE {version} -> appStoreVersion id={new_id}",
181
+ file=sys.stderr,
182
+ )
183
+ return new_id
184
+
185
+
186
+ def main() -> None:
187
+ key_id = env("ASC_KEY_ID")
188
+ issuer_id = env("ASC_ISSUER_ID")
189
+ key_path = env("ASC_KEY_PATH")
190
+ app_id = env("APP_STORE_APPLE_ID")
191
+ project_version = normalize(env("PROJECT_MARKETING_VERSION"))
192
+
193
+ token = make_jwt(key_id, issuer_id, key_path)
194
+ versions = fetch_versions(app_id, token)
195
+ match = next(
196
+ (v for v in versions if v["versionString"] == project_version), None
197
+ )
198
+
199
+ if match and match["state"] in EDITABLE_STATES:
200
+ print(
201
+ f"[decision] REUSE {project_version} (state={match['state']}, "
202
+ f"id={match['id']})",
203
+ file=sys.stderr,
204
+ )
205
+ result = {
206
+ "decision": "REUSE",
207
+ "versionString": project_version,
208
+ "appStoreVersionId": match["id"],
209
+ }
210
+ elif match and match["state"] in BLOCKING_STATES:
211
+ fail_blocking(project_version)
212
+ return # unreachable
213
+ elif match and match["state"] in TERMINAL_STATES:
214
+ fail_terminal(project_version, match["state"])
215
+ return # unreachable
216
+ else:
217
+ top = highest_terminal(versions)
218
+ if top is not None and to_tuple(project_version) <= top:
219
+ fail_not_higher(project_version, top)
220
+ new_id = create_version(app_id, project_version, token)
221
+ result = {
222
+ "decision": "CREATE",
223
+ "versionString": project_version,
224
+ "appStoreVersionId": new_id,
225
+ }
226
+
227
+ print(json.dumps(result))
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
@@ -15,11 +15,8 @@ Environment:
15
15
  from __future__ import annotations
16
16
 
17
17
  import os
18
- import sys
19
- import time
20
18
 
21
- import jwt
22
- import requests
19
+ from asc_common import get_json, make_jwt
23
20
 
24
21
 
25
22
  def env(name: str) -> str:
@@ -29,44 +26,27 @@ def env(name: str) -> str:
29
26
  return val
30
27
 
31
28
 
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()
29
+ def _build_version_int(build: dict) -> int | None:
30
+ try:
31
+ return int(build.get("attributes", {}).get("version"))
32
+ except (TypeError, ValueError):
33
+ return None
40
34
 
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
35
 
36
+ def main() -> None:
37
+ token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
49
38
  # 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"
39
+ data = get_json(
40
+ "/builds",
41
+ token,
42
+ params={
43
+ "filter[app]": env("APP_STORE_APPLE_ID"),
44
+ "sort": "-version",
45
+ "limit": "200",
46
+ },
53
47
  )
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)
48
+ versions = (n for n in map(_build_version_int, data.get("data", [])) if n is not None)
49
+ print(max(versions, default=0) + 1)
70
50
 
71
51
 
72
52
  if __name__ == "__main__":
@@ -4,12 +4,12 @@ Prepare iOS code signing on a fresh CI runner.
4
4
 
5
5
  Uses the App Store Connect API (auth via P8 key) to:
6
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,
7
+ 2. Create a new Apple Distribution certificate from the CSR; if the per-team
8
+ cap is hit (409), revoke the newest existing DISTRIBUTION cert and retry.
9
+ 3. Ensure a provisioning profile with a known name exists for the bundle ID,
10
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
11
+ 4. Install the profile into ~/Library/MobileDevice/Provisioning Profiles/
12
+ 5. Import cert + private key into a dedicated temporary keychain and put
13
13
  that keychain on the search list so codesign / xcodebuild can find it.
14
14
 
15
15
  Environment inputs:
@@ -27,25 +27,19 @@ Writes nothing to stdout that would leak secrets.
27
27
  from __future__ import annotations
28
28
 
29
29
  import base64
30
- import json
31
30
  import os
31
+ import plistlib
32
32
  import subprocess
33
- import sys
34
- import time
35
33
  from pathlib import Path
36
34
 
37
- import jwt
38
- import requests
35
+ from asc_common import get_json, make_jwt, request
39
36
  from cryptography import x509
40
37
  from cryptography.hazmat.primitives import hashes, serialization
41
38
  from cryptography.hazmat.primitives.asymmetric import rsa
39
+ from cryptography.hazmat.primitives.serialization import pkcs12
42
40
  from cryptography.x509.oid import NameOID
43
41
 
44
42
 
45
- ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
46
- CERT_MARKER = "ScudoVPN-CI"
47
-
48
-
49
43
  def env(name: str) -> str:
50
44
  val = os.environ.get(name)
51
45
  if not val:
@@ -53,37 +47,8 @@ def env(name: str) -> str:
53
47
  return val
54
48
 
55
49
 
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)."""
50
+ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
51
+ """Return (private_key, csr_payload_b64_bytes)."""
87
52
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
88
53
  csr = (
89
54
  x509.CertificateSigningRequestBuilder()
@@ -97,29 +62,28 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes, bytes]:
97
62
  )
98
63
  .sign(private_key, hashes.SHA256())
99
64
  )
100
- priv_pem = private_key.private_bytes(
101
- encoding=serialization.Encoding.PEM,
102
- format=serialization.PrivateFormat.PKCS8,
103
- encryption_algorithm=serialization.NoEncryption(),
104
- )
105
65
  # Apple wants the CSR PEM base64 (without headers)
106
66
  csr_pem = csr.public_bytes(serialization.Encoding.PEM)
107
67
  csr_payload = b"".join(
108
68
  line for line in csr_pem.splitlines() if not line.startswith(b"-----")
109
69
  )
110
- return private_key, priv_pem, csr_payload
70
+ return private_key, csr_payload
111
71
 
112
72
 
113
73
  def newest_distribution_cert_id(token: str) -> str | None:
114
74
  """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",
75
+ data = get_json(
76
+ "/certificates",
118
77
  token,
78
+ params={
79
+ "limit": "200",
80
+ "sort": "-id",
81
+ "filter[certificateType]": "DISTRIBUTION",
82
+ },
119
83
  )
120
84
  newest_id = None
121
85
  newest_exp = ""
122
- for cert in resp.json().get("data", []):
86
+ for cert in data.get("data", []):
123
87
  attrs = cert.get("attributes") or {}
124
88
  # Use expirationDate as a proxy for creation time (cert expiration
125
89
  # is exactly creation + 1 year for Apple Distribution certs).
@@ -132,7 +96,7 @@ def newest_distribution_cert_id(token: str) -> str | None:
132
96
 
133
97
  def revoke_cert(token: str, cert_id: str) -> None:
134
98
  print(f"Revoking distribution cert {cert_id}")
135
- api("DELETE", f"/certificates/{cert_id}", token)
99
+ request("DELETE", f"/certificates/{cert_id}", token)
136
100
 
137
101
 
138
102
  def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
@@ -151,12 +115,9 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
151
115
  },
152
116
  }
153
117
  }
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)
118
+ resp = request(
119
+ "POST", "/certificates", token, json_body=body, allow_status={409}
120
+ )
160
121
  if resp.status_code == 409:
161
122
  print("Distribution cert cap hit; revoking newest existing cert")
162
123
  target = newest_distribution_cert_id(token)
@@ -166,12 +127,7 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
166
127
  "found to revoke"
167
128
  )
168
129
  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
- )
130
+ resp = request("POST", "/certificates", token, json_body=body)
175
131
  data = resp.json()["data"]
176
132
  cert_id = data["id"]
177
133
  cert_content_b64 = data["attributes"]["certificateContent"]
@@ -181,22 +137,24 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
181
137
 
182
138
 
183
139
  def find_bundle_id(token: str, identifier: str) -> str:
184
- resp = api(
185
- "GET", f"/bundleIds?filter[identifier]={identifier}&limit=5", token
140
+ data = get_json(
141
+ "/bundleIds",
142
+ token,
143
+ params={"filter[identifier]": identifier, "limit": "5"},
186
144
  )
187
- for item in resp.json().get("data", []):
145
+ for item in data.get("data", []):
188
146
  if item["attributes"]["identifier"] == identifier:
189
147
  return item["id"]
190
148
  raise SystemExit(f"bundle id {identifier!r} not found in ASC")
191
149
 
192
150
 
193
151
  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", []):
152
+ data = get_json("/profiles", token, params={"limit": "200"})
153
+ for p in data.get("data", []):
196
154
  if (p["attributes"].get("name") or "") == name:
197
155
  pid = p["id"]
198
156
  print(f"Deleting existing profile {pid} ({name})")
199
- api("DELETE", f"/profiles/{pid}", token)
157
+ request("DELETE", f"/profiles/{pid}", token)
200
158
 
201
159
 
202
160
  def create_profile(
@@ -217,7 +175,7 @@ def create_profile(
217
175
  },
218
176
  }
219
177
  }
220
- resp = api("POST", "/profiles", token, json=body)
178
+ resp = request("POST", "/profiles", token, json_body=body)
221
179
  data = resp.json()["data"]
222
180
  profile_content_b64 = data["attributes"]["profileContent"]
223
181
  return base64.b64decode(profile_content_b64)
@@ -225,8 +183,7 @@ def create_profile(
225
183
 
226
184
  def install_profile(profile_der: bytes) -> str:
227
185
  """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.
186
+ # Profile is CMS-signed plist; decode with `security cms` to read UUID.
230
187
  profiles_dir = Path.home() / "Library/MobileDevice/Provisioning Profiles"
231
188
  profiles_dir.mkdir(parents=True, exist_ok=True)
232
189
  tmp_path = profiles_dir / "tmp.mobileprovision"
@@ -234,10 +191,7 @@ def install_profile(profile_der: bytes) -> str:
234
191
  decoded = subprocess.check_output(
235
192
  ["security", "cms", "-D", "-i", str(tmp_path)]
236
193
  )
237
- import plistlib
238
-
239
- plist = plistlib.loads(decoded)
240
- uuid = plist["UUID"]
194
+ uuid = plistlib.loads(decoded)["UUID"]
241
195
  final_path = profiles_dir / f"{uuid}.mobileprovision"
242
196
  tmp_path.rename(final_path)
243
197
  print(f"Installed provisioning profile {uuid} at {final_path}")
@@ -248,8 +202,6 @@ def write_p12(
248
202
  private_key: rsa.RSAPrivateKey, cert_der: bytes, out_path: Path, passwd: str
249
203
  ) -> None:
250
204
  cert = x509.load_der_x509_certificate(cert_der)
251
- from cryptography.hazmat.primitives.serialization import pkcs12
252
-
253
205
  p12 = pkcs12.serialize_key_and_certificates(
254
206
  name=b"ScudoVPN CI",
255
207
  key=private_key,
@@ -262,11 +214,8 @@ def write_p12(
262
214
  out_path.write_bytes(p12)
263
215
 
264
216
 
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
217
+ def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
218
+ """Delete any stale keychain at this path, then create + unlock a fresh one."""
270
219
  subprocess.run(
271
220
  ["security", "delete-keychain", keychain_path],
272
221
  check=False,
@@ -281,37 +230,36 @@ def setup_keychain(p12_path: Path, p12_pass: str) -> str:
281
230
  subprocess.check_call(
282
231
  ["security", "unlock-keychain", "-p", keychain_pass, keychain_path]
283
232
  )
284
- # Import the p12
233
+
234
+
235
+ def _import_p12(
236
+ keychain_path: str, keychain_pass: str, p12_path: Path, p12_pass: str
237
+ ) -> None:
238
+ """Import p12 into the keychain and authorize codesign to use the key."""
285
239
  subprocess.check_call(
286
240
  [
287
- "security",
288
- "import",
289
- str(p12_path),
290
- "-P",
291
- p12_pass,
241
+ "security", "import", str(p12_path),
242
+ "-P", p12_pass,
292
243
  "-A", # allow any app to read (simplest for CI)
293
- "-t",
294
- "cert",
295
- "-f",
296
- "pkcs12",
297
- "-k",
298
- keychain_path,
244
+ "-t", "cert",
245
+ "-f", "pkcs12",
246
+ "-k", keychain_path,
299
247
  ]
300
248
  )
301
249
  # Allow codesign to use the key without prompts (modern macOS)
302
250
  subprocess.check_call(
303
251
  [
304
- "security",
305
- "set-key-partition-list",
306
- "-S",
307
- "apple-tool:,apple:,codesign:",
252
+ "security", "set-key-partition-list",
253
+ "-S", "apple-tool:,apple:,codesign:",
308
254
  "-s",
309
- "-k",
310
- keychain_pass,
255
+ "-k", keychain_pass,
311
256
  keychain_path,
312
257
  ]
313
258
  )
314
- # Add to default search list (in addition to login + System)
259
+
260
+
261
+ def _prepend_to_user_search_list(keychain_path: str) -> None:
262
+ """Put `keychain_path` at the head of the user search list + make it default."""
315
263
  existing = subprocess.check_output(
316
264
  ["security", "list-keychains", "-d", "user"]
317
265
  ).decode()
@@ -329,6 +277,14 @@ def setup_keychain(p12_path: Path, p12_pass: str) -> str:
329
277
  subprocess.check_call(
330
278
  ["security", "default-keychain", "-s", keychain_path]
331
279
  )
280
+
281
+
282
+ def setup_keychain(p12_path: Path, p12_pass: str) -> str:
283
+ keychain_path = os.path.join(env("RUNNER_TEMP"), "ci.keychain-db")
284
+ keychain_pass = "ci"
285
+ _create_keychain(keychain_path, keychain_pass)
286
+ _import_p12(keychain_path, keychain_pass, p12_path, p12_pass)
287
+ _prepend_to_user_search_list(keychain_path)
332
288
  print(f"Keychain ready: {keychain_path}")
333
289
  return keychain_path
334
290
 
@@ -343,7 +299,7 @@ def main() -> None:
343
299
 
344
300
  token = make_jwt(key_id, issuer_id, asc_key_path)
345
301
 
346
- private_key, priv_pem, csr_b64 = generate_key_and_csr()
302
+ private_key, csr_b64 = generate_key_and_csr()
347
303
  cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
348
304
 
349
305
  bundle_pk = find_bundle_id(token, bundle_id)
@@ -1,93 +0,0 @@
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()