@daemux/store-automator 0.10.38 → 0.10.39

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.38"
8
+ "version": "0.10.39"
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.38",
15
+ "version": "0.10.39",
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.38",
3
+ "version": "0.10.39",
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.38",
3
+ "version": "0.10.39",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -43,11 +43,11 @@ You are a senior ASO (App Store Optimization) specialist and localization expert
43
43
  | full_description.txt | 4000 chars | Keyword-rich naturally, first 250 chars most important, no keyword stuffing |
44
44
  | changelogs/default.txt | 500 chars | What is new in this version |
45
45
 
46
- ### Shared (fastlane/metadata/ios/)
46
+ ### Shared (fastlane/metadata/)
47
47
 
48
48
  | File | Content |
49
49
  |------|---------|
50
- | copyright.txt | "Copyright {YEAR} {COMPANY_NAME}" |
50
+ | copyright.txt | "Copyright {YEAR} {COMPANY_NAME}" — placed at metadata root, NOT inside locale dirs |
51
51
 
52
52
  ### Age Rating Config (fastlane/app_rating_config.json)
53
53
 
@@ -241,6 +241,7 @@ fastlane/
241
241
  app_rating_config.json
242
242
  data_safety.csv
243
243
  metadata/
244
+ copyright.txt
244
245
  review_information/
245
246
  first_name.txt
246
247
  last_name.txt
@@ -250,7 +251,6 @@ fastlane/
250
251
  demo_password.txt
251
252
  notes.txt
252
253
  ios/
253
- copyright.txt
254
254
  en-US/
255
255
  name.txt
256
256
  subtitle.txt
@@ -58,6 +58,11 @@ IAP and subscription configuration. Durations: ONE_WEEK to ONE_YEAR. Intro offer
58
58
  ### fastlane/screenshots/
59
59
  Store screenshots by platform and device size. Generated by app-designer via Stitch MCP.
60
60
 
61
+ ### App Privacy (Manual Step)
62
+ App Privacy declarations must be set manually by an Admin in the App Store Connect web interface.
63
+ This is a one-time setup per app (not per release). Go to App Store Connect > Your App > App Privacy.
64
+ Apple requires this before the first submission. It cannot be automated via API or fastlane.
65
+
61
66
  ### App Icon Setup
62
67
  The app icon must be set before the first release. Flutter creates projects with a default icon that MUST be replaced.
63
68
 
@@ -31,6 +31,7 @@ ios:
31
31
  price_tier: 0
32
32
  submit_for_review: true
33
33
  automatic_release: true
34
+ content_rights_third_party: false # true if app uses third-party content requiring rights
34
35
 
35
36
  # === ANDROID SETTINGS ===
36
37
  android:
@@ -52,6 +52,9 @@ jobs:
52
52
  id: sync-iap
53
53
  run: scripts/ci/ios/sync-iap.sh
54
54
 
55
+ - name: Setup App Info
56
+ run: scripts/ci/ios/setup-app-info.sh
57
+
55
58
  - name: Save .ci-state
56
59
  if: success()
