@daemux/store-automator 0.10.52 → 0.10.54

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,20 +5,15 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.52"
8
+ "version": "0.10.37"
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.52",
16
- "keywords": [
17
- "flutter",
18
- "app-store",
19
- "google-play",
20
- "fastlane"
21
- ]
15
+ "version": "0.10.37",
16
+ "keywords": ["flutter", "app-store", "google-play", "fastlane"]
22
17
  }
23
18
  ]
24
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.52",
3
+ "version": "0.10.54",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,16 +1,9 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.52",
3
+ "version": "0.10.54",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
7
7
  },
8
- "keywords": [
9
- "flutter",
10
- "app-store",
11
- "google-play",
12
- "fastlane",
13
- "screenshots",
14
- "metadata"
15
- ]
8
+ "keywords": ["flutter", "app-store", "google-play", "fastlane", "screenshots", "metadata"]
16
9
  }
@@ -47,6 +47,9 @@ jobs:
47
47
  - name: Install Python dependencies
48
48
  run: pip3 install --break-system-packages requests PyJWT
49
49
 
50
+ - name: Ensure App Exists
51
+ run: scripts/ci/ios/ensure-app-exists.sh
52
+
50
53
  - name: Upload Metadata & Screenshots
51
54
  id: upload-metadata
52
55
  run: scripts/ci/ios/upload-metadata.sh
