@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/scripts/ci/ios-native/asc_common.py +143 -0
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +231 -0
- package/templates/scripts/ci/ios-native/next_build_number.py +18 -38
- package/templates/scripts/ci/ios-native/prepare_signing.py +64 -108
- package/templates/scripts/ci/ios-native/next_marketing_version.py +0 -93
|
@@ -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.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.
|
|
15
|
+
"version": "0.10.67",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
"
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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.
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
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,
|
|
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
|
-
|
|
116
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 =
|
|
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
|
-
|
|
185
|
-
"
|
|
140
|
+
data = get_json(
|
|
141
|
+
"/bundleIds",
|
|
142
|
+
token,
|
|
143
|
+
params={"filter[identifier]": identifier, "limit": "5"},
|
|
186
144
|
)
|
|
187
|
-
for item in
|
|
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
|
-
|
|
195
|
-
for p in
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
295
|
-
"-
|
|
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
|
-
"
|
|
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
|
-
|
|
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,
|
|
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()
|