28 KiB
28 KiB
| name | description |
|---|---|
| store-pitfalls | Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page, a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first, stale Capacitor web assets, a declined flow step logged ok. Read it before any store task, and when a store command does something you did not expect. |
Store pitfalls
Each entry: symptom → cause → what to do → what guards it now. The full changelog of lessons with dates is
docs/pitfalls.md in the storebound repository. When you hit a new one, tell the owner and add it there (and here).
1. A product you just created is "not there"
- Symptom:
apple iap-reviewuploads nothing for a productapple iapcreated a minute ago; a list looks short. - Cause: App Store Connect's
/inAppPurchasesV2list (and Play's product list) can lag for a short while after a create. A command that walks the list skips what is not in it yet. - Do: wait a minute and re-run. Never assume a product was handled because the command finished.
- Guard:
apple iap-reviewcompares the config's products with the store's list, names the missing ones ("the store's list can lag a minute after creating; re-run") and exits 1.apple iapandplay productsread the list again after creating and warn about any new product that is not in it yet; their dry runs name products that are not on the store.
2. A dry run that only prints a count
- Symptom:
--drysays "listing: 20 locales" or "3 change(s)", but not which fields would change to what. - Cause: the command logged a summary instead of the plan.
- Do: never ask the owner to approve a count. Show the rows that change (item, action, old → new). If a command
only prints a count, say so and show the owner the diff from
pull/--jsonoutput instead. - Guard:
cli/lib/dry.tsdryPlan(title, rows)prints the standard plan table (apple iap,apple iap-review,play productsuse it);tests/pitfalls.test.tsscanscli/**for--drybranches that print nothing but a count.
3. "The app name you entered is already being used"
- Symptom: creating the App Store record or saving the name fails although you own no app with that name.
- Cause: App Store app names are unique per storefront, across all developers.
- Do: ask the owner for a variant (a short suffix such as "Example Notes: Lists") and set
apple.namein the config; keepapp.namefor Play and the brand. Do not pick the suffix yourself. - Guard:
apple.nameoverrides the App Store name.
4. Keyword field "too long" at fewer than 100 characters
- Symptom: App Store Connect rejects keywords that look short enough, mostly in Turkish, German, CJK.
- Cause: the 100 limit counts UTF-8 bytes, not characters ("ş" is 2 bytes, a CJK character 3).
- Do: shorten by bytes; drop words, not commas.
- Guard:
storebound checkcounts bytes for keywords.
5. Data safety says "email collected" for an app that never asks for one
- Symptom: the proposal marks email (or phone) as collected because the app has accounts.
- Cause: account features do not imply email collection (anonymous ids, Game Center, Play Games sign-in).
- Do: answer only what the code proves; ask the owner for the rest (rule 5).
- Guard:
storebound scanmarks email only with evidence (an email field, an email sign-up call) and otherwise says "ask owner".
6. Boxes or wrong glyphs in localized screenshots
- Symptom: Turkish, Polish or Vietnamese captions show fallback glyphs (ş, ğ, ł, ệ) while the English ones are fine.
- Cause: the font (or the Google Fonts subset that was downloaded) does not cover those characters, and the browser silently draws them with another font.
- Do: check every language's characters before rendering, pick a font that covers them or let the fallback fill in.
- Guard:
storebound shots fonts/shots checkcheck each language's characters against the fonts' glyphs.
7. Console automation stops at sign-in or loses the session
- Symptom:
console fillwaits on a sign-in page, cannot find fields, or is signed out on the next run. - Cause: the App Store Connect sign-in runs inside an
idmsa.apple.comiframe; "User Access" is a plain heading, not a labeled group; Chrome drops session cookies unless the last session is restored. - Do: let the owner sign in in the opened window (never type credentials); re-run after the sign-in.
- Guard: the console session handles the iframe and the plain heading and starts Chrome with
--restore-last-session; a partial miss fills every field found and leaves only the missing ones to the owner.
8. scan misses Capacitor plugins in a monorepo
- Symptom: SDKs or privacy manifests of Capacitor plugins are not found.
- Cause: the plugins live in
apps/native/node_modules(a workspace package), not in the rootnode_modules. - Do: run
scanfrom the project root; check that the stack line lists the plugins. - Guard:
storebound scanandprivacy-manifestresolve packages through workspace folders too.
9. A second run (or a retry) creates a duplicate
- Symptom: two in-app purchases or subscription groups with the same name, a screenshot twice in a set, an offer code campaign or a custom product page twice, after a timeout, a crash, a re-run a minute later, or two runs at once.
- Cause: a POST create retried after a timeout or a 5xx may have been applied the first time; the store's lists lag right after a create, so a re-run does not see what the last run made; two writers race; a Play edit redone after a commit whose answer was lost.
- Do: just run the command again: every write command is an upsert and converges (
docs/idempotency.md). Never retry a create by hand (storebound api) without a GET first. If a run says another storebound run holds the lock, wait for it; use--force-unlockonly when that run is really gone. Never run two write commands for the same app in parallel. - Guard:
cli/lib/upsert.ts(findOrCreate: fresh re-check + journal.storebound/created.json; createOnce: 409 = found, an unclear answer is looked up, never re-sent blindly), retries only for requests that are safe twice, Play edits deleted on every error and never redone after an unclear commit, screenshot / preview sets keep the matching prefix in order,.storebound/locks/per app and store.tests/idempotency.test.ts.
10. "We rank #4 for this keyword" (from the Search API)
- Symptom:
aso ranksays #4, the owner's phone shows the app lower (or higher) in App Store search. - Cause: the iTunes Search API order is close to, not equal to, App Store search: no personalisation, no search ads, no in-app event rows. It has no subtitle field either.
- Do: read ranks as a trend over weeks (
aso auditarrows), never as a promise; never claim a competitor's subtitle (no public API has it); a popularity number exists only from Apple Ads, never estimate one. - Guard:
aso rank,aso competitorsandaso auditsay it in their output;aso suggestshows popularity "unknown" without Apple Ads credentials.
11. "Check our Google Play keyword ranks"
- Symptom: the owner asks for Play ranks or Play competitors per keyword.
- Cause: Google Play has no public search API; reading Play search pages would be scraping.
- Do: say so. Measure Play through the Play Console acquisition reports (search terms) and
analytics snapshot(store listing visitors, acquisitions); improve the listing text withaso audit(short description, density). - Guard:
asocovers App Store search only; the aso-basics skill states the limit.
12. A new Play app: "Create app" is both the opener and the submit
- Symptom: an agent cannot create the Play app through the API, or clicks "Create app" without knowing whether it opens the form or submits it; or an app goes out as Free and the owner later wants it paid.
- Cause: the Play Developer API cannot create apps. Play Console's Home has a "Create app" button that opens the
form, and the form's final button has the same words. Free cannot become Paid later on Google Play. The form
(
/console/u/0/developers/<developerId>/create-new-app, verified 2026-10-12) has an App package name field (max 150) with a read-only "Check availability" button ("Package name available"); the package name is fixed for good. App or game and Free or paid are<material-radio>s: setting the native input leaves the form saying "Select app or game" until the inner[role=radio]is clicked (a JS click works in a background window). - Do:
storebound console plan play-app-recordand drive the form with your browser tool (or the scriptedconsole fill play-app-record); fill the package name and press Check availability; click the inner[role=radio]s and check that no "Select …" hint is left (console verify play-app-record); tell the owner that Free is final; the owner ticks the declarations and presses Create app. Afterwards the app lives at/console/u/0/developers/<developerId>/app/<appId>/app-dashboard(setplay.appId). The service account's app-level permissions do NOT cover the new app: the OWNER adds it (Users and permissions > the service account > Add app > Admin, or the minimal set > Apply > Save changes; never an agent: it is a permission grant), thenstorebound apps check --store play. - Guard:
createAppAllowedlets the opener through only on the app list, never inside a form or dialog (English and Turkish); agreement checkboxes are never ticked; Free or paid is outlined as an owner check.
13. App Privacy purposes over-mapped
- Symptom: App Privacy says Developer's Advertising or Marketing for Device ID, Coarse Location, Product Interaction or Performance Data, although the app only shows an ad SDK's ads.
- Cause: a Play purpose was turned into two Apple purposes ("advertising" -> Third-Party Advertising AND Developer's Advertising or Marketing). Apple's definitions (developer.apple.com/app-store/app-privacy-details/): third-party ads are Third-Party Advertising; Developer's Advertising is first-party ads or marketing to your users.
- Do: one Apple purpose per Play purpose; put the owner's real answers in
appPrivacy.types(Apple type ids, purposes, linked, tracking) and letstorebound checkcompare them with Info.plist and PrivacyInfo.xcprivacy. - Guard:
applePurpose(cli/lib/declarations.ts),appPrivacy.typesvalidation andappPrivacyMismatches(cli/lib/app-privacy.ts),tests/app-privacy.test.ts.
14. An unknown console page: bounded wait and a debug dump
- Symptom:
console fillclicked Next (or opened the form) and then nothing happened in the log for a long time. - Cause: the console showed a page storebound does not recognise (a changed layout, a new question): no field it can fill and no button it may click.
- Do: read the warning: it names the page ("the page after "Next""), its buttons, and where the debug dump is.
Do that part by hand (the manual steps follow), or stop. To report it, re-run with
--debug-dumpand attach the folder.storebound/console-debug/<form>-<time>/after the owner looked atscreen.jpg(the screenshot shows what the window showed).page.json/page.htmlhold labels and structure only: no values, cookies or tokens; emails, phone numbers and the account area are replaced. - Guard: the watchdog in
fillSession(--step-timeout, default 30 s; a heartbeat line a minute; 20 min without progress ends the run with exit 1),cli/console/debug.ts,tests/console-flow.test.ts(unknown-page fixture and a scan for waits without a deadline).
15. App Privacy data types not found in the real dialog
- Symptom: after Get Started and Next,
console fill apple-app-privacyfinds no data type checkbox. - Cause: the real "Data Collection" dialog names each checkbox by its label run together with its help text ("NameIncluding first or last name"), and some labels differ from Apple's help page ("Purchases", "Other Data").
- Do: nothing by hand: storebound matches the label as a prefix. If the console changes again, the watchdog
(pitfall 14) writes a debug dump: re-run with
--debug-dumpand attach it. - Guard:
labelNamesandTYPE_ALIASES(cli/lib/console-forms.ts),tests/console-flow.test.ts.
16. "Still waiting for the sign-in" on a console that is signed in
- Symptom:
console fill(often with the copied Chrome cookies) prints only "still waiting for the sign-in" while the window shows Play Console (the account chooser, the app list). - Cause: Google pages embed
accounts.google.com/ogs.google.comframes (the account widget, cookie rotation) while the owner is signed in; an older detector counted any frame on a sign-in host as a sign-in page. - Do: read the line "sign-in detected because: at " and the heartbeats (they name the page). A
top-level
accounts.google.com/idmsa.apple.com/ App Store Connect/loginURL right after the launch means the copied Chrome session did not carry over (expired or tied to that device): sign in once in the window, or run without--use-chrome-session(pitfall 18). Anything else: re-run with--debug-dump(up to 3 dumps of the sign-in wait) and attach the folder after the owner looked atscreen.jpg. - Guard:
signInReasonincli/console/fill.ts(top-level URL, the main frame's password / email-entry form, a visible Apple sign-in frame; no other frame counts), the sign-in watchdog dump,tests/console-fill.test.ts.
17. App Privacy data types listed but never set up
- Symptom: after the data types were saved,
console fill apple-app-privacystops on the App Privacy page with every type showing "Set Up " and nothing clicked. - Cause: the real page shows "Set Up " as plain text (a
<p>in a div with a click handler: no button, no link) in a box after the Data Types heading, so role-based lookups miss it. - Do: nothing by hand: storebound clicks each "Set Up " by its exact text (only for types it answers, never
the "?" help buttons or Publish) and fills that type's dialog step by step. A dialog step it does not know: it writes
a debug dump, says
paused on "<type>", and goes on once the owner answered that step. - Guard:
setUpAllowed(cli/lib/console-forms.ts),setUpClick/setUpType(cli/console/fill.ts),tests/console-flow.test.tswithtests/fixtures/console/apple-app-privacy-setup.html.
18. Never copy Google session cookies: they are device-bound
- Symptom:
console fill play-app-recordwith the owner's copied Chrome cookies reached the Play account chooser once (22:32); between runs Google showedaccounts.google.com/v3/signin/rejected; from 23:12 the same copy landed on the account chooser. - Cause: Google binds its session cookies to the device. Chrome's Device Bound Session Credentials keep a key in the TPM / Secure Enclave that cannot leave the machine; Google only renews the short-lived cookies for the browser that proves it holds that key (public on Windows from Chrome 146, rolling out on macOS from Chrome 147). Google also signs out sessions whose cookies look stolen ("logged out of their account for that session and any related suspicious sessions on that device", then an extra verification). Copied cookies in a second Chrome look exactly like cookie theft: the copy is refused, and the owner's REAL Chrome session can be signed out or the account asked to verify again.
- Do: never copy Google cookies. The default is storebound's own profile (
~/.storebound/console-profile/<store>): the owner signs in there once, by hand; it is kept.--use-chrome-session googleexists only as an explicit owner opt-in that prints the warning; an agent never adds it. Apple publishes no such binding, but--use-chrome-session appleis opt-in too. If Google says "Couldn't sign you in" in storebound's window, storebound prints "Google refused sign-in in this window (/signin/rejected)": try again, or sign in at accounts.google.com first in that window. - Confirmed (2026-10-12): the earlier default copy of the Google cookies DID sign the owner out of Play Console in their real Chrome.
- Guard:
browserPlan/openConsoleBrowserincli/console/session.ts(no copy without the flag, the copy keeps only the form's own provider), Chrome started by storebound without--enable-automationand withnavigator.webdriverfalse,googleRejected;tests/console-session.test.ts. - Sources: https://blog.google/security/protecting-cookies-with-device-bound-session-credentials/ , https://developer.chrome.com/docs/web-platform/device-bound-session-credentials , https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies
19. play images aborts on missing tablet screenshots
- Symptom:
storebound play images --dryon a phone-only project (renders with feature, ios65, ios69 and play, no tab7 / tab10) stopped withen-US: no sevenInchScreenshots in .../store/renders/en (render first: storebound shots render). - Cause: every Play image type was treated as required. Play requires only the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (Play Console Help, "Add preview assets to showcase your app", answer 9866151); 7" / 10" tablet, TV and Wear screenshots are optional (without tablet screenshots the app is not eligible for tablet featuring).
- Do: nothing special:
play imagesuploads what exists and printstablet screenshots: none (optional; ...). To render and require tablets setplay.tablets: true; to never ask for them,play.tablets: false(default: true when tablet renders exist). A missing optional set never deletes the tablet screenshots Play already has; the dry run showskeeps sevenInchScreenshots on Play, and only--prune(owner's call) deletes them. - Also: the next abort was
GET .../listings/de-DE/icon -> 404 "Listing for language 'de-DE' not found.": 20 languages were stillstatus=draft, soplay listinghad (rightly) not created them.play imagesnow takes the same languages asplay listing(drafts skipped unless--include-drafts) and skips a language with no Play listing (skipped de-DE: no Play listing yet), never failing on it. Runplay listingbeforeplay images. - Guard:
langImages/missingChangeincli/play/images.ts,resolvedTabletsincli/shots/report.ts;tests/play-post-reads.test.ts(phone-only fixture: dry plan, info line, exit 0;tablets: truewithout renders is an error; no tablet DELETE without--prune).
20. Drive consoles agent-first; the scripted fill is a fast path
- Symptom: hours went into fix cycles of the scripted engine's selectors (
console fill) while the console UI kept differing from the model (App Privacy's "Set Up" as plain text, unlabeled purpose checkboxes, two information steps, Play's material radios), each one a stop until a new release. - Cause: a script only knows the page it was written for. An AI agent with a browser tool reads the page as it is and adapts.
- Do: run
storebound console plan <form> --jsonand drive the page yourself (Claude in Chrome, a built-in browser, Playwright) from its answer sheet, never-press list and UI facts; long flows as an in-page async loop that writeswindow.__log(one evaluate call times out around 45 s); stop before the final button and tell the owner what to press; thenstorebound console verify <form>.console fillis the optional scripted fast path; when it stalls, switch to the plan instead of waiting for a selector fix. - Guard:
console plan/console verify(CLI and the read-only MCP toolsconsole_plan,console_verify), the console-autofill skill puts them first;tests/console-plan.test.ts.
21. Play Data safety: conditional required rows
- Symptom:
play data-safetyanswers 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL" (or anotherPSL_...id) although--drylooked fine. - Cause: some questions are required only after another answer. Account creation not
PSL_ACM_NONErequires the delete-account URL (PSL_ACCOUNT_DELETION_URL) and the data-only deletion question (PSL_SUPPORT_DATA_DELETION_BY_USER; when yes,PSL_DATA_DELETION_URL), and Play then refusesPSL_HAS_OUTSIDE_APP_ACCOUNTS. A selected data type needs its whole usage block. - Do: set
accounts.deletionUrl, ask the owner fordataSafety.partialDeletion: true | false(andpartialDeletionUrlwhen true and it is another page), runstorebound check/play data-safety --dryuntil nomissing: <id>is left. If Play names an id storebound does not know, export the form (Play Console > App content > Data safety > Export to CSV) and add the row indataSafety.extra(same id = replaces the generated row). - Guard:
DATA_SAFETY_RULES/typeRules/dataSafetyIssuesincli/lib/data-safety.ts;check,project.declarationsandplay data-safety(dry and real, before the POST) report them. - Sources: https://support.google.com/googleplay/android-developer/answer/10787469 (Data safety form, CSV import), https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link; deleting other data is a separate question).
Adding a lesson
Every bug found in real use gets an entry in docs/pitfalls.md and here, and, where possible, a check or a test
that fails without the fix (see CONTRIBUTING.md).
22. play build: "AAB manifest without package / versionCode" on a good bundle
- Symptom: Gradle printed BUILD SUCCESSFUL, then storebound stopped with
AAB manifest without package / versionCode(storebound before this fix).play uploadandplay releaseused the same reader. - Cause: storebound read the AAB's proto manifest (aapt2 Resources.proto) with the wrong field numbers. Nothing was wrong with the bundle.
- Do: update storebound. Don't rebuild or change the Gradle setup for this error.
- Guard:
aabInfoincli/lib/gradle.ts; a byte-level test that doesn't use storebound's own encoder.
23. Capacitor: native build ships old web assets
- Symptom:
storebound flow run release --applywent straight intoapple build(xcodebuild archive) whileios/App/App/publicandandroid/app/src/main/assets/publicwere 3.5 hours older than the latest source commit: the build would have shipped the old game. - Cause: a Capacitor app's native projects contain a copy of the web build that
npx cap syncmade last. Neither xcodebuild nor Gradle rebuilds or re-copies it; nothing ran the web build + cap sync before the native build. - Do: set
build.prebuild(e.g."npm run build && npx cap sync"; per storeapple.prebuild/play.prebuild).apple build/play buildrun it first in the project root (printed, output streamed, a nonzero exit stops the build;--dryonly prints it;--no-prebuildskips it); inside oneflow runthe shared command runs once, not per store. Never pass--allow-stale-webto get past the check without the owner knowing that the old web build ships. - Guard: after the prebuild, a Capacitor project's copied
public/index.htmlmust be newer than the last git commit outside ios/ android/ node_modules/ store/ .storebound/ and than<webDir>/index.html, else the build stops with "web assets are older than the source (copied ..., source ...)" (webStep/webStalenessincli/lib/web-assets.ts).storebound checkwarns when the assets are stale and no prebuild is set. The build output and the dry-run plan show the web bundle that goes in (sha256 of the copied index.html and its mtime).tests/web-assets.test.ts. - Monorepo: with the Capacitor app in
apps/native(its own capacitor.config.ts, android/, ios/),play buildonce failed with "no Gradle project (settings.gradle) in android or .". The builds, the staleness check andchecknow find the one Capacitor project one or two levels down (workspaces, apps/, packages/) and name it; several are listed and needplay.build.dir/apple.build.workspace|project(capacitorProjectincli/lib/web-assets.ts).
24. A flow step that declined its confirmation was logged "ok"
- Symptom:
flow run release --applyranstorebound apple upload --no-wait, which printed "not a terminal: re-run with --yes to confirm" and uploaded nothing, yet the flow logged "4/13 upload: ok" and went on toapple testflight waitfor a build that was never uploaded. - Cause: a declined confirmation (
confirm()incli/lib/ctx.ts) returned from the command with exit 0, andflow run --yeswas not passed on to the steps, so a run without a terminal could never confirm. - Do: read a step's output, not only its status. For a run without a terminal the owner can approve the writes
with
flow run <name> --apply --yes(only on their word, per run); otherwise run the flow in a terminal. - Guard: every declined or unanswerable confirmation prints
declined: <reason>and exits 3 (DECLINED_EXIT);flow runstops with "step N declined: " (statusdeclined, neverok) and adds--yesto write steps only with--apply --yes(planStepincli/lib/flow.ts,declinedReasonincli/flow.ts).tests/flow.test.ts(non-TTY flow with a confirming step and no--yesfails at that step with "declined").
25. Builds and uploads in a non-interactive shell: no SDK, no output
- Symptom:
storebound play buildstopped in Gradle with "SDK location not found. Define ANDROID_HOME or sdk.dir in local.properties" although the SDK was in~/Library/Android/sdk.storebound apple upload --no-wait --yesran for over 10 minutes with no line at all while it sent the IPA. - Cause: agents and scripts run non-interactive shells:
ANDROID_HOMEfrom.zshrcis not set there, andlocal.properties(where Android Studio writessdk.dir) is gitignored, so a fresh checkout has none. The upload logged only every 10th part and had no timeout per part or per API call, so one large or stalled part was silence. - Do: nothing special now.
play buildfinds the SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT,sdk.dirin local.properties, the Android Studio default folder per OS), passes it to Gradle in the environment and never writes local.properties; without one it stops before Gradle and lists what it checked (storebound doctorshows the SDK and its source).apple uploadprints the size, a line per part and a heartbeat every 20 s (also for altool and the processing wait);--timeoutstops it with exit 7, and running the same command again resumes. Still run long uploads in the background with a log and report its lines. - Guard:
resolveAndroidSdk/androidSdkEnvincli/lib/tools.ts,cli/play/build.ts,cli/doctor.ts;sendPartsincli/apple/upload.ts,cli/lib/progress.ts(injectable heartbeat),waitForBuildincli/apple/testflight.ts, a 2-minute limit per App Store Connect call (ASC_REQUEST_TIMEOUT_MS);tests/tools-path.test.ts,tests/build-sign.test.ts,tests/upload-progress.test.ts.