@@ -0,0 +1,74 @@
1
+ """App Store Connect Subscription Review Submission API layer.
2
+
3
+ Functions for submitting subscriptions and subscription groups for App Store review.
4
+ """
5
+ import sys
6
+
7
+ try:
8
+ import requests
9
+ except ImportError:
10
+ import subprocess
11
+ subprocess.check_call(
12
+ [sys.executable, "-m", "pip", "install", "--break-system-packages", "requests"],
13
+ stdout=subprocess.DEVNULL,
14
+ )
15
+ import requests
16
+
17
+ from asc_iap_api import BASE_URL, TIMEOUT, print_api_errors
18
+
19
+
20
+ def create_review_submission(
21
+ headers: dict, sub_id: str, reviewer_notes: str = "",
22
+ ) -> dict | None:
23
+ """Submit a subscription for App Store review.
24
+
25
+ Idempotent: silently handles 409 Conflict (already submitted).
26
+ """
27
+ resp = requests.post(
28
+ f"{BASE_URL}/subscriptionAppStoreReviewSubmissions",
29
+ json={"data": {
30
+ "type": "subscriptionAppStoreReviewSubmissions",
31
+ "attributes": {"reviewerNotes": reviewer_notes} if reviewer_notes else {},
32
+ "relationships": {
33
+ "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
34
+ },
35
+ }},
36
+ headers=headers,
37
+ timeout=TIMEOUT,
38
+ )
39
+ if resp.status_code == 409:
40
+ return None
41
+ if not resp.ok:
42
+ print_api_errors(resp, f"submit subscription {sub_id} for review")
43
+ return None
44
+ print(f" Submitted subscription {sub_id} for review")
45
+ return resp.json().get("data")
46
+
47
+
48
+ def create_group_submission(
49
+ headers: dict, group_id: str,
50
+ ) -> dict | None:
51
+ """Submit a subscription group for App Store review.
52
+
53
+ Idempotent: silently handles 409 Conflict (already submitted).
54
+ """
55
+ resp = requests.post(
56
+ f"{BASE_URL}/subscriptionGroupSubmissions",
57
+ json={"data": {
58
+ "type": "subscriptionGroupSubmissions",
59
+ "relationships": {
60
+ "subscriptionGroup": {
61
+ "data": {"type": "subscriptionGroups", "id": group_id},
62
+ },
63
+ },
64
+ }},
65
+ headers=headers,
66
+ timeout=TIMEOUT,
67
+ )
68
+ if resp.status_code == 409:
69
+ return None
70
+ if not resp.ok:
71
+ print_api_errors(resp, f"submit group {group_id} for review")
72
+ return None
73
+ print(f" Submitted subscription group {group_id} for review")
74
+ return resp.json().get("data")
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Ensures the app exists in App Store Connect (registers bundle ID, creates
3
+ # app record, sets content rights and pricing). Fully idempotent.
4
+ # Requires read-config.sh to have been sourced (provides PROJECT_ROOT, credentials, etc.).
5
+ set -euo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ source "$SCRIPT_DIR/../common/read-config.sh"
9
+ source "$SCRIPT_DIR/../common/ci-notify.sh"
10
+
11
+ echo "=== Ensure App Exists in App Store Connect ==="
12
+
13
+ # --- Validate ASC credentials ---
14
+ if [ -z "${APPLE_KEY_ID:-}" ] || [ -z "${APPLE_ISSUER_ID:-}" ]; then
15
+ ci_skip "ASC credentials not configured"
16
+ fi
17
+
18
+ if [ -z "${BUNDLE_ID:-}" ] || [ -z "${APP_NAME:-}" ]; then
19
+ ci_skip "BUNDLE_ID or APP_NAME not set in ci.config.yaml"
20
+ fi
21
+
22
+ # --- Set up App Store Connect API credentials ---
23
+ P8_FULL_PATH="$PROJECT_ROOT/$P8_KEY_PATH"
24
+ if [ ! -f "$P8_FULL_PATH" ]; then
25
+ echo "ERROR: P8 key file not found at $P8_FULL_PATH" >&2
26
+ exit 1
27
+ fi
28
+
29
+ export APP_STORE_CONNECT_KEY_IDENTIFIER="$APPLE_KEY_ID"
30
+ export APP_STORE_CONNECT_ISSUER_ID="$APPLE_ISSUER_ID"
31
+ export APP_STORE_CONNECT_PRIVATE_KEY="$(cat "$P8_FULL_PATH")"
32
+ export BUNDLE_ID="$BUNDLE_ID"
33
+ export APP_NAME="$APP_NAME"
34
+ export SKU="${SKU:-$BUNDLE_ID}"
35
+ export PLATFORM="${PLATFORM:-IOS}"
36
+
37
+ echo "ASC API key configured (Key ID: $APPLE_KEY_ID)"
38
+
39
+ # --- Run ensure-app-exists via Python ---
40
+ CREATE_SCRIPT="$PROJECT_ROOT/scripts/create_app_record.py"
41
+
42
+ if [ ! -f "$CREATE_SCRIPT" ]; then
43
+ echo "ERROR: create_app_record.py not found at $CREATE_SCRIPT" >&2
44
+ exit 1
45
+ fi
46
+
47
+ echo "Ensuring app exists in App Store Connect..."
48
+ python3 "$CREATE_SCRIPT"
49
+
50
+ ci_done "App exists and is configured in App Store Connect"
@@ -20,6 +20,7 @@ fi
20
20
  CURRENT_HASH=$(cat "$IAP_CONFIG" \
21
21
  "$PROJECT_ROOT/scripts/sync_iap_ios.py" \
22
22
  "$PROJECT_ROOT/scripts/asc_subscription_setup.py" \
23
+ "$PROJECT_ROOT/scripts/asc_subscription_submit.py" \
23
24
  "$PROJECT_ROOT/scripts/asc_iap_api.py" \
24
25
  | shasum -a 256 | cut -d' ' -f1)
25
26
 
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Create an App Store Connect app record via the direct API.
3
+ Ensure an App Store Connect app exists with correct configuration.
4
4
 
5
- Looks up the Bundle ID resource, then creates a new app record
6
- with the specified name, SKU, and primary locale.
5
+ Fully idempotent flow:
6
+ 1. Register Bundle ID on the Developer Portal (skip if exists)
7
+ 2. Create App Record in App Store Connect (skip if exists)
8
+ 3. Set content rights declaration (via asc_app_setup)
9
+ 4. Set app pricing schedule (via asc_app_setup)
7
10
 
8
11
  Required env vars:
9
12
  APP_STORE_CONNECT_KEY_IDENTIFIER - Key ID from App Store Connect
@@ -11,10 +14,15 @@ Required env vars:
11
14
  APP_STORE_CONNECT_PRIVATE_KEY - Contents of the P8 key file
12
15
  BUNDLE_ID - App bundle identifier (e.g. com.daemux.gigachat)
13
16
  APP_NAME - Display name for the app
14
- SKU - Unique SKU string for the app
17
+ SKU - Unique SKU string (defaults to BUNDLE_ID)
18
+
19
+ Optional env vars:
20
+ PLATFORM - "IOS" or "UNIVERSAL" (default: "IOS")
21
+ CONTENT_RIGHTS_THIRD_PARTY - "true" if app uses third-party content (default: "false")
22
+ PRICE_TIER - Price tier integer (default: "0" for free)
15
23
 
16
24
  Exit codes:
17
- 0 - App record created successfully
25
+ 0 - App ready (created or already existed)
18
26
  1 - Any failure (missing env vars, API error, etc.)
19
27
  """
20
28
  import json
@@ -35,6 +43,10 @@ except ImportError:
35
43
  import jwt
36
44
  import requests
37
45
 
46
+ # Import content rights and pricing from asc_app_setup (same directory)
47
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
+ from asc_app_setup import print_api_errors, set_content_rights, set_app_pricing # noqa: E402
49
+
38
50
  BASE_URL = "https://api.appstoreconnect.apple.com/v1"
39
51
  TIMEOUT = (10, 30)
40
52
 
@@ -48,13 +60,53 @@ def get_jwt_token(key_id: str, issuer_id: str, private_key: str) -> str:
48
60
  "exp": now + 1200,
49
61
  "aud": "appstoreconnect-v1",
50
62
  }
51
- return jwt.encode(
52
- payload, private_key, algorithm="ES256", headers={"kid": key_id}
63
+ return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id})
64
+
65
+
66
+ def ensure_bundle_id(headers: dict, bundle_id: str, app_name: str, platform: str) -> str:
67
+ """Register a Bundle ID if it does not exist. Returns the resource ID."""
68
+ resp = requests.get(
69
+ f"{BASE_URL}/bundleIds",
70
+ params={"filter[identifier]": bundle_id},
71
+ headers=headers,
72
+ timeout=TIMEOUT,
53
73
  )
74
+ resp.raise_for_status()
75
+ data = resp.json().get("data", [])
76
+ if data:
77
+ resource_id = data[0]["id"]
78
+ print(f" Bundle ID already registered: {resource_id}")
79
+ return resource_id
80
+
81
+ print(f" Registering new Bundle ID '{bundle_id}' (platform: {platform})...")
82
+ post_resp = requests.post(
83
+ f"{BASE_URL}/bundleIds",
84
+ json={
85
+ "data": {
86
+ "type": "bundleIds",
87
+ "attributes": {
88
+ "identifier": bundle_id,
89
+ "name": app_name,
90
+ "platform": platform,
91
+ },
92
+ }
93
+ },
94
+ headers=headers,
95
+ timeout=TIMEOUT,
96
+ )
97
+ if post_resp.status_code == 409:
98
+ print(" Bundle ID already exists (409 Conflict), re-fetching...")
99
+ return _refetch_bundle_id(headers, bundle_id)
100
+ if not post_resp.ok:
101
+ print_api_errors(post_resp, "register bundle ID")
102
+ sys.exit(1)
103
+ resource_id = post_resp.json()["data"]["id"]
104
+ print(f" Bundle ID registered: {resource_id}")
105
+ return resource_id
54
106
 
55
107
 
56
- def lookup_bundle_id_resource(headers: dict, bundle_id: str) -> str:
57
- """Look up the Bundle ID resource ID for the given identifier."""
108
+ def _refetch_bundle_id(headers: dict, bundle_id: str) -> str:
109
+ """Re-fetch a bundle ID resource after a 409 conflict."""
58
110
  resp = requests.get(
59
111
  f"{BASE_URL}/bundleIds",
60
112
  params={"filter[identifier]": bundle_id},
@@ -64,15 +116,24 @@ def lookup_bundle_id_resource(headers: dict, bundle_id: str) -> str:
64
116
  resp.raise_for_status()
65
117
  data = resp.json().get("data", [])
66
118
  if not data:
67
- print(
68
- f"ERROR: No Bundle ID found for identifier '{bundle_id}'. "
69
- "Register it in App Store Connect > Certificates, Identifiers & Profiles first.",
70
- file=sys.stderr,
71
- )
119
+ print(f"ERROR: Bundle ID '{bundle_id}' not found after 409 conflict.", file=sys.stderr)
72
120
  sys.exit(1)
73
121
  return data[0]["id"]
74
122
 
75
123
 
124
+ def _lookup_existing_app(headers: dict, bundle_id: str) -> dict | None:
125
+ """Look up an existing app by bundle ID. Returns app data dict or None."""
126
+ resp = requests.get(
127
+ f"{BASE_URL}/apps",
128
+ params={"filter[bundleId]": bundle_id},
129
+ headers=headers,
130
+ timeout=TIMEOUT,
131
+ )
132
+ resp.raise_for_status()
133
+ data = resp.json().get("data", [])
134
+ return data[0] if data else None
135
+
136
+
76
137
  def create_app_record(
77
138
  headers: dict,
78
139
  bundle_id: str,
@@ -80,7 +141,18 @@ def create_app_record(
80
141
  app_name: str,
81
142
  sku: str,
82
143
  ) -> dict:
83
- """Create a new app record in App Store Connect."""
144
+ """Create or retrieve an existing app record in App Store Connect."""
145
+ existing = _lookup_existing_app(headers, bundle_id)
146
+ if existing:
147
+ app_info = {
148
+ "app_id": existing["id"],
149
+ "name": existing["attributes"]["name"],
150
+ "bundle_id": existing["attributes"]["bundleId"],
151
+ "sku": existing["attributes"]["sku"],
152
+ }
153
+ print(f" App already exists: {json.dumps(app_info)}")
154
+ return existing
155
+
84
156
  payload = {
85
157
  "data": {
86
158
  "type": "apps",
@@ -92,80 +164,87 @@ def create_app_record(
92
164
  },
93
165
  "relationships": {
94
166
  "bundleId": {
95
- "data": {
96
- "type": "bundleIds",
97
- "id": bundle_id_resource_id,
98
- }
167
+ "data": {"type": "bundleIds", "id": bundle_id_resource_id}
99
168
  }
100
169
  },
101
170
  }
102
171
  }
103
- resp = requests.post(
104
- f"{BASE_URL}/apps",
105
- json=payload,
106
- headers=headers,
107
- timeout=TIMEOUT,
108
- )
172
+ resp = requests.post(f"{BASE_URL}/apps", json=payload, headers=headers, timeout=TIMEOUT)
109
173
  if resp.status_code == 409:
110
- print(
111
- f"ERROR: An app with bundle ID '{bundle_id}' already exists.",
112
- file=sys.stderr,
113
- )
174
+ print(" App creation returned 409, fetching existing record...")
175
+ existing = _lookup_existing_app(headers, bundle_id)
176
+ if existing:
177
+ return existing
178
+ print("ERROR: App creation returned 409 but app not found.", file=sys.stderr)
114
179
  sys.exit(1)
115
180
  if not resp.ok:
116
- errors = resp.json().get("errors", [])
117
- for err in errors:
118
- print(f"ERROR: {err.get('detail', err.get('title', 'Unknown'))}", file=sys.stderr)
181
+ print_api_errors(resp, "create app record")
119
182
  sys.exit(1)
120
183
  return resp.json()["data"]
121
184
 
122
185
 
123
- def main() -> None:
186
+ def validate_env_vars() -> dict:
187
+ """Validate and return all required/optional env vars as a dict."""
124
188
  key_id = os.environ.get("APP_STORE_CONNECT_KEY_IDENTIFIER", "")
125
189
  issuer_id = os.environ.get("APP_STORE_CONNECT_ISSUER_ID", "")
126
190
  private_key = os.environ.get("APP_STORE_CONNECT_PRIVATE_KEY", "")
127
191
  bundle_id = os.environ.get("BUNDLE_ID", "")
128
192
  app_name = os.environ.get("APP_NAME", "")
129
- sku = os.environ.get("SKU", "")
130
-
131
- missing = []
132
- if not key_id:
133
- missing.append("APP_STORE_CONNECT_KEY_IDENTIFIER")
134
- if not issuer_id:
135
- missing.append("APP_STORE_CONNECT_ISSUER_ID")
136
- if not private_key:
137
- missing.append("APP_STORE_CONNECT_PRIVATE_KEY")
138
- if not bundle_id:
139
- missing.append("BUNDLE_ID")
140
- if not app_name:
141
- missing.append("APP_NAME")
142
- if not sku:
143
- missing.append("SKU")
144
-
193
+ sku = os.environ.get("SKU", "") or bundle_id
194
+
195
+ required = {
196
+ "APP_STORE_CONNECT_KEY_IDENTIFIER": key_id,
197
+ "APP_STORE_CONNECT_ISSUER_ID": issuer_id,
198
+ "APP_STORE_CONNECT_PRIVATE_KEY": private_key,
199
+ "BUNDLE_ID": bundle_id,
200
+ "APP_NAME": app_name,
201
+ }
202
+ missing = [k for k, v in required.items() if not v]
145
203
  if missing:
146
204
  print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
147
205
  sys.exit(1)
148
206
 
149
- token = get_jwt_token(key_id, issuer_id, private_key)
150
- headers = {
151
- "Authorization": f"Bearer {token}",
152
- "Content-Type": "application/json",
207
+ platform = os.environ.get("PLATFORM", "IOS")
208
+ if platform not in ("IOS", "UNIVERSAL"):
209
+ print(f"ERROR: PLATFORM must be 'IOS' or 'UNIVERSAL', got '{platform}'", file=sys.stderr)
210
+ sys.exit(1)
211
+
212
+ price_tier_str = os.environ.get("PRICE_TIER", "0")
213
+ try:
214
+ price_tier = int(price_tier_str)
215
+ except ValueError:
216
+ print(f"ERROR: PRICE_TIER must be an integer, got '{price_tier_str}'", file=sys.stderr)
217
+ sys.exit(1)
218
+
219
+ return {
220
+ "key_id": key_id, "issuer_id": issuer_id, "private_key": private_key,
221
+ "bundle_id": bundle_id, "app_name": app_name, "sku": sku,
222
+ "platform": platform, "price_tier": price_tier,
223
+ "uses_third_party": os.environ.get("CONTENT_RIGHTS_THIRD_PARTY", "false").lower() == "true",
153
224
  }
154
225
 
155
- print(f"Looking up Bundle ID resource for '{bundle_id}'...")
156
- bundle_id_resource_id = lookup_bundle_id_resource(headers, bundle_id)
157
- print(f"Found Bundle ID resource: {bundle_id_resource_id}")
158
226
 
159
- print(f"Creating app record '{app_name}' (SKU: {sku})...")
160
- app_data = create_app_record(headers, bundle_id, bundle_id_resource_id, app_name, sku)
227
+ def main() -> None:
228
+ """Orchestrate the full app creation and configuration flow."""
229
+ cfg = validate_env_vars()
230
+ token = get_jwt_token(cfg["key_id"], cfg["issuer_id"], cfg["private_key"])
231
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
161
232
 
162
- result = {
163
- "app_id": app_data["id"],
164
- "name": app_data["attributes"]["name"],
165
- "bundle_id": app_data["attributes"]["bundleId"],
166
- "sku": app_data["attributes"]["sku"],
167
- }
168
- print(f"App record created successfully: {json.dumps(result)}")
233
+ print(f"Step 1: Ensuring Bundle ID '{cfg['bundle_id']}' is registered...")
234
+ bundle_id_resource_id = ensure_bundle_id(headers, cfg["bundle_id"], cfg["app_name"], cfg["platform"])
235
+
236
+ print(f"Step 2: Ensuring app record '{cfg['app_name']}' (SKU: {cfg['sku']}) exists...")
237
+ app_data = create_app_record(headers, cfg["bundle_id"], bundle_id_resource_id, cfg["app_name"], cfg["sku"])
238
+ app_id = app_data["id"]
239
+ print(f" App ID: {app_id}")
240
+
241
+ print("Step 3: Setting content rights declaration...")
242
+ set_content_rights(headers, app_id, cfg["uses_third_party"])
243
+
244
+ print("Step 4: Setting app pricing...")
245
+ set_app_pricing(headers, app_id, cfg["price_tier"])
246
+
247
+ print(f"All done. App '{cfg['app_name']}' ({cfg['bundle_id']}) is ready.")
169
248
 
170
249
 
171
250
  if __name__ == "__main__":
@@ -52,6 +52,10 @@ from asc_subscription_setup import (
52
52
  list_all_territory_ids,
53
53
  upload_review_screenshot,
54
54
  )
55
+ from asc_subscription_submit import (
56
+ create_group_submission,
57
+ create_review_submission,
58
+ )
55
59
 
56
60
  CURRENCY_TO_TERRITORY = {
57
61
  "USD": "USA", "EUR": "FRA", "GBP": "GBR", "JPY": "JPN",
@@ -155,8 +159,10 @@ def sync_subscription_group(
155
159
  )
156
160
  if not resp.ok:
157
161
  print_api_errors(resp, f"touch subscription {sub_id}")
162
+ create_review_submission(headers, sub_id)
158
163
  sub_results.append({"product_id": sub_config["product_id"], "id": sub_id})
159
164
 
165
+ create_group_submission(headers, group_id)
160
166
  return {"group": ref_name, "group_id": group_id, "subscriptions": sub_results}
161
167
 
162
168