storebound/skills/store-pitfalls/SKILL.md

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.