@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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.53"
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.53",
15
+ "version": "0.10.55",
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.53",
3
+ "version": "0.10.55",
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.53",
3
+ "version": "0.10.55",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -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}/subscriptionAppStoreReviewSubmissions",
28
+ f"{BASE_URL}/subscriptionSubmissions",
29
29
  json={"data": {
30
- "type": "subscriptionAppStoreReviewSubmissions",
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()