1245 lines
110 KiB
Markdown
1245 lines
110 KiB
Markdown
---
|
|
name: store-pitfalls
|
|
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - first: do the work, don't ask the owner for every step; 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 characters (not bytes), Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries and re-runs, search ranks that are only a trend, no Play 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, 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).
|
|
|
|
## First: do it, don't ask (the costliest pitfall)
|
|
You and storebound do everything: API calls, uploads, builds, CSV / template exports, every console navigation
|
|
(open, Get Started, Edit, Next, Continue, Save draft), console forms, verification. Never hand the owner "run this" or
|
|
"click X". The owner presses only the final public or irreversible buttons (Submit for review, release or a
|
|
production rollout, App Privacy Publish, legal agreements; deleting store data, live prices, review replies,
|
|
testers) and answers, in ONE batch at the start, only the facts no file proves (compliance answers that `check` /
|
|
`validate` list). Loop: plan, drive, verify, continue. Full rule: `docs/agents-core.md` (AGENTS.md); lesson 26.
|
|
|
|
## 1. A product you just created is "not there"
|
|
- **Symptom:** `apple iap-review` uploads nothing for a product `apple iap` created a minute ago; a list looks short.
|
|
- **Cause:** App Store Connect's `/inAppPurchasesV2` list (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-review` compares 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 iap` and `play products` read 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:** `--dry` says "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` / `--json` output instead.
|
|
- **Guard:** `cli/lib/dry.ts` `dryPlan(title, rows)` prints the standard plan table (`apple iap`, `apple iap-review`,
|
|
`play products` use it); `tests/pitfalls.test.ts` scans `cli/**` for `--dry` branches 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.name` in the config;
|
|
keep `app.name` for Play and the brand. Do not pick the suffix yourself.
|
|
- **Guard:** `apple.name` overrides the App Store name.
|
|
|
|
## 4. Keyword field length (superseded by 43)
|
|
- The limit is 100 characters, not UTF-8 bytes (see 43). Count characters; drop whole words, not commas.
|
|
|
|
## 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 scan` marks 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 check` check each language's characters against the fonts' glyphs.
|
|
|
|
## 7. Console automation stops at sign-in or loses the session
|
|
- **Symptom:** `console fill` waits 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.com` iframe; "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 root `node_modules`.
|
|
- **Do:** run `scan` from the project root; check that the stack line lists the plugins.
|
|
- **Guard:** `storebound scan` and `privacy-manifest` resolve 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-unlock` only 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 rank` says #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 audit` arrows), 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 competitors` and `aso audit` say it in their output; `aso suggest` shows 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 with `aso audit` (short description, density).
|
|
- **Guard:** `aso` covers 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-record` and drive the form with your browser tool (or the scripted
|
|
`console 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` (set `play.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), then
|
|
`storebound apps check --store play`.
|
|
- **Guard:** `createAppAllowed` lets 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 let `storebound check` compare them with Info.plist and PrivacyInfo.xcprivacy.
|
|
- **Guard:** `applePurpose` (cli/lib/declarations.ts), `appPrivacy.types` validation and `appPrivacyMismatches`
|
|
(cli/lib/app-privacy.ts), `tests/app-privacy.test.ts`.
|
|
|
|
## 14. An unknown console page: bounded wait and a debug dump
|
|
- **Symptom:** `console fill` clicked 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.
|
|
Drive that part yourself with your browser tool from `storebound console plan <form>` (agent-first), then go on. To report it, re-run with `--debug-dump` and attach the
|
|
folder `.storebound/console-debug/<form>-<time>/` after the owner looked at `screen.jpg` (the screenshot shows what
|
|
the window showed). `page.json` / `page.html` hold 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/browser/console-flow.browser.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-privacy` finds 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-dump` and attach it.
|
|
- **Guard:** `labelNames` and `TYPE_ALIASES` (`cli/lib/console-forms.ts`), `tests/browser/console-flow.browser.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.com` frames (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: <rule> at <url>" and the heartbeats (they name the page). A
|
|
top-level `accounts.google.com` / `idmsa.apple.com` / App Store Connect `/login` URL 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 at `screen.jpg`.
|
|
- **Guard:** `signInReason` in `cli/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/browser/console-fill.browser.ts`.
|
|
|
|
## 17. App Privacy data types listed but never set up
|
|
- **Symptom:** after the data types were saved, `console fill apple-app-privacy` stops on the App Privacy page with
|
|
every type showing "Set Up <type>" and nothing clicked.
|
|
- **Cause:** the real page shows "Set Up <type>" 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 <type>" 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/browser/console-flow.browser.ts` with `tests/fixtures/console/apple-app-privacy-setup.html`.
|
|
|
|
## 18. Never copy Google session cookies: they are device-bound
|
|
- **Symptom:** `console fill play-app-record` with the owner's copied Chrome cookies reached the Play account chooser
|
|
once (22:32); between runs Google showed `accounts.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 google` exists 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
|
|
apple` is 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` / `openConsoleBrowser` in `cli/console/session.ts` (no copy without the flag, the copy keeps
|
|
only the form's own provider), Chrome started by storebound without `--enable-automation` and with
|
|
`navigator.webdriver` false, `googleRejected`; `tests/browser/console-session.browser.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 --dry` on a phone-only project (renders with feature, ios65, ios69 and play, no
|
|
tab7 / tab10) stopped with `en-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 images` uploads what exists and prints `tablet screenshots: none (optional; ...)`. To
|
|
render and require tablets set `play.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
|
|
shows `keeps 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 still `status=draft`, so `play listing` had (rightly) not created them. `play images` now takes the same
|
|
languages as `play 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. Run `play listing` before `play images`.
|
|
- **Guard:** `langImages` / `missingChange` in `cli/play/images.ts`, `resolvedTablets` in `cli/shots/report.ts`;
|
|
`tests/play-post-reads.test.ts` (phone-only fixture: dry plan, info line, exit 0; `tablets: true` without 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> --json` and 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
|
|
writes `window.__log` (one evaluate call times out around 45 s); stop before the final button and tell the owner
|
|
what to press; then `storebound console verify <form>`. `console fill` is 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 tools `console_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-safety` answers 400 "Invalid safety labels declaration: Response missing for
|
|
PSL_ACCOUNT_DELETION_URL" (or another `PSL_...` id) although `--dry` looked fine.
|
|
- **Cause:** some questions are required only after another answer. Account creation not `PSL_ACM_NONE` requires 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 refuses
|
|
`PSL_HAS_OUTSIDE_APP_ACCOUNTS`. A selected data type needs its whole usage block. The CSV must also use the current
|
|
form's Response IDs: `PSL_SUPPORT_DATA_DELETION_BY_USER` became a single choice (`DATA_DELETION_YES` /
|
|
`DATA_DELETION_NO` / `DATA_DELETION_NO_AUTO_DELETED`) and a bare FALSE got 400 "Line 7: Response ID missing";
|
|
`PSL_DATA_COLLECTION_USER_REQUEST_DELETE` was removed from the form.
|
|
- **Do:** set `accounts.deletionUrl` (it also answers `PSL_SUPPORT_DATA_DELETION_BY_USER` with yes: that is the
|
|
general "can users request deletion?" question, not "without deleting the account"; a no next to a delete-account
|
|
URL was a false declaration storebound wrote once), set `partialDeletionUrl` only for a data-only page, run `storebound check` / `play data-safety --dry` until no
|
|
`missing: <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 in `dataSafety.extra` (same id = replaces the generated row).
|
|
When Play changes the form, export it empty and save it over `cli/data/play-data-safety-form.csv`.
|
|
- **Guard:** `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts`; `check`,
|
|
`project.declarations` and `play data-safety` (dry and real, before the POST) report them. storebound checks every
|
|
(Question ID, Response ID) pair, `dataSafety.extra` included, against Google's exported empty form
|
|
(`cli/data/play-data-safety-form.csv`, 2026-10-12; `formPairIssues`) and takes each Answer requirement from it;
|
|
`tests/pure.test.ts` (pairs from Play's exported form).
|
|
- **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 upload` and `play release` used 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:** `aabInfo` in `cli/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 --apply` went straight into `apple build` (xcodebuild archive) while
|
|
`ios/App/App/public` and `android/app/src/main/assets/public` were 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 sync` made 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 store `apple.prebuild` /
|
|
`play.prebuild`). `apple build` / `play build` run it first in the project root (printed, output streamed, a
|
|
nonzero exit stops the build; `--dry` only prints it; `--no-prebuild` skips it); inside one `flow run` the shared
|
|
command runs once, not per store. Never pass `--allow-stale-web` to 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.html` must 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` / `webStaleness` in
|
|
`cli/lib/web-assets.ts`). `storebound check` warns 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 build`
|
|
once failed with "no Gradle project (settings.gradle) in android or .". The builds, the staleness check and `check`
|
|
now find the one Capacitor project one or two levels down (workspaces, apps/*, packages/*) and name it; several are
|
|
listed and need `play.build.dir` / `apple.build.workspace|project` (`capacitorProject` in `cli/lib/web-assets.ts`).
|
|
|
|
## 24. A flow step that declined its confirmation was logged "ok"
|
|
- **Symptom:** `flow run release --apply` ran `storebound 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 to
|
|
`apple testflight wait` for a build that was never uploaded.
|
|
- **Cause:** a declined confirmation (`confirm()` in `cli/lib/ctx.ts`) returned from the command with exit 0, and
|
|
`flow run --yes` was 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. After showing the dry run, run the writes yourself with
|
|
`flow run <name> --apply --yes` (owner steps never run in a flow, they print as "YOUR CLICK").
|
|
- **Guard:** every declined or unanswerable confirmation prints `declined: <reason>` and exits 3 (`DECLINED_EXIT`);
|
|
`flow run` stops with "step N declined: <reason>" (status `declined`, never `ok`) and adds `--yes` to write steps
|
|
only with `--apply --yes` (`planStep` in `cli/lib/flow.ts`, `declinedReason` in `cli/flow.ts`).
|
|
`tests/flow.test.ts` (non-TTY flow with a confirming step and no `--yes` fails at that step with "declined").
|
|
|
|
## 25. Builds and uploads in a non-interactive shell: no SDK, no output
|
|
- **Symptom:** `storebound play build` stopped 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 --yes` ran
|
|
for over 10 minutes with no line at all while it sent the IPA.
|
|
- **Cause:** agents and scripts run non-interactive shells: `ANDROID_HOME` from `.zshrc` is not set there, and
|
|
`local.properties` (where Android Studio writes `sdk.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 build` finds the SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT,
|
|
`sdk.dir` in 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 doctor`
|
|
shows the SDK and its source). `apple upload` prints the size, a line per part and a heartbeat every 20 s (also for
|
|
altool and the processing wait); `--timeout` stops 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` / `androidSdkEnv` in `cli/lib/tools.ts`, `cli/play/build.ts`, `cli/doctor.ts`;
|
|
`sendParts` in `cli/apple/upload.ts`, `cli/lib/progress.ts` (injectable heartbeat), `waitForBuild` in
|
|
`cli/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`.
|
|
|
|
## 26. Asking the owner for every step
|
|
- **Symptom:** an agent using storebound spent hours handing the owner one click, one command or one answer at a
|
|
time: "open App content", "press Next", "download the CSV template", "now run the upload", "what is your
|
|
answer for X?" in the middle of a run.
|
|
- **Cause:** skills and rules said "let the owner do it" and "by hand" for navigation, draft saves, exports and
|
|
uploads, and compliance questions were asked when the run reached them, not at the start.
|
|
- **Do:** follow the first rule (top of this file, `docs/agents-core.md`): do every API call, upload, build, export,
|
|
console navigation and form yourself, verify by reading back, go on. Collect every fact no file proves in one batch
|
|
at the start (`storebound check` / `validate` list them) and save the answers in the config. Stop only for the
|
|
owner steps (Submit for review, release / production rollout, App Privacy Publish, legal agreements, deleting store
|
|
data, live prices, review replies, testers), prepared with the dry run shown. Make `check` / `--dry` catch store
|
|
refusals so they never surface as a 400 mid-run.
|
|
- **Guard:** the rule is the first section of `docs/agents-core.md` and so of every generated adapter (AGENTS.md,
|
|
GEMINI.md, Copilot and Cursor rules), of README and of this skill; `tests/do-it-dont-ask.test.ts` checks that and
|
|
fails when a skill or the rules hand navigation, draft saves, exports, uploads or form filling to the owner
|
|
(navigation "by hand", uploads or exports left to the owner, asking the owner to click through a form).
|
|
|
|
## 27. Play steps without an API: category, countries, App content, IARC
|
|
- **Symptom:** after a first Play release through the API the App category showed "Not selected", production had 0
|
|
countries, and an agent hunted for each App content card; the IARC questionnaire kept revealing new questions, an
|
|
evaluate call timed out at 45 s, material checkboxes ignored `form_input`, and "Send changes for review" ignored a
|
|
JS click.
|
|
- **Cause:** `play listing` cannot set the category (edits.details has no category field) and
|
|
edits.countryAvailability is read-only; the IARC questionnaire is one long page where each Yes reveals follow-ups;
|
|
Play's `<material-checkbox>` / `<material-radio>` keep their own model.
|
|
- **Do:** set `play.category`, `play.countries` (or all), `console.contentRating.details`, `console.appContent`,
|
|
`social` and `products[].randomRewards` from ONE batch of owner answers (`storebound console plan play-app-content`
|
|
lists them), then drive `console plan play-store-settings`, `play-app-content` and its cards,
|
|
`play-production-countries` and `play-production-release`. Click the inner `[role=radio]` / `[role=checkbox]`;
|
|
run long flows with the plan's `inPageLoop` and poll `window.__log`. Never tick the IARC Terms of Use box (the
|
|
owner ticks it before the run: lesson 39); the Summary's button is "Save", and like every App content card Save it
|
|
is yours (lesson 41). "Send changes for review" is the owner's real click; managed
|
|
publishing is off by default, so approved means live (`play.managedPublishing`).
|
|
- **Guard:** the plans above, `check` (category warning, open App content answers), `validate` (category reminder,
|
|
"production has 0 countries"), `play status` / `play release` notes; `tests/console-play-plans.test.ts`.
|
|
|
|
## 28. First App Store IAPs: `apple iap-submit --with-version`, not the console
|
|
- **Symptom:** the IAP page's "Add for Review" menu ignored scripted clicks, and the version page no longer has an
|
|
"In-App Purchases and Subscriptions" section.
|
|
- **Do:** `storebound apple iap-submit --with-version --dry`, then after the owner's yes (it submits)
|
|
`--with-version --yes`: on a real first release it added the IAPs to the draft submission that already held
|
|
version 1.0.0 and submitted them, first try. App Privacy: the owner's Publish opens "Publish Your App Privacy
|
|
Responses?" (Cancel / Publish); afterwards "Published … ago by …" shows, which `console verify apple-app-privacy`
|
|
reads as the published signal.
|
|
- **Guard:** appstore-first-release and store-iap-setup skills; `tests/console-play-plans.test.ts`.
|
|
|
|
## 29. ITMS-91064: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains
|
|
- **Symptom:** Apple marks the build Invalid Binary a minute after the upload: "ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid."
|
|
- **Cause:** the app's own PrivacyInfo.xcprivacy said NSPrivacyTracking true with an empty NSPrivacyTrackingDomains
|
|
because App Privacy says "used for tracking", but the tracking came from an ad SDK (mediation, attribution) whose own
|
|
manifest declares it. Apple's text names the other direction (domains listed with false); both are refused.
|
|
- **Do:** in every manifest, NSPrivacyTracking true needs a non-empty NSPrivacyTrackingDomains (domains the code of
|
|
THAT bundle sends tracking data to), and a non-empty list needs true. When only an SDK tracks, the app's manifest
|
|
says false; App Privacy in App Store Connect still says tracking = yes and ATT stays. Set
|
|
`appPrivacy.trackingDomains` only for the app's own tracking domains. A bad SDK manifest: update the SDK. Rebuild
|
|
and upload a new build number.
|
|
- **Guard:** `check`, `scan` and `privacy-manifest` fail on either mismatch with the ITMS text; `check` reports
|
|
tracking declared by an SDK manifest as info and warns only when no manifest declares it; `privacy-manifest
|
|
--propose` writes true only with `appPrivacy.trackingDomains`; `apple upload` checks every manifest in the IPA
|
|
(app root, Frameworks, bundles) before any network call, `apple build` after the export;
|
|
`tests/privacy-tracking.test.ts`.
|
|
|
|
## 30. Resubmit after a rejection: reuse the UNRESOLVED_ISSUES submission
|
|
- **Symptom:** after an Invalid Binary rejection, `apple iap-submit --with-version --yes` created a new, empty draft
|
|
and stopped with "the store says it already exists but does not list it yet (its lists lag ...)". The real answer
|
|
was 409 `ITEM_PART_OF_ANOTHER_SUBMISSION`: the version was still in the rejected submission.
|
|
- **Cause:** a rejected submission stays open with UNRESOLVED_ISSUES and keeps its items (the REJECTED version, its
|
|
IAPs); storebound only looked for READY_FOR_REVIEW drafts, and read the 409's "already added" as a duplicate.
|
|
- **Also seen (same day):** with build 2 VALID, APP_STORE_ELIGIBLE and attached, every `PATCH reviewSubmissions/{id}
|
|
submitted: true` answered 409 STATE_ERROR "Version is not ready to be submitted yet, please try again later" for
|
|
25 minutes, and the console's Resubmit and Add for Review buttons were disabled. Retrying never helps: the
|
|
appStoreVersion item was still REJECTED. `PATCH /v1/reviewSubmissionItems/{item id}` with
|
|
`{"data":{"type":"reviewSubmissionItems","id":"<item id>","attributes":{"resolved":true}}}` answered 200, the item
|
|
went to READY_FOR_REVIEW, and the submit then passed (WAITING_FOR_REVIEW). The 7 IAP items were READY_FOR_REVIEW.
|
|
An empty READY_FOR_REVIEW draft (0 items) cannot be canceled: `canceled: true` answers 409 "Resource is not in
|
|
cancellable state". It is harmless; leave it (a later run reuses it).
|
|
- **Do:** upload the fixed build, `storebound apple status` (shows the submission, its items, which are REJECTED and
|
|
not resolved, and the command), then `storebound apple resubmit --build <n>` (dry run, plan table with "mark
|
|
resolved" rows) and after the owner's yes `--yes`: it attaches the build, marks the REJECTED version item resolved,
|
|
then submits. REJECTED IAP / subscription items are marked resolved only with `--resolve-iaps` (after fixing what
|
|
App Review asked for). Right after a build is attached, "not ready yet" with nothing left REJECTED is retried with
|
|
backoff (up to 10 min) and a re-run continues where it stopped. Never offer a cancel command for an empty draft.
|
|
- **Guard:** `chooseSubmission` (cli/lib/review-submit.ts) reuses an open submission and never creates a second one;
|
|
`addItem` turns the 409 into a message naming the other submission (never the lag message: `isConflict` excludes
|
|
it), a draft created by the same run is left as is (not cancellable) and the named one used; `resolveRejected` /
|
|
`toResolve` mark REJECTED items in scope resolved before the submit; `submitWithRetry` (cli/apple/submission.ts)
|
|
re-reads the items on "not ready", resolves those in scope and stops at once (naming the item and how to resolve
|
|
it) when one out of scope is left, backing off only when none is; `apple status` labels an empty draft "empty draft
|
|
(harmless; App Store Connect keeps it, not cancellable)" and treats the cancel 409 as harmless; the fake ASC blocks
|
|
the submit while an item is REJECTED and refuses to cancel an empty draft; `tests/resubmit.test.ts`.
|
|
|
|
## 31. After a canceled submission: DEVELOPER_REJECTED versions go in as they are
|
|
- **Symptom:** the owner canceled a WAITING_FOR_REVIEW submission; `apple iap-submit --with-version --dry` then
|
|
skipped every IAP ("latest version is DEVELOPER_REJECTED: change it in App Store Connect (or --new-version)") and
|
|
said "nothing to submit".
|
|
- **Cause:** canceling leaves the app version DEVELOPER_REJECTED (then PREPARE_FOR_SUBMISSION) and every IAP version
|
|
DEVELOPER_REJECTED. Apple accepts the same IAP version again: `POST /v1/reviewSubmissionItems` → 201
|
|
READY_FOR_REVIEW, then the submit → 200 WAITING_FOR_REVIEW. storebound treated it like an approved product.
|
|
- **Do:** `storebound apple iap-submit --with-version --dry`: the plan lists them as "add (developer rejected,
|
|
re-submitted as is)" into the open draft (an empty one is reused); then after the owner's yes `--yes`. Use
|
|
`--new-version` only for APPROVED or REJECTED products whose metadata changed.
|
|
- **Guard:** `planIapSubmission` (cli/lib/review-submit.ts) takes DEVELOPER_REJECTED IAP, subscription and group
|
|
versions; the fake ASC models the cancel and the re-add; `tests/resubmit.test.ts`.
|
|
|
|
## 32. `play build`: the AAB keeps the build file's versionCode
|
|
- **Symptom:** `play build` passed versionCode 3, the AAB had versionCode 1 (the post-build check stopped it). The
|
|
Capacitor `app/build.gradle` read versionCode from an env var.
|
|
- **Cause:** storebound passes `-PversionCode` / `-PversionName` (and `android.injected.version.*`); a build file
|
|
that does not read the project property ignores them. `android.injected.version.code` does NOT override
|
|
`defaultConfig` for `bundleRelease` (checked with AGP 8.13).
|
|
- **Do:** in `defaultConfig`, Groovy: `versionCode((project.findProperty('versionCode') ?: 1) as Integer)` and
|
|
`versionName project.findProperty('versionName') ?: '1.0'`; Kotlin:
|
|
`versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1` and
|
|
`versionName = project.findProperty("versionName") as String? ?: "1.0"` (your current values as defaults). Not
|
|
`versionCode (project.findProperty('versionCode') ?: 1) as Integer`: Groovy reads it as `versionCode(...) as
|
|
Integer` and Gradle fails with "Value is null".
|
|
- **Guard:** `play build` (also `--dry`) and `check` warn before Gradle with the file, the line and the line to use;
|
|
the post-build versionCode check is a hard stop; `tests/build-sign.test.ts`, `tests/web-assets.test.ts`.
|
|
|
|
## 33. Play "Do you want to restart your review?"
|
|
- **Symptom:** in Publishing overview, "Submit N changes for review" while a review is running opens "Do you want
|
|
to restart your review?" with "Restart review".
|
|
- **Cause:** Play reviews one set of changes at a time; restarting sends the new changes and starts the running
|
|
review over.
|
|
- **Do:** never press "Restart review" as an agent: tell the owner a review is running and what restarting means;
|
|
the owner decides (Cancel keeps the running review).
|
|
- **Guard:** the `play-production-release` console plan lists the dialog, "Restart review" is in neverPress;
|
|
`tests/console-play-plans.test.ts`.
|
|
|
|
## 34. Desktop release: never run the x86_64 slice on the build Mac
|
|
- **Symptom:** while building the storebound desktop app, macOS tells the owner "App Update Required: this version
|
|
of storebound includes a component that will not open in macOS 28".
|
|
- **Cause:** a check ran the universal app's Intel slice (`arch -x86_64 ... --self-test`) on Apple silicon, which
|
|
starts Rosetta; macOS warns about every app that does.
|
|
- **Do:** check the x86_64 slice only by structure (`lipo -archs`, `file`, `codesign`) and run self-tests natively
|
|
(arm64). The same holds for any universal app or game you build: never launch the Intel slice to test it.
|
|
- **Guard:** `tools/desktop-release.ts` (`archProblems` before signing: every Mach-O must contain arm64, no Intel-only
|
|
file, no Info.plist that forces Intel; `sliceProblems`: every Mach-O in the bundle must carry arm64 and x86_64,
|
|
nothing x86_64 is run); `tests/desktop-release.test.ts`.
|
|
|
|
## 35. Scan findings from a bare word, a module path or a config claim
|
|
- **Symptom:** `scan` reports "A reward for rating or reviewing the app" on `const rate = F.genRate(lv, bonus.gen(r));`;
|
|
account findings cite `import './buildings/register';`; 5.1.1(v) disappears once `accounts.inApp` is set although
|
|
the app has no delete-account code.
|
|
- **Cause:** the signals matched "rate" next to "bonus", and "/register'" anywhere on a line; the config's word was
|
|
taken as proof.
|
|
- **Do:** read every finding's evidence before acting on it. A finding whose evidence is not real code is a scan bug:
|
|
report it (and accept it with `scan.ignore` and the owner's reason meanwhile). For 5.1.1(v), show the deletion code
|
|
path or screen; a config line alone is a claim, not evidence.
|
|
- **Guard:** the incentive signal needs a rate-us / review phrase next to a reward, a reward "for rating", or a reward
|
|
next to a system review prompt call; sign-in / sign-up / deletion signals skip import and require lines and accept
|
|
only absolute auth paths or URLs; config-only accounts read "from config (accounts.enabled)"; deletion claimed only
|
|
by the config stays a medium finding ("claimed by config, not found in code"). `tests/game-report.test.ts`.
|
|
|
|
## 36. Privacy answers derived from the wrong source
|
|
- **Symptom:** `privacy-manifest --propose` prints an empty NSPrivacyCollectedDataTypes while `appPrivacy.types` lists
|
|
collected types; the App Privacy plan adds "Other Usage Data" you never listed; the Play plan answers "uses
|
|
advertising ID: Yes" because Data safety declares a Device ID.
|
|
- **Cause:** the proposal only kept the manifest's own entries; the App Privacy sheet mixed in types derived from
|
|
dataSafety; the advertising ID answer read PSL_DEVICE_ID (a random install id is not the advertising ID).
|
|
- **Do:** treat `appPrivacy.types` as the App Privacy answer: a type it does not list is "not collected". Answer the
|
|
advertising ID from the SDKs and the merged manifest (`com.google.android.gms.permission.AD_ID`), never from Data
|
|
safety alone; set `console.appContent.advertisingId` when the signals disagree.
|
|
- **Guard:** `--propose` builds the list from `appPrivacy.types` (linked, tracking, purposes; NSPrivacyTracking true
|
|
only with app-level domains, lesson 29) and `check` warns on an empty list; `console plan` scans the project
|
|
(ad / attribution SDKs, AD_ID, support and AR code) and answers No with no signal, asks on a conflict.
|
|
`tests/game-report.test.ts`.
|
|
|
|
## 37. `scan.ignore` that matches nothing; placeholder credentials
|
|
- **Symptom:** a `scan.ignore` entry does not accept the finding and nothing says why; commands treat
|
|
`YOUR_KEY_ID` or `/absolute/path/to/upload.p12` in `.storebound/credentials.json` as a credential.
|
|
- **Cause:** `match` is compared with the finding TITLE, not the evidence; the 0.7.1 credentials template held
|
|
example-looking values.
|
|
- **Do:** `scan.ignore: [{ rule, reason, match?, evidence?, store? }]`: `match` = text in the finding title (one check
|
|
of a rule with several), `evidence` = text in one evidence line as the report prints it (`src/main.ts:12` or a code
|
|
fragment); both together narrow further. Never add an entry without the owner's reason. Fill credentials with real
|
|
values or leave them empty.
|
|
- **Guard:** `scan` (notes) and `check` (warnings) name every entry that accepts no finding; the credentials template
|
|
is empty strings; "your-…", `<…>`, "xxx", "/absolute/path/to/…" and "example" values count as missing everywhere
|
|
(`check` warns, `doctor` lists them). `tests/game-report.test.ts`.
|
|
## 38. New App: Name and SKU empty after a value set
|
|
- **Symptom:** App Store Connect's New App dialog showed the Name and SKU, yet the dialog's state was empty ("").
|
|
- **Cause:** the dialog is React: a programmatic value set (`form_input`, `el.value = …`) changes the input, not the
|
|
component state; clicking the field and typing works (verified 2026-10-13).
|
|
- **Do:** type, don't set value: click the field, type the text key by key, read it back.
|
|
- **Guard:** the `apple-app-record` plan (uiFacts, the fill step); `console fill` types every text field (`typeText`);
|
|
`tests/browser/console-flow.browser.ts`.
|
|
|
|
## 39. IARC page 1: Next does nothing until the Terms box is ticked
|
|
- **Symptom:** on the content rating questionnaire's page 1 Next did nothing, and the agent waited mid-run for the
|
|
owner.
|
|
- **Cause:** the IARC Terms of Use checkbox gates Next, and it is an agreement only the owner ticks.
|
|
- **Do:** hand the owner the plan's `ownerBatch` in ONE message before the run: it has "Tick the IARC Terms of Use
|
|
box" with the exact place (Content ratings > Start questionnaire, page 1). Never tick it yourself; if it is still
|
|
unticked, say so once and poll it, do not press Next again and again.
|
|
- **Guard:** `ownerBatch` in `console plan play-content-rating` / `play-app-content` (printed first); `console fill`
|
|
waits for the tick ("agreement needed on …"); `tests/console-play-plans.test.ts`, `tests/browser/console-flow.browser.ts`.
|
|
|
|
## 40. Finding play.appId in a paginated app list
|
|
- **Symptom:** finding a new app's Play Console app id took several steps: 10 apps per page, the new app not on page 1.
|
|
- **Cause:** the Play API does not return the id; it is only in console URLs.
|
|
- **Do:** after Create app read `location.href` (`/app/<appId>/app-dashboard`) and run `storebound console verify
|
|
play-app-record --url "<url>"` (dry diff), then `--write` (an upsert of `play.appId`). Missed it: `storebound play
|
|
app-id` (search the package name in the list or walk the pages, pipe the captures in), then `--write`.
|
|
- **Guard:** `playAppIdInText`, `findPlayAppId`, `upsertNestedString`; `tests/play-app-id.test.ts`.
|
|
|
|
## 41. App content card Saves are the agent's
|
|
- **Symptom:** every Play App content card ended in an owner-only Save, so the owner came back once per card.
|
|
- **Cause:** a saved declaration was treated as public. It is not: Google gets it only with the next "Send changes
|
|
for review" in Publishing overview (verified 2026-10-13).
|
|
- **Do:** press each card's Save yourself (privacy policy, ads, app access, advertising ID, government, financial,
|
|
health, target audience, the content rating questionnaire and Summary, data safety). The owner keeps: the IARC
|
|
Terms box (before), typing the demo account only when it is not in `.storebound/credentials.json` (otherwise you
|
|
type it, never printed), and "Send changes for review" / Submit / Publish / "Restart review" (after).
|
|
`storebound console plan play-app-content --all` walks every card and ends with that one owner list.
|
|
- **Guard:** `declarationSaveAllowed`, `publishingControl`, `flowAllowed` (`cli/lib/console-forms.ts`), the plans'
|
|
`buttons.final` "Send changes for review", `console fill`'s declaration Save; `tests/console-play-plans.test.ts`.
|
|
|
|
## 42. A filter the spec declares, refused live (TestFlight distribute)
|
|
- **Symptom:** `apple testflight distribute --build 1 --group Team --yes` printed the build state, then failed:
|
|
`GET /v1/betaGroups?filter[app]=…&filter[builds]=… -> 400`. Before the groups existed it said `unknown group
|
|
"Team" ()`.
|
|
- **Cause:** Apple's OpenAPI spec declares `filter[builds]` on `GET /v1/betaGroups`, but App Store Connect answers 400;
|
|
the spec has no GET of a build's groups. An internal group with all builds already has every build and refuses
|
|
one added by hand.
|
|
- **Do:** read a build's groups per group (`GET /v1/betaGroups/{id}/relationships/builds`); skip internal all-builds
|
|
groups ("already available"). A configured group missing on App Store Connect: `storebound apple testflight groups
|
|
sync` first. Never use a query parameter that is not declared for the operation (`storebound api list`), and add
|
|
one refused live to `APPLE_LIVE_REJECTED` (`cli/api/client.ts`).
|
|
- **Guard:** `tests/asc-query-guard.test.ts` (every App Store Connect GET with a query in `cli/` against the spec and
|
|
`APPLE_LIVE_REJECTED`), the fake App Store Connect's 400 for such parameters, `unknownGroupMessage`;
|
|
`tests/idempotency.test.ts` (distribute), `tests/testflight.test.ts`.
|
|
|
|
## 43. Keyword field: 100 characters, not bytes
|
|
- **Symptom:** `check` / `listing check` / `check --aso` called live keyword fields too long ("105 bytes > 100") that App
|
|
Store Connect accepted.
|
|
- **Cause:** App Store Connect counts characters. Live READY_FOR_SALE versions (read 2026-10-13): Thai 95 characters /
|
|
265 bytes, Russian 91 / 169, Turkish 100 / 105, Polish 99 / 105. Entry 4 (bytes) was wrong.
|
|
- **Do:** count characters (`[...text].length`); fill up to 100 characters in any script.
|
|
- **Guard:** every keyword check counts characters (`cli/lib/copy.ts`, `aso.ts`, `listing-translate.ts`,
|
|
`aso-intel.ts`); tests with Thai, Turkish and Polish fields over 100 bytes.
|
|
|
|
## 44. "No 6.7-inch iPhone screenshots" on a live app with the 6.5-inch set
|
|
- **Symptom:** `validate` blocked every locale of a live app: "en-US: no APP_IPHONE_67 screenshots".
|
|
- **Cause:** App Store Connect requires the 6.9" set (APP_IPHONE_67) or, when it is missing, the 6.5" set
|
|
(APP_IPHONE_65), and scales the smaller sizes from it.
|
|
- **Do:** treat the 6.5" set as meeting the requirement; adding a 6.9" set is a recommendation.
|
|
- **Guard:** `ACCEPTED_INSTEAD` in `cli/lib/validate-rules.ts` (a warning `screenshots-size`, no blocker).
|
|
|
|
## 45. Play graphics missing in extra languages
|
|
- **Symptom:** `validate --play` listed an icon and a feature graphic blocker for every non-default language.
|
|
- **Cause:** Play shows the default language's graphics in a language that has none of its own.
|
|
- **Do:** only the default language's graphics are required; a language with none uses them (a note).
|
|
- **Guard:** `playRules` (`images-fallback` note per type).
|
|
|
|
## 46. Play Developer Reporting API off
|
|
- **Symptom:** `apps list --store play` and Play vitals failed with a 403 SERVICE_DISABLED.
|
|
- **Cause:** the Reporting API is not enabled in the service account's Google Cloud project.
|
|
- **Do:** open the link the finding prints (`…/playdeveloperreporting.googleapis.com?project=<id>`), press Enable,
|
|
wait a few minutes. Meanwhile `apps list` shows the Play apps of this machine's folders and store-only entries.
|
|
- **Guard:** `reportingEnableUrl`, `knownPlayPackages` (`cli/apps.ts`), `healthFindings`.
|
|
|
|
## 47. Onboarding a live app left its products out ("no USD prices")
|
|
- **Symptom:** after `init` + `listing pull` the config had no products although the app sells some.
|
|
- **Cause:** only texts were pulled, never prices; Apple's price schedule can have a base territory other than the USA.
|
|
- **Do:** `storebound onboard` (then `--write`): products and subscriptions from both stores with usd = the US price,
|
|
Apple's base territory as an override, one-store items marked with `platforms`. Then `storebound catalog diff`
|
|
(clean). Store prices are never changed; an item with no USD price anywhere is named with its base price.
|
|
- **Guard:** `importCatalog` (`cli/lib/onboard.ts`), `tests/onboard.test.ts`.
|
|
|
|
## 48. Contact e-mail, website and App Review contact asked of the owner
|
|
- **Symptom:** onboarding kept `support@example.com` and asked for the website and review contact.
|
|
- **Cause:** nothing read Play's app details or App Store Connect's review detail.
|
|
- **Do:** `storebound onboard` reads them; template values are replaced, real ones kept; the review contact goes to
|
|
`.storebound/credentials.json` (masked in output, the phone never printed).
|
|
- **Guard:** `contactProposal`, `reviewContactOf`, `maskContact`.
|
|
|
|
## 49. Sign-in for review defaulted to yes
|
|
- **Symptom:** every onboarded app got "sign-in required: add a demo account".
|
|
- **Do:** decide from Apple's review detail (`demoAccountRequired`; a demo account there is imported, never printed)
|
|
and from the code's auth signals; ask only when neither proves it.
|
|
- **Guard:** `signInDecision`.
|
|
|
|
## 50. Account deletion URL asked although the site has the page
|
|
- **Do:** `storebound onboard` tries /delete-account, /account/delete, /delete, /account-deletion and the privacy
|
|
policy's deletion links; not found is reported as the real gap it is (Play requires the page). Never invent one.
|
|
- **Guard:** `findDeletionUrl`.
|
|
|
|
## 51. No default credential profile except one named "default"
|
|
- **Do:** `storebound accounts default <profile>` (`--clear`). Order: env > the project's named profile > the
|
|
project's own keys > this default > the only profile.
|
|
- **Guard:** `defaultProfile`, `tests/machine.test.ts`.
|
|
|
|
## 52. A live app with no local project is missing from the desktop
|
|
- **Do:** `storebound apps add --store apple --app-id <id> --account <profile>`; the desktop shows it read-only
|
|
("Store only": status, reviews, analytics). `apps remove apple:<id>` takes it out.
|
|
- **Guard:** `storeApps` in `~/.storebound/projects.json` (`cli/lib/registry.ts`, `registry.rs`), the boot check.
|
|
|
|
## 53. `apple upload` passed a stale IPA as "already VALID"
|
|
- **Do:** upload the IPA `apple build` just printed (`build/ios/export/<version>-<build>/App.ipa`), or `apple upload
|
|
--build N`. An IPA whose build number is already on App Store Connect now fails with exit 1: the last build failed;
|
|
run `apple build` again. `--allow-existing` only for re-checking a build this command uploaded before (flows use it
|
|
with `--build ${BUILD}`).
|
|
- **Guard:** per-build export folders, `existingBuild` / `exportedIpas` (`cli/apple/upload.ts`), `exportIpa`
|
|
(`cli/apple/build.ts`), `tests/apple-build-export.test.ts`.
|
|
|
|
## 54. "exportArchive Copy failed": a Homebrew rsync on PATH
|
|
- **Do:** read the IDEDistribution log lines `apple build` prints. "rsync error: syntax or usage error" means a
|
|
non-Apple rsync (Homebrew 3.x) is first on PATH; storebound runs xcodebuild with /usr/bin first, `storebound doctor`
|
|
names the culprit. Never install or `brew link` a global tool as a side effect on a shared machine.
|
|
- **Guard:** `xcodePath`, `foreignRsync`, `distributionErrors` (`cli/lib/xcode.ts`), doctor, `publishSteps`
|
|
(`tools/desktop-release.ts`, rsync by absolute path).
|
|
|
|
## 55. The feature graphic cut the phone off at the bottom
|
|
- **Do:** nothing by default: `shots.feature.fit` 'contain' sizes the (rotated) phone so its outline stays inside the
|
|
1024 x 500 canvas with a 24 px margin. `fit: 'bleed'` keeps the larger, cropped phone on purpose; `scale` (0.5-1.5)
|
|
resizes it. `shots check` fails a feature graphic whose device leaves the canvas (measured from the page's layout
|
|
at render time, `devices` in report.json), and warns when a screenshot's front device runs off a side or the top.
|
|
- **Guard:** `featureLayout`, `rotatedBox` (`cli/shots/lib.ts`), `deviceIssues` (`cli/shots/report.ts`),
|
|
`tests/browser/feature-fit.browser.ts` (geometry, both templates rendered at 1024 x 500, a bleeding fixture).
|
|
|
|
## 56. "Live on Play" while Google was still reviewing, and 1.0 / 1.0.0 shown as two versions
|
|
- **Do:** never call a Google Play production release live from the Developer API alone: a release reads `completed`
|
|
(userFraction 1) from the moment it is sent, also while Google reviews the first release. `play status` checks the
|
|
public store page (no auth, 4 s timeout, cached 5 minutes) and decides, never "maybe": the page's "Version" (About
|
|
this app, read from its AF_initDataCallback data) equals the release's versionName = "Live"; 404 = "In review" (with
|
|
play.managedPublishing: "In review / ready to publish (managed publishing)"); an older version on the page = "In
|
|
review (live: <older>)"; "Varies with device" = live. Only a network failure gives "Couldn't check the store page",
|
|
and then the last known page answer is used with its time. Match versions across the stores
|
|
by `versionKey` (trailing .0 parts dropped: 1 == 1.0 == 1.0.0); show each store's own string.
|
|
- **Guard:** `playPublicPage` (`cli/lib/play-public.ts`), `playReleases` / `versionKey` / `sameVersion`
|
|
(`cli/lib/portfolio.ts`), `versionHistory` (`cli/lib/train.ts`), `tests/play-public-version-match.test.ts`, the
|
|
desktop boot check's `release-state` cases (page 404 and 200).
|
|
|
|
## 57. A raw Google JSON error in the window, and "Exit code 1" for a scan that found problems
|
|
- **Do:** never show an API error body to the owner. Parse Google errors into a typed finding (`parseGoogleError`):
|
|
SERVICE_DISABLED = the API is off in the service account's Cloud project (say which API and project, link the
|
|
console page to turn it on), PERMISSION_DENIED = the service account has no access to that app (Play Console > Users
|
|
and permissions), 401 / invalid_grant = the key is revoked or expired, 429 / RESOURCE_EXHAUSTED = quota. One project
|
|
that blocks several apps is one notice, not one error per app. Keep the raw text in a collapsed Details and the log.
|
|
Exit 1 of `scan` / `check` / `validate` / `health` with the JSON result printed means "found problems": show the
|
|
findings; a real failure shows the command's own error line, never a bare exit code.
|
|
- **Guard:** `cli/lib/google-errors.ts`, `outcomeOf` / `errorLine` (`cli/lib/desktop-protocol.ts`),
|
|
`tests/google-errors.test.ts`, the desktop boot check's `errors` cases (api-disabled, scan-findings, scan-failed).
|
|
|
|
## 58. Parallel Play reads deleted each other's edits, and a failed read said "not on Google Play yet"
|
|
- **Do:** Google Play keeps one open edit per app: a second `edits.insert` for the same package deletes the first, so a
|
|
read in it answers 400 "This Edit has been deleted". Every edit (read or write) of a package takes the machine-wide
|
|
lock `~/.storebound/locks/play-edit-<package>.lock` (short wait with a heartbeat log, 90 s timeout for reads, 10 min
|
|
for writes; stale when its process is gone or untouched for a minute); other packages stay parallel. A read whose
|
|
edit was deleted, committed elsewhere or expired is redone once with a new edit; a write is never redone that way
|
|
(it keeps its own invalidation retry). A store that could not be read is "couldn't read Google Play" (unknown),
|
|
never "not on Google Play yet"; its last good releases stay with "couldn't refresh". When the stores' current
|
|
versions differ (normal), each lane shows its own ("4.1.2 · Live (4.1.9 not submitted)") under a neutral "Current"
|
|
header; "Not on this store" only when the store has no record of the app.
|
|
- **Guard:** `withMachineLock` (`cli/lib/lock.ts`), `read()` / `edit()` (`cli/play/client.ts`),
|
|
`laneGap` / `storesDiffer` / `withLastGood` (`cli/lib/train.ts`), `tests/play-edit-lock.test.ts` (a fake Play where a
|
|
second insert invalidates the first), the desktop boot check's auto-read case (two Play reads of one app at once,
|
|
Orbit Hop on different versions per store).
|
|
|
|
## 59. A live app showed missing art and render blockers although both stores had its screenshots
|
|
- **Do:** for an app that is already live, pull the art instead of asking for render sources: `storebound art pull
|
|
--dry`, then `--write` (`onboard` runs it too). The App Store CDN re-encodes: a downloaded screenshot never has the
|
|
MD5 App Store Connect keeps for the uploaded file (`sourceFileChecksum`), so without a record `apple screenshots`
|
|
would re-upload every set. `store/renders/pulled.json` keeps each pulled file's sha256 with the store's own checksum,
|
|
and the uploads compare an unchanged pulled file with that checksum (nothing sent). Play serves the original bytes
|
|
at `=s0` (its sha256 equals the one `images.list` gives; checked on download). Never overwrite a local file that
|
|
differs from the store without `--force`: it may be the owner's new render.
|
|
- **Guard:** `cli/art.ts`, `cli/lib/art-pull.ts` (`decide`, `pulledChecksum`, `storeEmptySets`), the pulled-file rules
|
|
in `scanRenders` (`cli/shots/report.ts`) and `check`, `tests/art-pull.test.ts` (recorded ASC screenshot sets with
|
|
`templateUrl` and Play `images.list`, the layout, the upsert and `--force`).
|
|
|
|
## 60. `init` + `onboard` on live apps left the template compliance answers in the config
|
|
- **Do:** treat every answer `storebound init` wrote as unanswered. The game template declares approximate location,
|
|
app interactions, diagnostics and device id as shared for advertising and `PSL_ACM_NONE`; pushing that from a live
|
|
app is a false declaration. Templates list the areas in `unanswered`; configs from before it are recognised by the
|
|
template values. `check`, `validate` and `scan` block; `play data-safety` (also `--dry`), `console plan` / `fill`,
|
|
`apple review-details` and `apple appinfo` refuse. For a live app run `storebound onboard --declarations --write`:
|
|
name and copyright from the stores (a copyright with a private term from `STOREBOUND_PRIVATE_TERMS` is left out),
|
|
the template answers out, the rows the code proves in (scan proposal, sign-up methods from auth code), the rest as
|
|
one owner question list. Remove an area from `unanswered` only after the owner answered it.
|
|
- **Guard:** `cli/lib/template-answers.ts`, `cli/lib/onboard-declarations.ts`, the `template-answers` scan rule,
|
|
`tests/template-answers.test.ts` (init blocks, data-safety --dry refuses, onboard on auth code without an ad SDK).
|
|
|
|
## 61. `prices ppp --json` printed log lines on stdout before the JSON
|
|
- **Do:** with `--json`, read stdout as one JSON document and stderr as the log. Every command run with `--json` goes
|
|
through `withJsonOutput` (cli/lib/log.ts, wrapped in cli/index.ts): log / done / tables go to stderr, `printJson` is
|
|
the only writer to stdout, prompts go to stderr. Never print a result with `console.log(JSON.stringify(...))`.
|
|
- **Guard:** `tests/json-stdout.test.ts` runs every command whose module reads `--json` against the fake stores and
|
|
`JSON.parse`s its stdout, plus one real CLI run (`prices ppp --offline --dry --json`).
|
|
|
|
## 62. PPP prices cut Germany to EUR 3.59 for a USD 4.99 product (store price EUR 5.99)
|
|
- **Do:** trust `prices ppp` defaults: only lower prices, only economies with a price level at most 50 % of the US,
|
|
World Bank high-income economies and mainland China keep the store's own US-equivalent price, never above it; a
|
|
top-10 revenue market dropping more than 20 % is warned about. The old math set a net price (base x PPP level x
|
|
exchange rate) against VAT-inclusive store prices and cut every region. Now par = the store's own conversion (Play
|
|
convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT), rounded up), share = clamp(level, 0.3, 1).
|
|
Regions that keep their price are left out of the sheet (`kept` in `--json`), so `prices import` leaves the store's
|
|
price there. `--include-high-income`, `--max-level`, `--min-ratio` / `--max-ratio`, `--no-only-decreases` widen it,
|
|
only after the owner asks.
|
|
- **Guard:** `shareOf`, `offlinePar`, `planRows`, `topMarketDrops` (cli/lib/ppp.ts), `tests/ppp.test.ts` (Germany
|
|
keeps EUR 5.99, India and Turkey drop, CN skipped on Play).
|
|
|
|
## 63. Creating new IAPs also changed live prices of existing ones (a terminal "y" covered them)
|
|
- **Do:** never rely on a confirmation for price changes. Creating products never changes an existing product's price
|
|
unless `--allow-price-changes` is passed, TTY or not; the terminal question of `play products`, `prices import`,
|
|
`play subscriptions` and `apple subscriptions` covers creates and other updates only. With the flag, price changes
|
|
get their own question listing each old -> new (in a terminal even with `--yes`). Dry runs list them in a separate
|
|
"price changes on existing products (held)" section. `apple iap` never changes an existing schedule.
|
|
- **Guard:** `allowPriceChanges` / `heldSection` (cli/lib/price-gate.ts), `tests/price-gate.test.ts` (TTY and non-TTY:
|
|
a "y" creates the new product and holds the live price; the flag's question names each change).
|
|
|
|
## 64. `signing ensure` made a manual App Store profile for an automatic-signing app
|
|
- **Do:** with `apple.signing.style: automatic` (the default), `apple signing ensure` only keeps the App ID and its
|
|
capabilities in step. It never creates certificates or profiles: Xcode owns them (`xcodebuild
|
|
-allowProvisioningUpdates` with the API key) and renews its managed profile when the capabilities change. The dry
|
|
run says so. Profiles storebound made that nothing uses ("storebound <bundle id> App Store <date>") are listed by
|
|
name with the exact `storebound api apple profiles_deleteInstance --param id=<id> --yes` command; deleting them is
|
|
the owner's call, never run by storebound.
|
|
- **Guard:** `ensureScope`, `unusedStoreboundProfiles` (cli/lib/signing.ts), `tests/signing-capabilities.test.ts`.
|
|
|
|
## 65. A capability add "succeeded" but the App ID kept only IN_APP_PURCHASE
|
|
- **Do:** never report a capability as added from the POST answer. `signing ensure` reads the App ID back after any
|
|
capability write (a few tries with growing waits, about 45 s at most) and fails with the API's answers when one is
|
|
missing. The capability names were right (CapabilityType: PUSH_NOTIFICATIONS, GAME_CENTER); what an App ID shows
|
|
and what a build may use are different things: a provisioning profile keeps the entitlements of the moment it was
|
|
made, and an app whose project has no `.entitlements` (or one not set in CODE_SIGN_ENTITLEMENTS) ships without
|
|
the capability whatever the App ID says. `apple status`, `apple signing status` and `validate` compare the App ID,
|
|
`apple.signing.capabilities` and the project's entitlements and name what is missing where.
|
|
- **Guard:** `verifyCapabilities`, `compareCapabilities`, `ENTITLEMENT_CAPABILITIES` (cli/lib/signing.ts),
|
|
`checkCapabilitiesStuck` (cli/apple/signing.ts), `tests/signing-capabilities.test.ts` (live-shape
|
|
bundleIdCapabilities answers, lag, a write that never sticks).
|
|
|
|
## 66. `storebound api apple ... --body f.json --yes` only dry-ran from an agent
|
|
- **Do:** expected: writes through `storebound api` are sent only from an interactive terminal, by design (since
|
|
pitfall 78 it is a loud refusal with exit 1, not a dry run). Use the
|
|
dedicated command the message names (bundle id capabilities: `apple signing ensure`; IAPs: `apple iap`; listing:
|
|
`apple listing`, ...), which runs anywhere with its own dry run and read-back.
|
|
- **Guard:** `dedicatedCommand` (cli/api/gate.ts), `tests/signing-capabilities.test.ts`.
|
|
|
|
## 67. "builds ship without push / Game Center" while the uploaded build had both
|
|
- **Do:** a checkout without `.entitlements` says nothing about the build: native changes may live on a release
|
|
branch on purpose (OTA fingerprint on main). The comparison warns only about this checkout ("built from another
|
|
branch?"), names the current branch and the local branches / worktrees that have an `.entitlements` file, and is
|
|
never a blocker. The real answer is the built IPA: with one in build/ios/export (highest build number, newest file)
|
|
storebound reads its entitlements (`codesign -d --entitlements :-` on macOS, else the Mach-O signature blob) and
|
|
compares those, labelled "uploaded build N". The App Store Connect API does not expose a build's entitlements, so
|
|
without a local IPA it says that instead of guessing.
|
|
- **Guard:** `checkoutInfo`, `builtIpaEntitlements` (cli/apple/signing.ts), `machoEntitlements`,
|
|
`compareCapabilities` (cli/lib/signing.ts), `tests/signing-capabilities.test.ts` (entitlements only on another
|
|
branch; IPAs with entitlements).
|
|
|
|
## 68. The owner's Chrome would not open: an orphaned headless storebound Chrome
|
|
- **Symptom:** clicking Chrome in the Dock does nothing (no window); `ps` shows a Chrome with
|
|
`--user-data-dir=…storebound-profile-…` (or `…/.storebound/console-profile/…`, `storebound-chrome-…`),
|
|
`--remote-debugging-port=0 --headless=new` and parent PID 1.
|
|
- **Cause:** a Chrome storebound started for a console flow (here a test run) outlived its run. Started with a
|
|
DevTools port, Chrome does not notice its parent dying; macOS LaunchServices then counts the windowless instance
|
|
as the running "Google Chrome", so the Dock only activates it.
|
|
- **Do:** run any `storebound console …` command (or `bun test tests/chrome-guard.test.ts`): every start ends such
|
|
headless orphans and names orphaned windows. By hand: `kill <pid>` of the process without `--type=`. Never kill a
|
|
Chrome whose parent is a running storebound (a run that waits for its window), or the owner's own Chrome.
|
|
- **Guard:** `guardChrome` / `startWatchdog` / `reapStaleChromes` (cli/console/chrome-guard.ts): exit, Ctrl+C,
|
|
SIGTERM / SIGHUP and the parent's death (SIGKILL included) end the Chrome; `console fill` closes it on errors too;
|
|
headless runs on macOS prefer Chromium / Chrome for Testing (its own bundle id) over Google Chrome.app
|
|
(`persistentExecutable`); `tests/chrome-guard.test.ts`.
|
|
|
|
## 69. `bun add -d storebound` broke a Next app's build
|
|
- **Symptom:** `next build` (or vue-tsc, ts-loader, `tsc --noEmit`) fails on the scaffolded `storebound.config.ts`:
|
|
`Cannot find module 'storebound' or its corresponding type declarations`.
|
|
- **Cause:** storebound <= 0.8.5 exported only its TypeScript source (`cli/lib/config.ts`) and shipped no `.d.ts`.
|
|
Bun imports .ts, so everything storebound tested passed; TypeScript never takes a package's .ts source as its types.
|
|
- **Do:** update to 0.8.6+ (declarations for ES module and CommonJS projects). Remove any tsconfig `exclude` of
|
|
storebound.config.ts that was added as a workaround.
|
|
- **Guard:** `buildEntry` / `pruneDeclarations` (tools/build-node.ts, run by prepack) and `tests/package-types.test.ts`:
|
|
the template config type-checks against the packed entry in ES module and CommonJS projects under bundler,
|
|
node16, nodenext and node10 with no @types/node and skipLibCheck off; `import()` and `require()` of the package
|
|
load under Node.
|
|
|
|
## 70. The first in-app purchase was not in the version's review submission
|
|
- **Symptom:** the app version has been WAITING_FOR_REVIEW for days while its first in-app purchase (here a starter
|
|
pack) still says READY_TO_SUBMIT; the review submission's items hold only the appStoreVersion.
|
|
- **Cause:** Apple reviews the first in-app purchase of each type (and the first subscription of a group) only together
|
|
with an app version. A submission made without it (from the App Store Connect version page, or before the product
|
|
was ready) leaves it out, and the version is approved without it: the product is not sellable. storebound <= 0.8.6
|
|
printed the IAP and the submission states separately and called READY_TO_SUBMIT "complete".
|
|
- **Do:** before submitting, `storebound validate` (blocker `iap-first`) and `storebound release plan`; submit with
|
|
`storebound apple submit --build <n>`, which adds every ready product to the same submission and refuses to leave a
|
|
never-approved first product out. Already in review: withdraw the submission (`storebound apple status
|
|
--cancel-submission <id>`) and resubmit with the product, or add it with the next version.
|
|
- **Guard:** `firstProductGaps` (cli/lib/review-submit.ts) read by `cli/apple/first-products.ts`: `apple status` (WARN
|
|
and `firstProductGaps` in --json), `release status` and the desktop checks (`appleFirstProductWarnings`), `validate`
|
|
/ `release plan` (blocker), the `apple submit` plan (blocker); `iap-review` says "ready, not submitted yet";
|
|
`tests/first-products.test.ts`.
|
|
|
|
## 71. The owner was sent to a console page with plain directions
|
|
- **Symptom:** a message said "App Store Connect > App Information > Age Rating" or "go to Play Console" and the owner
|
|
had to find the app, the page and the card by hand.
|
|
- **Cause:** storebound's findings, fix hints and errors named console pages as text paths (40+ in cli/), without the
|
|
app's URL.
|
|
- **Do:** when you send the owner to a console page, give the absolute deep link (storebound prints it; `storebound
|
|
console plan` has start URLs), never just "go to App Store Connect". Set apple.appId, play.developerId and
|
|
play.appId so links open the app's own page instead of the app list.
|
|
- **Guard:** `cli/lib/console-links.ts` (every page key, absolute URL with and without ids; `url` in validate / scan /
|
|
declarations / release plan JSON, an Open button in the desktop) and `tests/console-links.test.ts`, which fails on a
|
|
console path in cli/ or templates/ without an https URL.
|
|
|
|
## 72. Play refused to send an edit for review: "set changesNotSentForReview to true"
|
|
- **Symptom:** `storebound play release <aab> ... --dry` (or a real run) uploads and updates the tracks, then
|
|
edits.validate or edits.commit answers 400 "Changes cannot be sent for review automatically. Please set the query
|
|
parameter changesNotSentForReview to true."
|
|
- **Cause:** after a rejection (here production 1.0.0 (3), Broken Functionality), with other changes pending in the
|
|
Console or with managed publishing, Play no longer sends an API commit for review by itself. The edit is fine; only
|
|
the automatic send is refused. storebound <= 0.8.8 stopped there.
|
|
- **Do:** nothing by hand: storebound commits the same edit again with changesNotSentForReview=true (only edits.commit binds it; validate answers 400 "Unknown name ... Cannot bind query parameter")
|
|
(`--not-sent-for-review` sets it from the start; MCP `not_sent_for_review`). A dry run says the real run will commit
|
|
that way. Then the owner presses **Send changes for review** in Publishing overview (storebound prints the deep link):
|
|
that button is the owner's, never press or automate it. The API has no read of "waiting for Send changes for
|
|
review", so storebound records its commit in `.storebound/play-review-pending.json` and `storebound play status`
|
|
shows "changes committed <time>, waiting for Send changes for review: <link>" (`--review-sent` forgets it; a later
|
|
commit Play sends by itself clears it).
|
|
- **Guard:** `isReviewRefused` and the edit flow in `cli/play/client.ts` / `cli/play/review-pending.ts` (only that 400;
|
|
no other request is repeated, the same edit id), `play status` (text and `reviewPending` in --json);
|
|
`tests/play-not-sent-for-review.test.ts` (fake Play refuses validate / commit once).
|
|
|
|
## 73. Guest-play apps rejected as a login wall
|
|
- **Symptom:** Play rejects a game that has no sign-in under "Play Console Requirements: provide valid login
|
|
credentials". The config said `reviewAccess: { signInRequired: false }` and Sign in details answered "All
|
|
functionality in my app is available without any access restrictions".
|
|
- **Cause:** the reviewer's screenshot was the first-run screen: an empty required player-name field, the Continue
|
|
button disabled until a name is typed, and an "I Have a Code" link that opens a transfer / recovery code dialog.
|
|
To a reviewer an empty field plus a disabled button plus a code entry looks like a sign-in, and "no restrictions"
|
|
gave them nothing to follow.
|
|
- **Do (console):** write English guest steps into `reviewAccess.instructions` (keep `signInRequired: false`; at most
|
|
500 characters), e.g. "No account needed. Open the app, the player name is pre-filled: tap Continue. 'I Have a Code'
|
|
is optional (moves progress from another device); skip it." `storebound console plan play-sign-in-details` then
|
|
answers "All or some functionality in my app is restricted" with one entry named "Guest play (no account needed)",
|
|
Username and Password empty and the steps in "Any other information required to access your app". The agent presses
|
|
Add instructions, fills the dialog and stops: the dialog's required box "Sign in details in this declaration provide
|
|
full access to all the features and content within this app, including premium or paid content" is a declaration
|
|
the owner ticks (agents never tick declarations). Then the agent presses Add and Save; Send changes for review stays
|
|
the owner's. App Review gets the same steps in its notes.
|
|
- **Do (in the app):** pre-fill a suggested name so Continue is enabled at once, and make the code entry clearly
|
|
optional ("Moving from another device? I have a code"), never the first thing a new player must do.
|
|
- **Guard:** `storebound check` warns "Play may reject as a login wall; add guest steps" for a Play app with a
|
|
first-run gate in the code (name entry, code / transfer entry, account transfer UI: `SIGNALS.firstRunGate`) or
|
|
in-app purchases and no `reviewAccess.instructions`, and when the steps are longer than 500 characters
|
|
(`cli/lib/profile.ts`). The plan's guest path is in `cli/lib/console-play-plans.ts` (`signInDetails`);
|
|
`tests/guest-login-wall.test.ts`.
|
|
|
|
## 74. Remote config and remote paywalls read as hidden features (2.3.1)
|
|
- **Symptom:** App Review rejects or removes an app under Guideline 2.3.1 (hidden, dormant or undocumented features),
|
|
or Play flags Deceptive behavior, after a feature or a hard paywall was switched on remotely, or the reviewer saw a
|
|
different paywall than the one users get.
|
|
- **Cause:** remote config / feature flag SDKs (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely,
|
|
PostHog feature flags, Unleash, ConfigCat, Amplitude Experiment, Split) and remote paywalls (RevenueCat Paywalls,
|
|
Superwall, Adapty, Qonversion, Purchasely, Apphud) change what the app does after review, and the review notes said
|
|
nothing. Paywall tests and designs are not features and are fine once App Review knows; a business-model change
|
|
(soft -> hard paywall, freemium -> premium, features moved behind a subscription) or functionality enabled after
|
|
review is a feature change.
|
|
- **Do:** this is the developer's call; storebound only warns. Suggest a line in `apple.info.reviewNotes` (and
|
|
`reviewAccess.instructions`, which also go to Play App access) that names the SDK and what changes remotely
|
|
(paywall designs, prices shown, which flags), document every feature that can be switched on or off, and send a
|
|
business-model change as a new review with a description that discloses what needs a paid subscription. Never
|
|
write the note into the developer's config or code yourself.
|
|
- **Guard:** `storebound scan` (and `validate`, which runs it; `check` prints it as a warning) reports `remote-features`
|
|
for Apple (2.3.1) and Play (Deceptive behavior), medium, with the deep links, unless the review notes mention remote
|
|
config, a feature flag, A/B, a paywall test, an experiment or "remotely" (`remoteFeatureFindings` in
|
|
`cli/lib/scan/rules.ts`, the SDK table `REMOTE_SDKS` in `cli/lib/scan/sdks.ts`). A code obfuscator (DexGuard,
|
|
iXGuard, a JavaScript obfuscator) is a low `obfuscation` note; ProGuard / R8 minify alone is never reported.
|
|
`tests/remote-features.test.ts`.
|
|
|
|
## 75. iPhone Duo screenshots: in the console, not in the API
|
|
|
|
- **Symptom:** App Store Connect shows an "iPhone Duo" tab next to "iPhone with Dynamic Island (medium display)" and
|
|
"iPad 13" display", but an upload for it through the API has no display type to use (a guessed one answers 400).
|
|
- **Cause:** the API's `ScreenshotDisplayType` (4.5.1) has no Duo value yet; only the asset library enum lists
|
|
`IPHONE_DUO`. Duo is optional now, reportedly required from April 2027 for iOS 27.1 SDK builds.
|
|
- **Do:** render and check the sets (`apple.iphoneDuo: true`), add them in the console's iPhone Duo tab (the skip
|
|
message has the deep link), never guess an enum name. For Play point foldables to the tablet sets; there is no Duo slot.
|
|
- **Guard:** `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts` (null = not in the API; regenerate with
|
|
`bun tools/gen-api.ts` and fill in the exact names), `skippedDuoSets` in `cli/apple/screenshots.ts`, the
|
|
`screenshots-duo` note in check / validate. `tests/ppo.test.ts`.
|
|
|
|
## 76. Header and Search Results assets are not uploaded like screenshots
|
|
- **Symptom:** a header or search results image was "uploaded" but the version page shows no header, a run waited on
|
|
an asset nobody had sent to review, or a re-run uploaded the same file again.
|
|
- **Cause:** these slots take assets from the app's Asset Library (`GET /v1/apps/{id}/assetLibrary`), not a set of
|
|
their own: a slot is an `appAssetLibraryPlacements` row (`PRODUCT_PAGE_HEADER_ASSET` or
|
|
`APP_STORE_SEARCH_RESULTS_ASSET`, the version localization). An asset is reviewed only once it is in a review
|
|
submission (its own item, `reviewSubmissionItems` with `appAssetLibraryImage` / `appAssetLibraryVideo`; the console's
|
|
"Add for Review"); after processing it is "Prepare for Submission". The version page's slots offer only approved
|
|
assets ("Browse approved assets", seen in a real release, 20 Oct 2026), so placing waits for Apple's approval.
|
|
Library assets carry no `sourceFileChecksum`, a rejected asset stays rejected (uploading the same bytes again only
|
|
adds a copy), and the search slot is the API's `APP_STORE_SEARCH_RESULTS_ASSET` (`SEARCH_RESULTS_ADS_ASSET` is the
|
|
Apple Ads one). Seen live (19 Oct 2026): placements of a live version read back with state `ACTIVE`, which the 4.5.1
|
|
spec's `AppAssetLibraryPlacementState` does not list; never validate placement states against the spec enum.
|
|
- **Do:** `storebound apple creative-assets`: MD5 in `referenceName` ("storebound <md5>"), reuse (or unarchive) a library
|
|
asset with the same MD5, never re-upload a rejected file or one that failed processing twice. `--add-for-review`
|
|
puts the unreviewed assets into the draft submission (with the first version, or alone once the app has an approved
|
|
version; Submit for Review stays the owner's). An asset that is not APPROVED is reported per locale and placement as
|
|
"uploaded <date>, waiting for Apple's approval; run `storebound apple creative-assets` again later"; the run is an
|
|
upsert, so a later run places it as soon as it is approved (an approved old asset stays shown meanwhile). `--verify`
|
|
and `validate` / the report show the same waiting state. With no editable version the live version is used.
|
|
Whether the API accepts a new placement on a live version is not documented: storebound places the new asset first
|
|
and removes the old one only after Apple accepted it, and on a 4xx it says so, leaves the live page unchanged, gives
|
|
the live version page's deep link (select it there, or create a version and re-run) and does not try the remaining
|
|
live slots.
|
|
- **Guard:** `cli/apple/creative-assets.ts` (`classify`, `pickAsset`, `waitingText`, `creativeWaiting`,
|
|
`processingTiming`), `tests/creative-assets.test.ts` (fake App Store Connect: upload, wait for approval, place on a
|
|
re-run, still processing, rejected / archived files, add-for-review without submitting, verify read-only with the
|
|
waiting state, a refused live placement, the validate finding).
|
|
|
|
## 77. What's New shared with Play rejected on the App Store (2.3.10)
|
|
- **Symptom:** a Mac App Store version was rejected under Guideline 2.3.10 (Accurate Metadata): its What's New came
|
|
from release notes shared with Google Play and had "Google Play Games" and "Android" lines. storebound wrote it
|
|
without a word (the old check was a warning in the editor only).
|
|
- **Cause:** one notes source for both stores, no per-store split, and no write that refused other platforms. Google
|
|
Play's Metadata policy is the mirror case (iOS, iPhone, App Store, Game Center, TestFlight in Play text).
|
|
- **Do:** tag store-specific lines `[play]` / `[apple]` in shared notes (whats-new skill); `storebound check` must
|
|
show no `other platform` line. A functional mention (a "Continue with Google" / "Sign in with Apple" button) is the
|
|
owner's call: `listing.allowPlatformMentions: [{ store, text, reason }]`. Never add an entry yourself.
|
|
- **Guard:** `cli/lib/cross-store.ts` (term lists, per locale / field / line, the allowlist, `notesForStore`).
|
|
Refused before anything is sent, dry runs included: `apple listing`, `play listing`, `play release` /
|
|
`play upload` (release notes), `listing set`, `whats-new check` / `apply`, the desktop editor and `storebound edit`
|
|
(`appleLocaleIssues` / `playLangIssues` errors). `check` / `validate` blockers (`other-platforms`), `scan` rules
|
|
`other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). `tests/cross-store.test.ts`.
|
|
|
|
## 78. `storebound api ... --yes` piped or redirected became a silent dry run
|
|
- **Symptom:** `storebound api apple <write> --yes | tail` (or `> out.json`) printed a dry run and exited 0; the
|
|
owner thought it was sent. Happened twice.
|
|
- **Cause:** writes need an interactive terminal; without one the gate fell back to a dry run.
|
|
- **Do:** run writes without a pipe or redirect in a terminal, or use the dedicated storebound command.
|
|
- **Guard:** `decide` in `cli/api/gate.ts` refuses (`NOT_A_TTY`): exit 1, the message on stderr, nothing sent, the
|
|
dedicated command named. Without `--yes` a write is still a plain dry run. `tests/api.test.ts`.
|
|
|
|
## 79. A macOS (tvOS, visionOS) version was invisible next to the iOS one
|
|
- **Symptom:** `apple resubmit` said "nothing to resubmit" while a macOS version was REJECTED with an
|
|
UNRESOLVED_ISSUES submission; `apple listing --version 0.8.7` refused because the iOS 0.8.7 was live, although the
|
|
macOS 0.8.7 was the rejected one to fix; `apple status` listed versions without saying which platform.
|
|
- **Cause:** one app record can have a version per platform, each with its own state and its own review submission
|
|
(`reviewSubmissions` carry a `platform`). Lookups filtered on `IOS` only, or did not filter at all and mixed them.
|
|
- **Do:** every version and review submission lookup filters by platform (`filter[platform]`). Commands that need one
|
|
version (status --wait-review, listing, listing pull, resubmit, submit, release, screenshots) take
|
|
`--platform ios|macos|tvos|visionos`; without it they use the platform that has the version they need, and refuse
|
|
naming both when several do. Status, validate, release status, the report and the desktop show the platform next to
|
|
every version; validate and release status warn about another platform's editable or rejected version.
|
|
- **Guard:** `cli/apple/platform.ts` (`pickPlatform`, `parsePlatform`), `tests/apple-platforms.test.ts` (fake App Store
|
|
Connect: iOS 0.8.7 live, macOS 0.8.7 rejected in UNRESOLVED_ISSUES).
|
|
|
|
## 80. Screenshot text and logo hugging the edges; a Mac app with no screenshots; sheet.png timing out
|
|
- **Symptom:** a game's hero logo and titles touched the left / right / top edge of the screenshots (the stores crop
|
|
and round the corners, it reads cramped); a game with a macOS version had no Mac screenshots (storebound had no Mac
|
|
size); `shots render` stopped with "sheet.png ... Timeout 30000ms" on a project with 9 sizes.
|
|
- **Cause:** the templates padded the text by 5 % and drew the logo 86 % wide with a negative top margin, nothing
|
|
measured it; the size table only knew iPhone, iPad and Play; the contact sheet used the same flat 30 s wait as one
|
|
screenshot while decoding every image of the language.
|
|
- **Do:** keep the 7 % / 5 % safe margins (`shots check` code `margin` fails anything under 6 % / 4 %, measured from the
|
|
page's layout boxes in report.json); set `apple.mac: true` for a Mac app (`mac` size, 16:10, `APP_DESKTOP`, uploaded
|
|
with `apple screenshots --platform macos`) and capture landscape frames; the sheet's wait now scales with images,
|
|
sizes and load and logs a heartbeat (or use `--no-sheet`).
|
|
- **Guard:** `marginViolations` / `SAFE_MARGIN` and `renderDims` in `cli/shots/lib.ts`, `marginIssues` in
|
|
`cli/shots/report.ts`, `IMAGE_SPECS.mac` and `macNote` in `cli/shots/spec.ts`, `MAC_DISPLAY` in
|
|
`cli/apple/screenshots.ts`, `sheetTimeoutMs`; `tests/mac-landscape.test.ts`, `tests/browser/landscape.browser.ts`,
|
|
the macOS case in `tests/idempotency.test.ts`.
|
|
|
|
## 81. `storebound apps folders add` listed the folders and added nothing
|
|
- **Symptom:** `storebound apps folders add` (or with `"$PWD"`) in a freshly init-ed folder printed the folder list,
|
|
no error, and ~/.storebound/projects.json stayed without the folder.
|
|
- **Cause:** only `apps add` added; `apps folders` ignored every word after it and listed.
|
|
- **Do:** `apps folders add [<folder>]` is now the same as `apps add [<folder>]` (bare = the current folder). A folder
|
|
whose config still has template values is added with a note; a folder without a config is refused with the reason
|
|
and the fix (`storebound init`). Unknown words after `apps folders` are an error, never a silent list.
|
|
- **Guard:** `folders` and `templateNote` in `cli/apps.ts`; `tests/store-minimums.test.ts` (apps folders add).
|
|
|
|
## 82. The MCP server failed to connect: spawn ENOENT, CONNECTION_CLOSED
|
|
- **Symptom:** the client showed `storebound (ENOENT) posix_spawn '<home>/…/bin/storebound'` or
|
|
"CONNECTION_CLOSED: Connection closed".
|
|
- **Cause:** `.mcp.json` spawned `${CLAUDE_PLUGIN_ROOT}/bin/storebound` directly; when that folder is gone (an old
|
|
plugin cache after an update) or the variable is unset (the repo opened as a project), the spawn fails before any
|
|
code runs. Under npx, the self-update could also start a second npx download before the handshake.
|
|
- **Do:** `.mcp.json` runs `sh -c`: the plugin's launcher if it exists, else `storebound` on PATH, else
|
|
`npx -y storebound@latest mcp`. `mcp` and `serve` skip the self-update. Nothing may print on stdout before the
|
|
JSON-RPC answer (logs go to stderr).
|
|
- **Guard:** `MCP_LAUNCH` in `tools/gen-adapters.ts`, `cli/index.ts`; `tests/mcp-spawn.test.ts` spawns the CLI, the
|
|
launcher and the `.mcp.json` command with a missing plugin root and completes `initialize` within 10 s.
|
|
|
|
## 83. Uploads below the store's build minimum (Play target API level, App Store Xcode / SDK)
|
|
- **Symptom:** Google Play refuses a bundle with targetSdkVersion 35 (API 36+ for new apps and updates since
|
|
2026-08-31); App Store Connect refuses an IPA built with an Xcode / SDK older than the minimum (Xcode 26 / the 26
|
|
SDKs since 2026-04-28).
|
|
- **Do:** set `targetSdkVersion` (and `compileSdkVersion`) in android/variables.gradle (Capacitor), the app module's
|
|
build.gradle(.kts), Flutter's android/app/build.gradle or Unity's Target API Level; build iOS with the current Xcode.
|
|
`scan` / `validate` (rules `play-target-sdk`, `apple-min-sdk` from the newest IPA in build/ios/export) and
|
|
`play upload` / `play release` / `apple upload` block before any network call, naming the requirement, date and fix.
|
|
- **Guard:** the dated tables `PLAY_TARGET_SDK_TABLE` and `APPLE_MIN_SDK_TABLE` in `cli/lib/store-minimums.ts` (a new
|
|
year is one row); `tests/store-minimums.test.ts`.
|
|
|
|
## 84. Thin app-style headlines, white backgrounds from a string palette.bg
|
|
- **Symptom:** a variable Google title family (a 200..800 axis) was downloaded as its default 400 instance, so the
|
|
750 headlines rendered thin (font-synthesis is off); a custom palette with `bg: '#15171C'` or a gradient string
|
|
rendered a white background, and white `ink` headlines vanished.
|
|
- **Do:** run `storebound shots fonts` again (it fetches the weights the templates use: full axis, used range, or
|
|
static weights) and `shots render`. Use `bg` as stops, one colour or a CSS gradient string; for a photo use
|
|
`palette.image` / `shots.feature.background` with an overlay. Read the `font-weight` and `contrast` warnings of
|
|
`shots check` before uploading.
|
|
- **Guard:** `cli/shots/weights.ts` (templateWeights, fetchFaces, weightIssues), `cli/shots/palette.ts` (bgIssue in
|
|
config validation, bgCss, contrastIssues, coverCrop); report.json `weights` / `contrast`; `tests/shots-look.test.ts`,
|
|
`tests/browser/palette-bg.browser.ts`.
|
|
|
|
## 85. Web app previews at CSS size (blurry 1080x1920 promo)
|
|
- **Symptom:** `shots record --source web` clips of a 430x932 @2x page came out 430x932, so a 1080x1920 preview was a
|
|
2.5x upscale. Chromium's `Page.startScreencast` sends frames at the viewport's CSS size whatever the deviceScaleFactor
|
|
(`maxWidth` / `maxHeight` only shrink); forcing scale 2 through `Emulation.setDeviceMetricsOverride` breaks the layout
|
|
(innerWidth 980).
|
|
- **Do:** update storebound; the recorder takes `Page.captureScreenshot` frames with `clip.scale` = the frame's dpr
|
|
(long side capped at 1920), 3 requests in flight. Re-record old web clips. If it still warns that frames are
|
|
smaller than asked, point `STOREBOUND_CHROME` at a current Chromium or record with `--source folder`.
|
|
- **Guard:** `recordSize` / `captureShortfall` in `cli/shots/devices.ts` (first frame size checked per clip, plus a
|
|
frames-per-second warning under 20); `tests/video.test.ts`.
|
|
|
|
## 86. Console plans behind the live pages (Play App content, IARC, App Privacy; 2026-10-21)
|
|
- **Symptom:** on a real run the health card offered "My app does not have any health features" while the sheet said
|
|
"Other" and verify said "not found"; Sign in details asked "Is any part of your app restricted? Yes / No" with an
|
|
"Add details" button; finance had its boxes on step 1 (step 2 is information only); App Privacy's "Additional Setup
|
|
Required" OK opened the first type's setup by itself; verify could not read Yes / No radios from text; a page
|
|
answered and then left with Next lost its answers; a keyword helper answered IARC "Digital purchases" No because its
|
|
text mentions "cash rewards", so the digital goods boxes and the loot-box / trading follow-ups never showed.
|
|
- **Do:** follow the plan's labels (new label first, `altLabels` as the fallback). On every Play page with a Save next
|
|
to Next: Save, wait for "Your changes have been saved", then Next. In App Privacy, a setup dialog that opened by
|
|
itself after the OK is the first type's: carry on there. Verify with the plan's `verify.capture` JSON
|
|
(`console verify <form> --from -`). Answer IARC by exact console labels only, never keyword sweeps over unanswered
|
|
groups; tick "Purchases of digital goods" after Yes on "Digital purchases".
|
|
- **Guard:** `cli/lib/console-play-plans.ts` (labels, step order, `INPUT_STATES_CAPTURE`), `saveBeforeNext` and the
|
|
exact-label `IN_PAGE_LOOP` in `cli/lib/console-plan.ts`, `parseStates` in `cli/lib/console-verify.ts`;
|
|
`tests/console-guardrails-1021.test.ts`.
|
|
|
|
## 87. "no review contact" with a reviewContact in the credentials file; whole help for a missing --version
|
|
- **Symptom:** `apple review-details` said "no review contact" while `.storebound/credentials.json` had
|
|
`reviewContact { firstName, lastName, phone, email }` (only the project folder's own file was read, under one key);
|
|
`apple listing` without `--version` (or with `--version` and no value) printed usage text or went on with "true".
|
|
- **Do:** update storebound. The contact is read from the project's file, the same file in parent folders up to the
|
|
repository root and the credential profile, under `reviewContact` (also `apple.reviewContact`, `appReview.contact`,
|
|
ASC field names); when none is found the error lists every file and key checked. Missing required values are one
|
|
line: "--version <x.y.z> is required (storebound apple status shows the editable version)".
|
|
- **Guard:** `findReviewContact` / `noContactMessage` in `cli/lib/credentials.ts`, `Args.required` in
|
|
`cli/lib/args.ts` (listing, privacy-manifest, adhoc export, edit, resubmit); `tests/console-guardrails-1021.test.ts`.
|
|
## 88. Game Center and Play Games Services: ids, SHA-1 and the deprecated enabled-versions route
|
|
- **Symptom:** Play Games sign-in fails on store installs while debug builds work (the Android credential has the
|
|
upload key's SHA-1, not the App signing key's); sign-in fails at once with no APP_ID (the Gradle dependency is there,
|
|
the manifest meta-data or games-ids.xml is not); Game Center items created twice by a re-run; a tool reading
|
|
`/v1/apps/{id}/gameCenterEnabledVersions`, which the API marks deprecated.
|
|
- **Do:** one `games` section (game-services skill). Game Center items are matched by vendor identifier (`id`), Play
|
|
items by `playId` or the default-language name (Play generates ids). Read the SHA-1 on Play Console > App integrity >
|
|
Play app signing (the Play API has only SHA-256, `generatedapks.list`). Version switch: `gameCenterAppVersions`.
|
|
- **Guard:** `cli/lib/game-services.ts` (validateGames, planGameCenter, planPlayGames, pgsFindings, gcFindings),
|
|
`storebound scan` rule `pgs.manifest` / `pgs.app-id`, `cli/play/games.ts` (no 5xx retry of an insert),
|
|
`tests/game-services.test.ts`.
|
|
|
|
## 89. Submit refused: 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED
|
|
- **Symptom:** `apple submit` of a first version failed with 409 `STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED`:
|
|
"The build for this app store version has com.apple.developer.game-center entitlement but appStoreVersion is
|
|
missing gameCenterConfigurations relationship". The build had the entitlement; the app had no Game Center detail.
|
|
- **Do:** `storebound apple game-center --dry`, then without `--dry`: `POST /v1/gameCenterDetails` (app) when the
|
|
app has none, then `POST /v1/gameCenterAppVersions` (appStoreVersion). The resubmit then passes. `apple submit` /
|
|
`resubmit` and `apple signing ensure` (when GAME_CENTER is wanted) now run the same switch first and show it in
|
|
the dry run; `validate` blocks while either is missing and Game Center is in the config, the entitlements or the
|
|
built IPA.
|
|
- **Guard:** `readGcSwitch` / `gcSwitchPlan` / `ensureGcSwitch` / `gameCenterInUse` in `cli/apple/game-center.ts`,
|
|
`gameServicesFindings` (codes game-center.detail, game-center.version), the fake store's `gameCenterBuild` 409 and
|
|
`tests/game-services.test.ts`.
|
|
|
|
## 90. The second step of `apple submit --no-submit` said "nothing to submit"
|
|
- **Symptom:** `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` prepared the draft
|
|
printed the plan (every item "in it already", last row "submit PATCH submitted: true · owner step (--yes)"),
|
|
logged "not added <iap>: already in the draft submission" for each item and ended with "nothing to submit"; the
|
|
draft stayed READY_FOR_REVIEW and was never sent.
|
|
- **Cause:** the submit ran only when the run added items or the submission was back from App Review
|
|
(UNRESOLVED_ISSUES). A draft that already held everything counted as nothing to do.
|
|
- **Do:** update storebound and run the same `--yes` again: the prepared draft is submitted as it is (with the
|
|
"not ready yet" retry). A plan that ends with a submit row and a run that ends with "nothing to submit" is a bug:
|
|
check `apple status` for the draft's state before re-running anything else.
|
|
- **Guard:** `willSubmit` in `cli/lib/review-submit.ts` (a non-empty READY_FOR_REVIEW draft that holds the named app
|
|
version, or for `iap-submit` an asked-for item, is submitted); `tests/resubmit.test.ts`.
|
|
|
|
## 91. Submit refused: 409 STATE_ERROR.ASSET_IN_POST_PROCESSING.APP_SCREENSHOT (screenshots stuck in UPLOAD_COMPLETE)
|
|
- **Symptom:** `apple submit` answered 409 `ASSET_IN_POST_PROCESSING.APP_SCREENSHOT` for hours. A few screenshots
|
|
sat in UPLOAD_COMPLETE and never became COMPLETE: their part upload had gone out with `Expect: 100-continue`, got
|
|
a non-2xx answer, and the upload was committed anyway.
|
|
- **Do:** `storebound validate` (or the failed submit) now lists every screenshot that is not COMPLETE: locale,
|
|
display type, position, file name, state and age. UPLOAD_COMPLETE for over 5 minutes is **stuck**, not processing
|
|
(a blocker); younger ones are a warning, wait. Then `storebound apple screenshots --repair-stuck --dry`, then
|
|
without `--dry`: only the stuck / FAILED / AWAITING_UPLOAD ones are deleted, the same local file (same name in the
|
|
renders) uploaded again and put back at its position; the set count is checked afterwards. Re-runnable.
|
|
- **Guard:** `cli/apple/screenshot-health.ts` (`pendingScreenshots`, `explain409`, `repairPlan`, `repairSet`,
|
|
ledger `.storebound/screenshot-states.json` for the UPLOAD_COMPLETE age), `uploadAssetVia` in `cli/apple/asc.ts`
|
|
(parts without `Expect`, any non-2xx part is a failure, the AWAITING_UPLOAD reservation is deleted and the upload
|
|
tried once more, never committed after a failed part), `tests/screenshot-health.test.ts`.
|
|
|
|
## 92. Agents could not send any `storebound api` write (no TTY)
|
|
- **Symptom:** an agent's shell has no TTY, so every write through `storebound api` was refused (pitfall 78), even
|
|
a harmless one the owner had already agreed to, and the owner was asked to run it by hand.
|
|
- **Do:** show the dry run, then `storebound api apple|play <operationId> ... --yes --confirm <operationId>`. The
|
|
`--confirm` value must equal the operation id the call resolved to (also with `METHOD path`), or nothing is sent.
|
|
Owner-only operations (submit, release, delete, prices, testers, replies, ...) refuse `--confirm`: the owner
|
|
still types the id in a terminal. MCP and the desktop app still never send writes.
|
|
- **Guard:** `decide` / `OWNER_NO_CONFIRM` in `cli/api/gate.ts`, `tests/api.test.ts`.
|
|
|
|
## 93. Screenshots rendered without their frames (empty store images, check said 0 errors)
|
|
- **Symptom:** `shots render` finished and `shots check` reported 0 errors / 0 warnings, but the images showed only
|
|
captions and backgrounds: no app or game. The render log had only `404 .../frames/...` warnings.
|
|
- **Cause:** the raw frames folder (`shots.frames`) is usually git-ignored; in a fresh checkout it was never captured,
|
|
and the 404s were recorded in report.json `missing` but never checked.
|
|
- **Do:** run `storebound shots capture` (or restore the frames folder) before rendering. Never ship renders whose
|
|
log has 404 lines.
|
|
- **Guard:** `shots render` refuses to start while any frame a selected scene uses is missing (`framesForRender` in
|
|
`cli/shots/lib.ts`) and exits 1 if any file failed to load while rendering; `shots check` and `validate` raise a
|
|
`frame-missing` error for every render whose report entry has failed loads (`cli/shots/report.ts`,
|
|
`tests/shots-missing-frames.test.ts`).
|
|
|
|
## 94. The MCP server never starts on Windows (`npx` as the stdio command)
|
|
- **Symptom:** the agent shows the `storebound` MCP server as "failed" / "connection closed" on Windows; the same
|
|
entry works on macOS and Linux. An old entry pinned to an old version (`storebound@0.8.2`) also hides new tools.
|
|
- **Cause:** on Windows `npx` is `npx.cmd`, which an MCP client cannot spawn directly as a stdio command; the
|
|
`bin/storebound` launcher is a /bin/sh script and cannot be spawned either.
|
|
- **Do:** use `{"command": "cmd", "args": ["/c", "npx", "-y", "storebound@<version>", "mcp"]}`, or re-run
|
|
`storebound install --agent <agent>`, which writes that form (or `bun <toolchain>/cli/index.ts mcp`) on Windows. Keep the version current.
|
|
- **Guard:** `winWrap` / `mcpCommand` in `cli/install.ts`, `tests/wave1.test.ts`.
|
|
|
|
## 95. `storebound desktop` stayed on "downloading ..." forever
|
|
- **Symptom:** `npx storebound desktop` printed `downloading storebound_<v>_x64-setup-unsigned.exe (37.0 MB)` and
|
|
then nothing, for many minutes.
|
|
- **Cause:** the installer was not in the CDN cache yet (`cf-cache-status: MISS`) and the origin sent no bytes; the
|
|
CLI read the whole body at once, with no progress and no timeout.
|
|
- **Do:** run the command again later; after a release, warm the CDN cache by fetching every installer once.
|
|
- **Guard:** `download` in `cli/desktop.ts` reads in chunks, prints progress every 10 % and fails after 60 s without
|
|
data (`tests/desktop-dist.test.ts`).
|
|
|
|
## 96. A project still uploaded with fastlane and storebound never said so
|
|
- **Symptom:** the Play listing or build changed behind storebound's back; CI ran `fastlane supply` (or `deliver` /
|
|
`pilot`) next to the storebound config, and `check`, `scan` and `release status` were all quiet.
|
|
- **Cause:** storebound looked only at its own config; fastlane files and CI upload calls were never read.
|
|
- **Do:** run `storebound import fastlane` (MCP `import_fastlane`) to bring the metadata over, then replace each
|
|
upload call with the storebound command and remove the lane. Keep a lane only on purpose (`scan.ignore`
|
|
`fastlane-upload` with the reason).
|
|
- **Guard:** `findFastlaneTraces` in `cli/lib/fastlane-traces.ts` (Fastfile / Appfile, CI files and scripts with
|
|
`fastlane supply|deliver|pilot`); `check` warns, `scan` reports `fastlane-upload` with file:line, `release status`
|
|
warns per app (`tests/fastlane-traces.test.ts`).
|
|
|
|
## 97. The project's own scripts called the store APIs (no fastlane, same bypass)
|
|
- **Symptom:** an app uploaded AABs, set the track and rollout, pushed listing images and read the listing back with
|
|
three hand-written scripts (a self-signed JWT against `androidpublisher.googleapis.com`); storebound said nothing.
|
|
- **Cause:** pitfall 96 looked only for fastlane; direct API calls and upload tools (altool, Transporter) were not read.
|
|
- **Do:** replace each script with the storebound command: `play upload` / `play release` (MCP `play_release_testing`),
|
|
`play images` (`play_images`), `play listing pull` (`play_listing_pull`); for Apple `apple upload` and the other
|
|
`apple` commands (MCP `apple_*`). Keep one on purpose only with `scan.ignore` `direct-store-api` and the reason.
|
|
- **Guard:** `findFastlaneTraces` in `cli/lib/fastlane-traces.ts` also reports `androidpublisher.googleapis.com`, the
|
|
googleapis androidpublisher client, `api.appstoreconnect.apple.com`, `altool` / `iTMSTransporter` in CI files and
|
|
scripts (not storebound's own checkout, never node_modules); `scan` rule `direct-store-api`, `check` and
|
|
`release status` warnings with file:line and the storebound command (`tests/fastlane-traces.test.ts`).
|
|
|
|
## 98. `play build` on Windows: "Executable not found in $PATH: gradlew.bat"; "C:/..." keystore "does not exist"
|
|
- **Symptom:** `storebound play build` stopped before Gradle on Windows although `android/gradlew.bat` exists; with
|
|
`ANDROID_KEYSTORE` set to an absolute `C:/...` path it said the keystore file does not exist.
|
|
- **Cause:** a bare `gradlew.bat` is searched on PATH only, never in the working folder; absolute paths were recognised
|
|
only when they started with `/`, so `C:/...` was joined onto the project folder.
|
|
- **Do:** update storebound. Until then run Gradle by hand and use a project-relative keystore path.
|
|
- **Guard:** `gradleArgs` in `cli/lib/gradle.ts` runs `cmd /c gradlew.bat` on Windows; `androidSigning` uses
|
|
`isAbsolute` (`tests/build-sign.test.ts`).
|
|
|
|
## 99. Credentials still pointed at the old computer, and the keystore password did not open the keystore
|
|
- **Symptom:** after a machine move `ANDROID_KEYSTORE`, `PLAY_SERVICE_ACCOUNT` and `ASC_P8` held the old machine's
|
|
absolute paths, and `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` did not open the upload keystore (keytool exit 1);
|
|
`check` passed and the build failed late.
|
|
- **Do:** point each path at the file on this machine; take the password and alias that open the keystore (an
|
|
`android/keystore.properties` often holds them). Verify with `keytool -list -keystore <file> -alias <alias>`.
|
|
- **Guard:** `credentialFileIssues` in `cli/lib/credentials.ts`: `check` names every credential path that does not
|
|
exist and runs `keytool -list` with the stored password and alias (when keytool is on PATH or in JAVA_HOME), naming
|
|
the bad field (`tests/build-sign.test.ts`). `play build` / `apple build` also write `<out>/latest.json` so CI
|
|
needs no glob for the AAB / IPA.
|
|
|
|
## 100. `console_verify` reported every App Privacy type missing (multi-line page text over MCP on Windows)
|
|
- **Symptom:** MCP `console_verify` with the App Privacy page text said "not on the page" for every data type and "no
|
|
Published line"; the command line it echoed held only the text's first line. The same text from a file matched.
|
|
- **Cause:** the text went to the CLI as one `--text=...` argument; on Windows the child starts through a shell, which
|
|
kept only the first line. Separately, `console_plan` output over 60 KB was cut at the start, losing the start URL
|
|
and the first steps.
|
|
- **Do:** update storebound; meanwhile save the page text to a file and run `storebound console verify <form>
|
|
--from-text <file>`.
|
|
- **Guard:** `textToFile` in `cli/mcp.ts` sends multi-line text as `--from-text <temp file>`; `clip` keeps the start of
|
|
long JSON output (the end of plain text) and names a file with the whole output (`tests/mcp-text-file.test.ts`).
|
|
|
|
## 101. `apple build` over SSH: errSecInternalComponent; a relative ASC_P8 path: "must be an absolute path"
|
|
- **Symptom:** `storebound apple build` run over SSH on a Mac failed late in the archive (Embed Pods Frameworks,
|
|
codesign errSecInternalComponent). With `ASC_P8` set to a project-relative path, xcodebuild stopped with
|
|
"-authenticationKeyPath must be an absolute path".
|
|
- **Cause:** codesign in an SSH session cannot use the login keychain. The key path was handed to xcodebuild as written
|
|
(and read relative to the working directory, not the project).
|
|
- **Do:** run the build in the Mac's logged-in session: a CI agent started there, or from SSH a `.command` file opened
|
|
with `open -a Terminal <file>` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). `--allow-ssh` after
|
|
`security unlock-keychain` skips the check.
|
|
- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `credPath` in
|
|
`cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an
|
|
absolute path (`tests/build-sign.test.ts`).
|
|
|
|
## 102. A new Play language: `play listing` 403 "no high resolution icon set for language", `play images` skips it
|
|
- **Symptom:** adding a language (tr-TR) to an existing app, `play listing --yes` failed at the edit commit with 403
|
|
"This app has no high resolution icon set for language tr-TR", while `play images` said "skipped tr-TR: no Play
|
|
listing yet": each waited for the other.
|
|
- **Cause:** Play commits a new language only when its hi-res icon is in the same edit.
|
|
- **Do:** update storebound; `play listing` now uploads the icon (and the feature graphic when rendered) for a new
|
|
language in the same edit. Then run `play images` for the screenshots.
|
|
- **Guard:** `firstGraphics` in `cli/play/images.ts`, used by `cli/play/listing.ts` for languages Play does not have yet;
|
|
it stops with the fix when the 512x512 icon is missing.
|