@daemux/store-automator 0.10.53 → 0.10.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/github/workflows/ios-release.yml +18 -0
- package/templates/scripts/asc_subscription_submit.py +2 -3
- package/templates/scripts/ci/ios/submit-for-review.sh +38 -0
- package/templates/scripts/submit_for_review_ios.py +265 -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.55"
|
|
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.55",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -153,3 +153,21 @@ jobs:
|
|
|
153
153
|
|
|
154
154
|
- name: Upload to App Store
|
|
155
155
|
run: scripts/ci/ios/upload-binary.sh
|
|
156
|
+
|
|
157
|
+
submit:
|
|
158
|
+
name: Submit iOS for Review
|
|
159
|
+
needs: [build]
|
|
160
|
+
runs-on: macos-latest
|
|
161
|
+
timeout-minutes: 45
|
|
162
|
+
steps:
|
|
163
|
+
- name: Checkout
|
|
164
|
+
uses: actions/checkout@v4
|
|
165
|
+
|
|
166
|
+
- name: Install yq
|
|
167
|
+
run: brew install yq
|
|
168
|
+
|
|
169
|
+
- name: Install Python dependencies
|
|
170
|
+
run: pip3 install --break-system-packages requests PyJWT
|
|
171
|
+
|
|
172
|
+
- name: Submit for Review
|
|
173
|
+
run: scripts/ci/ios/submit-for-review.sh
|
|
@@ -25,10 +25,9 @@ def create_review_submission(
|
|
|
25
25
|
Idempotent: silently handles 409 Conflict (already submitted).
|
|
26
26
|
"""
|
|
27
27
|
resp = requests.post(
|
|
28
|
-
f"{BASE_URL}/
|
|
28
|
+
f"{BASE_URL}/subscriptionSubmissions",
|
|
29
29
|
json={"data": {
|
|
30
|
-
"type": "
|
|
31
|
-
"attributes": {"reviewerNotes": reviewer_notes} if reviewer_notes else {},
|
|
30
|
+
"type": "subscriptionSubmissions",
|
|
32
31
|
"relationships": {
|
|
33
32
|
"subscription": {"data": {"type": "subscriptions", "id": sub_id}},
|
|
34
33
|
},
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Submits the iOS app for App Store review via ASC API.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
source "$SCRIPT_DIR/../common/read-config.sh"
|
|
7
|
+
source "$SCRIPT_DIR/../common/ci-notify.sh"
|
|
8
|
+
|
|
9
|
+
echo "=== Submit iOS for Review ==="
|
|
10
|
+
|
|
11
|
+
if [ "$SUBMIT_FOR_REVIEW" != "true" ]; then
|
|
12
|
+
ci_skip "submit_for_review is disabled"
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# --- Validate ASC credentials ---
|
|
16
|
+
if [ -z "$APPLE_KEY_ID" ] || [ -z "$APPLE_ISSUER_ID" ] || [ -z "$P8_KEY_PATH" ]; then
|
|
17
|
+
echo "ERROR: Missing Apple credentials in ci.config.yaml (APPLE_KEY_ID, APPLE_ISSUER_ID, or P8_KEY_PATH)" >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
P8_FULL_PATH="$PROJECT_ROOT/$P8_KEY_PATH"
|
|
22
|
+
if [ ! -f "$P8_FULL_PATH" ]; then
|
|
23
|
+
echo "ERROR: P8 key file not found at $P8_FULL_PATH" >&2
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# --- Set ASC API env vars for submit_for_review_ios.py ---
|
|
28
|
+
export APP_STORE_CONNECT_KEY_IDENTIFIER="$APPLE_KEY_ID"
|
|
29
|
+
export APP_STORE_CONNECT_ISSUER_ID="$APPLE_ISSUER_ID"
|
|
30
|
+
export APP_STORE_CONNECT_PRIVATE_KEY="$(cat "$P8_FULL_PATH")"
|
|
31
|
+
export BUNDLE_ID
|
|
32
|
+
|
|
33
|
+
echo "ASC API key configured (Key ID: $APPLE_KEY_ID)"
|
|
34
|
+
|
|
35
|
+
echo "Submitting iOS app for App Store review..."
|
|
36
|
+
python3 "$PROJECT_ROOT/scripts/submit_for_review_ios.py"
|
|
37
|
+
|
|
38
|
+
ci_done "iOS app submitted for App Store review"
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Submit an iOS app version for App Store review.
|
|
4
|
+
|
|
5
|
+
Polls App Store Connect API waiting for a build to finish processing,
|
|
6
|
+
attaches it to the version in PREPARE_FOR_SUBMISSION state, then submits
|
|
7
|
+
for review using the Review Submissions API.
|
|
8
|
+
|
|
9
|
+
Required env vars:
|
|
10
|
+
APP_STORE_CONNECT_KEY_IDENTIFIER - Key ID from App Store Connect
|
|
11
|
+
APP_STORE_CONNECT_ISSUER_ID - Issuer ID from App Store Connect
|
|
12
|
+
APP_STORE_CONNECT_PRIVATE_KEY - Contents of the P8 key file
|
|
13
|
+
BUNDLE_ID - App bundle identifier
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import jwt
|
|
21
|
+
import requests
|
|
22
|
+
except ImportError:
|
|
23
|
+
import subprocess
|
|
24
|
+
subprocess.check_call([
|
|
25
|
+
sys.executable, "-m", "pip", "install", "--break-system-packages",
|
|
26
|
+
"PyJWT", "cryptography", "requests"
|
|
27
|
+
], stdout=subprocess.DEVNULL)
|
|
28
|
+
import jwt
|
|
29
|
+
import requests
|
|
30
|
+
|
|
31
|
+
BASE_URL = "https://api.appstoreconnect.apple.com/v1"
|
|
32
|
+
POLL_INTERVAL = 30
|
|
33
|
+
MAX_POLL_DURATION = 2400
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_jwt_token(key_id, issuer_id, private_key):
|
|
37
|
+
payload = {
|
|
38
|
+
"iss": issuer_id,
|
|
39
|
+
"exp": int(time.time()) + 1200,
|
|
40
|
+
"aud": "appstoreconnect-v1",
|
|
41
|
+
}
|
|
42
|
+
return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_app_id(headers, bundle_id):
|
|
46
|
+
resp = requests.get(
|
|
47
|
+
f"{BASE_URL}/apps",
|
|
48
|
+
params={"filter[bundleId]": bundle_id},
|
|
49
|
+
headers=headers,
|
|
50
|
+
timeout=(10, 30),
|
|
51
|
+
)
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
data = resp.json().get("data", [])
|
|
54
|
+
if not data:
|
|
55
|
+
print(f"ERROR: No app found for bundle ID {bundle_id}", file=sys.stderr)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
return data[0]["id"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fail_on_error(resp, action):
|
|
61
|
+
try:
|
|
62
|
+
errors = resp.json().get("errors", [])
|
|
63
|
+
for err in errors:
|
|
64
|
+
detail = err.get("detail", err.get("title", "Unknown error"))
|
|
65
|
+
print(f"ERROR ({action}): {detail}", file=sys.stderr)
|
|
66
|
+
except (ValueError, KeyError):
|
|
67
|
+
print(f"ERROR ({action}): HTTP {resp.status_code}", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def poll_build_processing(headers, app_id, version_string, key_id, issuer_id, private_key):
|
|
72
|
+
start = time.time()
|
|
73
|
+
last_token_time = start
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
elapsed = int(time.time() - start)
|
|
77
|
+
|
|
78
|
+
if time.time() - last_token_time > 900:
|
|
79
|
+
token = get_jwt_token(key_id, issuer_id, private_key)
|
|
80
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
81
|
+
last_token_time = time.time()
|
|
82
|
+
|
|
83
|
+
resp = requests.get(
|
|
84
|
+
f"{BASE_URL}/builds",
|
|
85
|
+
params={
|
|
86
|
+
"filter[app]": app_id,
|
|
87
|
+
"filter[preReleaseVersion.version]": version_string,
|
|
88
|
+
"sort": "-uploadedDate",
|
|
89
|
+
"limit": 1,
|
|
90
|
+
},
|
|
91
|
+
headers=headers,
|
|
92
|
+
timeout=(10, 30),
|
|
93
|
+
)
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
builds = resp.json().get("data", [])
|
|
96
|
+
|
|
97
|
+
if builds:
|
|
98
|
+
state = builds[0]["attributes"]["processingState"]
|
|
99
|
+
if state == "VALID":
|
|
100
|
+
print(f" [{elapsed}s] Build processing complete.")
|
|
101
|
+
return builds[0]
|
|
102
|
+
if state in ("FAILED", "INVALID"):
|
|
103
|
+
print(f"ERROR: Build processing ended with state '{state}'.", file=sys.stderr)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
print(f" [{elapsed}s] Build processing... (state: {state})")
|
|
106
|
+
else:
|
|
107
|
+
print(f" [{elapsed}s] Waiting for build to appear...")
|
|
108
|
+
|
|
109
|
+
if elapsed >= MAX_POLL_DURATION:
|
|
110
|
+
print(f"ERROR: Timed out after {MAX_POLL_DURATION}s waiting for build.", file=sys.stderr)
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
time.sleep(POLL_INTERVAL)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_version_for_submission(headers, app_id):
|
|
117
|
+
resp = requests.get(
|
|
118
|
+
f"{BASE_URL}/apps/{app_id}/appStoreVersions",
|
|
119
|
+
params={
|
|
120
|
+
"filter[appStoreState]": "PREPARE_FOR_SUBMISSION",
|
|
121
|
+
"filter[platform]": "IOS",
|
|
122
|
+
},
|
|
123
|
+
headers=headers,
|
|
124
|
+
timeout=(10, 30),
|
|
125
|
+
)
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
versions = resp.json().get("data", [])
|
|
128
|
+
if not versions:
|
|
129
|
+
print("ERROR: No version in PREPARE_FOR_SUBMISSION state found.", file=sys.stderr)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
return versions[0]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def attach_build_to_version(headers, version_id, build_id):
|
|
135
|
+
resp = requests.patch(
|
|
136
|
+
f"{BASE_URL}/appStoreVersions/{version_id}/relationships/build",
|
|
137
|
+
json={"data": {"type": "builds", "id": build_id}},
|
|
138
|
+
headers=headers,
|
|
139
|
+
timeout=(10, 30),
|
|
140
|
+
)
|
|
141
|
+
if not resp.ok:
|
|
142
|
+
fail_on_error(resp, "attach build to version")
|
|
143
|
+
print(f" Build {build_id} attached to version {version_id}.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_or_create_submission(headers, app_id):
|
|
147
|
+
create_resp = requests.post(
|
|
148
|
+
f"{BASE_URL}/reviewSubmissions",
|
|
149
|
+
json={
|
|
150
|
+
"data": {
|
|
151
|
+
"type": "reviewSubmissions",
|
|
152
|
+
"attributes": {"platform": "IOS"},
|
|
153
|
+
"relationships": {
|
|
154
|
+
"app": {"data": {"type": "apps", "id": app_id}}
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
headers=headers,
|
|
159
|
+
timeout=(10, 30),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if create_resp.status_code == 409:
|
|
163
|
+
existing_resp = requests.get(
|
|
164
|
+
f"{BASE_URL}/apps/{app_id}/reviewSubmissions",
|
|
165
|
+
params={"filter[state]": "READY_FOR_REVIEW,WAITING_FOR_REVIEW"},
|
|
166
|
+
headers=headers,
|
|
167
|
+
timeout=(10, 30),
|
|
168
|
+
)
|
|
169
|
+
existing_resp.raise_for_status()
|
|
170
|
+
submissions = existing_resp.json().get("data", [])
|
|
171
|
+
if not submissions:
|
|
172
|
+
print("ERROR: 409 Conflict but no existing submission found.", file=sys.stderr)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
return submissions[0]["id"]
|
|
175
|
+
|
|
176
|
+
if not create_resp.ok:
|
|
177
|
+
fail_on_error(create_resp, "create review submission")
|
|
178
|
+
|
|
179
|
+
return create_resp.json()["data"]["id"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def submit_for_review(headers, app_id, version_id):
|
|
183
|
+
submission_id = get_or_create_submission(headers, app_id)
|
|
184
|
+
print(f" Review submission ID: {submission_id}")
|
|
185
|
+
|
|
186
|
+
item_resp = requests.post(
|
|
187
|
+
f"{BASE_URL}/reviewSubmissionItems",
|
|
188
|
+
json={
|
|
189
|
+
"data": {
|
|
190
|
+
"type": "reviewSubmissionItems",
|
|
191
|
+
"relationships": {
|
|
192
|
+
"reviewSubmission": {
|
|
193
|
+
"data": {"type": "reviewSubmissions", "id": submission_id}
|
|
194
|
+
},
|
|
195
|
+
"appStoreVersion": {
|
|
196
|
+
"data": {"type": "appStoreVersions", "id": version_id}
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
headers=headers,
|
|
202
|
+
timeout=(10, 30),
|
|
203
|
+
)
|
|
204
|
+
if not item_resp.ok:
|
|
205
|
+
fail_on_error(item_resp, "add review submission item")
|
|
206
|
+
|
|
207
|
+
submit_resp = requests.patch(
|
|
208
|
+
f"{BASE_URL}/reviewSubmissions/{submission_id}",
|
|
209
|
+
json={
|
|
210
|
+
"data": {
|
|
211
|
+
"type": "reviewSubmissions",
|
|
212
|
+
"id": submission_id,
|
|
213
|
+
"attributes": {"submitted": True},
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
headers=headers,
|
|
217
|
+
timeout=(10, 30),
|
|
218
|
+
)
|
|
219
|
+
if not submit_resp.ok:
|
|
220
|
+
fail_on_error(submit_resp, "submit for review")
|
|
221
|
+
|
|
222
|
+
print(" Submitted for review successfully.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main():
|
|
226
|
+
key_id = os.environ.get("APP_STORE_CONNECT_KEY_IDENTIFIER", "")
|
|
227
|
+
issuer_id = os.environ.get("APP_STORE_CONNECT_ISSUER_ID", "")
|
|
228
|
+
private_key = os.environ.get("APP_STORE_CONNECT_PRIVATE_KEY", "")
|
|
229
|
+
bundle_id = os.environ.get("BUNDLE_ID", "")
|
|
230
|
+
|
|
231
|
+
if not all([key_id, issuer_id, private_key, bundle_id]):
|
|
232
|
+
print("ERROR: Missing required environment variables", file=sys.stderr)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
token = get_jwt_token(key_id, issuer_id, private_key)
|
|
236
|
+
headers = {
|
|
237
|
+
"Authorization": f"Bearer {token}",
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
app_id = get_app_id(headers, bundle_id)
|
|
242
|
+
print(f"App ID: {app_id}")
|
|
243
|
+
|
|
244
|
+
version = get_version_for_submission(headers, app_id)
|
|
245
|
+
version_id = version["id"]
|
|
246
|
+
version_string = version["attributes"]["versionString"]
|
|
247
|
+
print(f"Version {version_string} (ID: {version_id})")
|
|
248
|
+
|
|
249
|
+
print(f"Polling for processed build (version {version_string})...")
|
|
250
|
+
build = poll_build_processing(headers, app_id, version_string, key_id, issuer_id, private_key)
|
|
251
|
+
build_id = build["id"]
|
|
252
|
+
build_version = build["attributes"]["version"]
|
|
253
|
+
print(f"Build {build_version} (ID: {build_id})")
|
|
254
|
+
|
|
255
|
+
print("Attaching build to version...")
|
|
256
|
+
attach_build_to_version(headers, version_id, build_id)
|
|
257
|
+
|
|
258
|
+
print("Submitting for App Store review...")
|
|
259
|
+
submit_for_review(headers, app_id, version_id)
|
|
260
|
+
|
|
261
|
+
print(f"Done. Version {version_string} (build {build_version}) submitted for review.")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
main()
|