57
60
  uses: actions/cache/save@v4
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ App Store Connect app setup: content rights and pricing.
4
+
5
+ Sets the content rights declaration and configures free (or paid) pricing
6
+ via the App Store Connect API.
7
+
8
+ Required env vars:
9
+ APP_STORE_CONNECT_KEY_IDENTIFIER - Key ID from App Store Connect
10
+ APP_STORE_CONNECT_ISSUER_ID - Issuer ID from App Store Connect
11
+ APP_STORE_CONNECT_PRIVATE_KEY - Contents of the P8 key file
12
+ BUNDLE_ID - App bundle identifier
13
+
14
+ Optional env vars:
15
+ PRICE_TIER - Price tier (default: "0" for free)
16
+ CONTENT_RIGHTS_THIRD_PARTY - "true" if app uses third-party content (default: "false")
17
+
18
+ Exit codes:
19
+ 0 - Setup completed successfully
20
+ 1 - Any failure (missing env vars, API error, etc.)
21
+ """
22
+ import os
23
+ import sys
24
+ import time
25
+
26
+ try:
27
+ import jwt
28
+ import requests
29
+ except ImportError:
30
+ import subprocess
31
+ subprocess.check_call(
32
+ [sys.executable, "-m", "pip", "install", "--break-system-packages", "PyJWT", "cryptography", "requests"],
33
+ stdout=subprocess.DEVNULL,
34
+ )
35
+ import jwt
36
+ import requests
37
+
38
+ BASE_URL = "https://api.appstoreconnect.apple.com/v1"
39
+ TIMEOUT = (10, 30)
40
+
41
+
42
+ def get_jwt_token(key_id: str, issuer_id: str, private_key: str) -> str:
43
+ """Generate a signed JWT for App Store Connect API authentication."""
44
+ now = int(time.time())
45
+ payload = {
46
+ "iss": issuer_id,
47
+ "iat": now,
48
+ "exp": now + 1200,
49
+ "aud": "appstoreconnect-v1",
50
+ }
51
+ return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id})
52
+
53
+
54
+ def get_app_id(headers: dict, bundle_id: str) -> str:
55
+ """Look up the App Store Connect app ID for the given bundle identifier."""
56
+ resp = requests.get(
57
+ f"{BASE_URL}/apps",
58
+ params={"filter[bundleId]": bundle_id},
59
+ headers=headers,
60
+ timeout=TIMEOUT,
61
+ )
62
+ resp.raise_for_status()
63
+ data = resp.json().get("data", [])
64
+ if not data:
65
+ print(f"ERROR: No app found for bundle ID '{bundle_id}'", file=sys.stderr)
66
+ sys.exit(1)
67
+ return data[0]["id"]
68
+
69
+
70
+ def print_api_errors(resp, action: str) -> None:
71
+ """Print human-readable API error messages."""
72
+ try:
73
+ errors = resp.json().get("errors", [])
74
+ for err in errors:
75
+ detail = err.get("detail", err.get("title", "Unknown error"))
76
+ print(f"ERROR ({action}): {detail}", file=sys.stderr)
77
+ except (ValueError, KeyError):
78
+ print(f"ERROR ({action}): HTTP {resp.status_code} - {resp.text[:200]}", file=sys.stderr)
79
+
80
+
81
+ def set_content_rights(headers: dict, app_id: str, uses_third_party: bool) -> None:
82
+ """Set the content rights declaration on the app."""
83
+ desired = "USES_THIRD_PARTY_CONTENT" if uses_third_party else "DOES_NOT_USE_THIRD_PARTY_CONTENT"
84
+
85
+ resp = requests.get(f"{BASE_URL}/apps/{app_id}", headers=headers, timeout=TIMEOUT)
86
+ resp.raise_for_status()
87
+ current = resp.json()["data"]["attributes"].get("contentRightsDeclaration")
88
+
89
+ if current == desired:
90
+ print(f" Content rights already set to '{desired}', skipping.")
91
+ return
92
+
93
+ patch_resp = requests.patch(
94
+ f"{BASE_URL}/apps/{app_id}",
95
+ json={
96
+ "data": {
97
+ "type": "apps",
98
+ "id": app_id,
99
+ "attributes": {"contentRightsDeclaration": desired},
100
+ }
101
+ },
102
+ headers=headers,
103
+ timeout=TIMEOUT,
104
+ )
105
+ if not patch_resp.ok:
106
+ print_api_errors(patch_resp, "set content rights")
107
+ sys.exit(1)
108
+ print(f" Content rights set to '{desired}'.")
109
+
110
+
111
+ def get_app_price_points(headers: dict, app_id: str, territory: str = "USA") -> list:
112
+ """Fetch all price points for the app in the given territory, with pagination."""
113
+ url = f"{BASE_URL}/apps/{app_id}/appPricePoints"
114
+ params = {"filter[territory]": territory, "limit": 200}
115
+ all_points = []
116
+
117
+ while url:
118
+ resp = requests.get(url, params=params, headers=headers, timeout=TIMEOUT)
119
+ resp.raise_for_status()
120
+ body = resp.json()
121
+ all_points.extend(body.get("data", []))
122
+ url = body.get("links", {}).get("next")
123
+ params = None
124
+
125
+ return all_points
126
+
127
+
128
+ def find_price_point_for_tier(price_points: list, tier: int = 0) -> str:
129
+ """Find the price point ID matching the given tier. Tier 0 = free."""
130
+ if tier == 0:
131
+ for pp in price_points:
132
+ price = pp.get("attributes", {}).get("customerPrice", "")
133
+ if price in ("0", "0.0", "0.00"):
134
+ return pp["id"]
135
+ print("ERROR: Could not find a free (tier 0) price point.", file=sys.stderr)
136
+ sys.exit(1)
137
+
138
+ for pp in price_points:
139
+ price = pp.get("attributes", {}).get("customerPrice", "")
140
+ try:
141
+ if float(price) > 0 and pp.get("attributes", {}).get("priceTier") == str(tier):
142
+ return pp["id"]
143
+ except (ValueError, TypeError):
144
+ continue
145
+ print(f"ERROR: Could not find price point for tier {tier}.", file=sys.stderr)
146
+ sys.exit(1)
147
+
148
+
149
+ def _is_pricing_set(headers: dict, app_id: str, price_tier: int) -> bool:
150
+ """Check if the app already has pricing set for the given tier."""
151
+ schedule_resp = requests.get(
152
+ f"{BASE_URL}/apps/{app_id}/appPriceSchedule",
153
+ params={"include": "manualPrices,baseTerritory"},
154
+ headers=headers,
155
+ timeout=TIMEOUT,
156
+ )
157
+ if not schedule_resp.ok:
158
+ return False
159
+
160
+ included = schedule_resp.json().get("included", [])
161
+ for item in included:
162
+ if item.get("type") == "appPrices":
163
+ existing_price = item.get("attributes", {}).get("customerPrice", "")
164
+ if price_tier == 0 and existing_price in ("0", "0.0", "0.00"):
165
+ return True
166
+ return False
167
+
168
+
169
+ def _create_price_schedule(headers: dict, app_id: str, price_point_id: str) -> requests.Response:
170
+ """POST a new app price schedule and return the response."""
171
+ price_ref_id = "${price-usa}"
172
+ return requests.post(
173
+ f"{BASE_URL}/appPriceSchedules",
174
+ json={
175
+ "data": {
176
+ "type": "appPriceSchedules",
177
+ "relationships": {
178
+ "app": {"data": {"type": "apps", "id": app_id}},
179
+ "baseTerritory": {"data": {"type": "territories", "id": "USA"}},
180
+ "manualPrices": {"data": [{"type": "appPrices", "id": price_ref_id}]},
181
+ },
182
+ },
183
+ "included": [
184
+ {
185
+ "type": "appPrices",
186
+ "id": price_ref_id,
187
+ "attributes": {"startDate": None},
188
+ "relationships": {
189
+ "appPricePoint": {
190
+ "data": {"type": "appPricePoints", "id": price_point_id}
191
+ }
192
+ },
193
+ }
194
+ ],
195
+ },
196
+ headers=headers,
197
+ timeout=TIMEOUT,
198
+ )
199
+
200
+
201
+ def set_app_pricing(headers: dict, app_id: str, price_tier: int = 0) -> None:
202
+ """Set the app price schedule. Handles idempotency via pre-check and 409 Conflict."""
203
+ if _is_pricing_set(headers, app_id, price_tier):
204
+ print(" Pricing already set to free, skipping.")
205
+ return
206
+
207
+ print(f" Fetching price points for USA territory...")
208
+ price_points = get_app_price_points(headers, app_id, territory="USA")
209
+ if not price_points:
210
+ print("ERROR: No price points returned for USA territory.", file=sys.stderr)
211
+ sys.exit(1)
212
+
213
+ pp_id = find_price_point_for_tier(price_points, tier=price_tier)
214
+ print(f" Found price point ID: {pp_id}")
215
+
216
+ post_resp = _create_price_schedule(headers, app_id, pp_id)
217
+ tier_label = "Free" if price_tier == 0 else f"Tier {price_tier}"
218
+
219
+ if post_resp.status_code == 409:
220
+ print(f" Pricing already set ({tier_label}), skipping (409 Conflict).")
221
+ return
222
+ if not post_resp.ok:
223
+ print_api_errors(post_resp, "set app pricing")
224
+ sys.exit(1)
225
+ print(f" Pricing set to {tier_label}.")
226
+
227
+
228
+ def main() -> None:
229
+ key_id = os.environ.get("APP_STORE_CONNECT_KEY_IDENTIFIER", "")
230
+ issuer_id = os.environ.get("APP_STORE_CONNECT_ISSUER_ID", "")
231
+ private_key = os.environ.get("APP_STORE_CONNECT_PRIVATE_KEY", "")
232
+ bundle_id = os.environ.get("BUNDLE_ID", "")
233
+ price_tier_str = os.environ.get("PRICE_TIER", "0")
234
+ third_party_str = os.environ.get("CONTENT_RIGHTS_THIRD_PARTY", "false")
235
+
236
+ missing = []
237
+ if not key_id:
238
+ missing.append("APP_STORE_CONNECT_KEY_IDENTIFIER")
239
+ if not issuer_id:
240
+ missing.append("APP_STORE_CONNECT_ISSUER_ID")
241
+ if not private_key:
242
+ missing.append("APP_STORE_CONNECT_PRIVATE_KEY")
243
+ if not bundle_id:
244
+ missing.append("BUNDLE_ID")
245
+ if missing:
246
+ print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
247
+ sys.exit(1)
248
+
249
+ try:
250
+ price_tier = int(price_tier_str)
251
+ except ValueError:
252
+ print(f"ERROR: PRICE_TIER must be an integer, got '{price_tier_str}'", file=sys.stderr)
253
+ sys.exit(1)
254
+
255
+ uses_third_party = third_party_str.lower() == "true"
256
+
257
+ token = get_jwt_token(key_id, issuer_id, private_key)
258
+ headers = {
259
+ "Authorization": f"Bearer {token}",
260
+ "Content-Type": "application/json",
261
+ }
262
+
263
+ print(f"Looking up app for bundle ID '{bundle_id}'...")
264
+ app_id = get_app_id(headers, bundle_id)
265
+ print(f"Found app ID: {app_id}")
266
+
267
+ print("Setting content rights declaration...")
268
+ set_content_rights(headers, app_id, uses_third_party)
269
+
270
+ print("Setting app pricing...")
271
+ set_app_pricing(headers, app_id, price_tier)
272
+
273
+ print("App setup complete.")
274
+
275
+
276
+ if __name__ == "__main__":
277
+ main()
@@ -53,6 +53,9 @@ export PRICE_TIER=$(yq '.ios.price_tier // ""' "$CONFIG")
53
53
  export SUBMIT_FOR_REVIEW=$(yq '.ios.submit_for_review // "false"' "$CONFIG")
54
54
  export AUTOMATIC_RELEASE=$(yq '.ios.automatic_release // "false"' "$CONFIG")
55
55
 
56
+ # App Info settings
57
+ export CONTENT_RIGHTS_THIRD_PARTY=$(yq '.ios.content_rights_third_party // "false"' "$CONFIG")
58
+
56
59
  # Android Play Store settings
57
60
  export TRACK=$(yq '.android.track // "internal"' "$CONFIG")
58
61
  export ROLLOUT_FRACTION=$(yq '.android.rollout_fraction // ""' "$CONFIG")
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # Sets up App Store Connect app info (content rights, age rating, categories) via ASC API.
3
+ # Requires read-config.sh to have been sourced (provides PROJECT_ROOT, credentials, etc.).
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ source "$SCRIPT_DIR/../common/read-config.sh"
8
+ source "$SCRIPT_DIR/../common/ci-notify.sh"
9
+
10
+ echo "=== App Store Connect App Info Setup ==="
11
+
12
+ # --- Validate ASC credentials ---
13
+ if [ -z "${APPLE_KEY_ID:-}" ] || [ -z "${APPLE_ISSUER_ID:-}" ]; then
14
+ ci_skip "ASC credentials not configured"
15
+ fi
16
+
17
+ # --- Set up App Store Connect API credentials ---
18
+ P8_FULL_PATH="$PROJECT_ROOT/$P8_KEY_PATH"
19
+ if [ ! -f "$P8_FULL_PATH" ]; then
20
+ echo "ERROR: P8 key file not found at $P8_FULL_PATH" >&2
21
+ exit 1
22
+ fi
23
+
24
+ export APP_STORE_CONNECT_KEY_IDENTIFIER="$APPLE_KEY_ID"
25
+ export APP_STORE_CONNECT_ISSUER_ID="$APPLE_ISSUER_ID"
26
+ export APP_STORE_CONNECT_PRIVATE_KEY="$(cat "$P8_FULL_PATH")"
27
+ export BUNDLE_ID="$BUNDLE_ID"
28
+ export PROJECT_ROOT="$PROJECT_ROOT"
29
+
30
+ echo "ASC API key configured (Key ID: $APPLE_KEY_ID)"
31
+
32
+ # --- Run app setup via Python ---
33
+ SETUP_SCRIPT="$PROJECT_ROOT/scripts/asc_app_setup.py"
34
+
35
+ if [ ! -f "$SETUP_SCRIPT" ]; then
36
+ echo "ERROR: asc_app_setup.py not found at $SETUP_SCRIPT" >&2
37
+ exit 1
38
+ fi
39
+
40
+ echo "Setting up App Store Connect app info..."
41
+ python3 "$SETUP_SCRIPT"
42
+
43
+ ci_done "App Store Connect app info configured"