# Changelog
## Unreleased
- **Remote config and remote paywalls (App Review 2.3.1, Play Deceptive behavior).** `storebound scan` (and
`validate`; `check` as a warning) reports `remote-features` for both stores when the app ships a remote config /
feature flag SDK (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely, PostHog feature flags,
Unleash, ConfigCat, Amplitude Experiment, Split) or a remote paywall (RevenueCat Paywalls, Superwall, Adapty,
Qonversion, Purchasely, Apphud) and the review notes do not mention it. Warn only: storebound never writes the note;
the config templates carry a commented example line. Code obfuscators are a low note, minify alone nothing
(store-pitfalls 74).
## 0.8.11 · 2026-10-17
- **Guest-play apps rejected as a login wall (Play).** `reviewAccess.instructions` with `signInRequired: false` holds
guest steps: `storebound check` warns when a Play app has a first-run gate (name entry, code / transfer entry) or
in-app purchases and no steps, and the `play-sign-in-details` plan then declares one entry without credentials,
the dialog's required full-access box as the owner's step (store-pitfalls 73).
## 0.8.10 · 2026-10-15
- **Neutral names only.** Docs, pitfalls, tests and comments use example names instead of the projects that reported
issues; the history was rewritten the same way. Older npm versions are withdrawn.
- **Releases:** a candidate whose build jobs were cancelled by a newer push says so and gives the tag move and
`--resume` line.
## 0.8.9 · 2026-10-15
- **Play edits Play will not send for review are committed anyway.** After a rejection, with other pending changes or
managed publishing, Play answers validate / commit with 400 "Changes cannot be sent for review automatically. Please
set the query parameter changesNotSentForReview to true". storebound now commits the same edit again (edits.commit only: validate refuses the parameter with 400 "Unknown name")
with that parameter (a dry run validates that way and says the real run will commit so), then prints "committed;
Play did not send it for review: press Send changes for review in Publishing overview: " (the owner's button).
`--not-sent-for-review` on `play release`, `upload`, `rollout`, `listing` and `images` (MCP / desktop
`not_sent_for_review`) sets it from the start. The API cannot read that changes wait to be sent, so storebound records
its commit in `.storebound/` and `play status` shows "changes committed , waiting for Send changes for review"
(`--review-sent` forgets it). Pitfall 72.
## 0.8.8 · 2026-10-15
- **Console pages as absolute deep links.** Every finding, fix hint, error and plan step that points to an App Store
Connect or Play Console page now prints the readable path plus the absolute URL of that page for the app
(`cli/lib/console-links.ts`; apple.appId, play.developerId and play.appId pick the app, the app list otherwise).
validate, scan, declarations and release plan JSON carry `url`, and the desktop shows it as an Open button. A test
fails on a plain console direction without a URL; AGENTS.md rule 11 and pitfall 71 tell agents the same.
## 0.8.7 · 2026-10-15
- **First in-app purchases left out of a review are caught.** Apple reviews the first in-app purchase of a type (and
the first subscription of a group) only together with an app version. `validate` and `release plan` now block when
one that never passed review is not in the open review submission, and `apple submit` refuses to leave one out;
`apple status`, `release status` and the desktop warn when a version waits for or is in review without it ("not
sellable after approval; add it with the next version, or withdraw the submission and resubmit with it").
`apple iap-review` says "ready, not submitted yet" instead of calling READY_TO_SUBMIT complete.
## 0.8.6 · 2026-10-14
- **Desktop: Fix with AI** on scan findings that need a code change (NSAllowsArbitraryLoads in Info.plist, a missing
foregroundServiceType, purpose strings, target SDK, ...). The app runs your local Claude Code headless in the app
folder with file tools only (no commands, no network, no git push), streams its progress, stops after 10 minutes or
on Cancel, and shows the diff with Keep and Undo (exactly the files it changed), then rescans. It refuses while the
folder has uncommitted files. Without Claude Code installed the button copies the prompt instead (Copy for AI).
- **Desktop: a Fix button on findings** that storebound can fix itself. Scan and validate findings carry a
machine-readable `action` (`--json`); the Checks tab runs it as a write with its dry run in the native dialog
before anything changes. privacy-labels writes the detected answers into the config (`scan --write-only`, the
dialog shows the config diff); missing Play listings, a missing App Store localization or copyright and the App
Review contact push the listing files / config to the store (text only). Nothing submits, releases or publishes.
- **Desktop:** long findings (a file path in a build folder) wrap inside the Store readiness card instead of pushing
the store chips out of it; the release timeline's rail no longer breaks after the current step; the rollout bar
stays under its stage.
- **`scan`: function words of the listing languages** (German "eine", "der", French "les", Turkish "bir", ...) no
longer count as repeated keywords in the Play full description.
- **`import type { ShipConfig } from 'storebound'` type-checks outside Bun.** The package ships declarations
for ES module and CommonJS projects (`dist/types`, `dist/types-cjs`) and a JS entry for Node; Bun keeps the .ts source. 0.8.5 named only
`cli/lib/config.ts`, so tsc (`next build`, vue-tsc, ts-loader, `tsc --noEmit`) failed on a scaffolded
storebound.config.ts and a working Next app stopped building. A test packs the entry and type-checks the template
in both project types under bundler, node16, nodenext and node10 with no @types/node and skipLibCheck off, and loads it with import() and require() under Node. Pitfall 69.
- **`check` fails on the scaffolder's own placeholders**: "2026 Example Studio" and example.com / .org / .net URLs and
addresses in `app`, `accounts` and `reviewAccess` would reach the stores as the app's copyright, contact and legal
pages.
## 0.8.5 · 2026-10-14
Releases take one command and minutes: every push to main is a release candidate (full CI, the acceptance matrix and
the Linux / Windows installers stored per commit), a version tag only promotes it, and `bun tools/release.ts` does
the rest on the Mac.
- **A Chrome storebound starts never outlives its run.** The console window's Chrome is ended on exit, closed
gracefully on Ctrl+C / SIGTERM / SIGHUP, and ended by a watchdog when the run dies in any way (SIGKILL included).
Every start first ends headless storebound Chromes earlier runs left behind (an orphan kept the owner's own Chrome
from opening from the macOS Dock) and names orphaned windows. Headless runs on macOS prefer Chromium / Chrome for
Testing over Google Chrome.app. `console fill` closes the window on an error too. Pitfall 68.
- **Every push to main is a release candidate.** ci.yml runs the full CI, the acceptance matrix (Linux + Windows x
Node 20 / 22 / Bun) and the installer builds in parallel and stores the green result as the draft `rc-`.
A version-only bump commit (proven file by file) reuses its parent's CI and matrix and rebuilds only the
installers. A `v*` tag promotes the candidate (no rebuild, no retest) and fails at once on a red commit.
- **`bun tools/release.ts --yes`:** bump and CHANGELOG cut, fast gate, commit, push, tag; the Mac build and
notarization in parallel with Forgejo; macOS acceptance cached per CLI key; add, verify, publish (each Mac file
crosses the owner's uplink once); /Applications/storebound.app updated; the npm commands printed. `--rehearsal`
runs it all as a throwaway prerelease and deletes it afterwards.
## 0.8.4 · 2026-10-14
Existing products' prices never change without `--allow-price-changes`; PPP pricing compares like with like and keeps
high-income markets at par. `--json` prints only JSON. Automatic signing creates no profiles, and capabilities are
verified against the App ID, the config and the build. `check` requires a copyright; What's new drafts come from
release notes and flag jargon.
- **`apple signing ensure` with automatic signing creates no profiles or certificates.** With `signing.style:
automatic` (the default) it only adds missing capabilities to the App ID; Xcode owns certificates and profiles, and
the dry run says so. Provisioning profiles storebound made that nothing uses are listed by name with the exact
delete command (never run by storebound).
- **Capability writes are read back.** After adding a capability, `ensure` reads the App ID again (a few tries,
about 45 s at most) and fails with the API's answers when one is missing, instead of reporting success.
- **Capabilities compared in three places.** `apple status`, `apple signing status` / `ensure --dry` and `validate`
compare the App ID, `apple.signing.capabilities` and the iOS project's `.entitlements` (push, Game Center,
associated domains, Sign in with Apple, iCloud, app groups, ...; also an entitlements file not set in
CODE_SIGN_ENTITLEMENTS) and name what is missing where.
- **No false alarm for entitlements on another branch.** A checkout without `.entitlements` is a warning about this
checkout only ("built from another branch?"), naming the current branch and the local branches / worktrees that
have one; it never claims the build lacks them. With a built IPA in build/ios/export, its real entitlements
(codesign, or the Mach-O signature when codesign is missing) are compared as "uploaded build N"; without one the
output says the App Store Connect API does not expose a build's entitlements.
- **`storebound api` writes outside a terminal** say that they need an interactive terminal by design and name the
dedicated command for common operations (bundle id capabilities: `apple signing ensure`, IAPs, listing, ...).
- **`--json` prints only JSON on stdout.** Every command run with `--json` sends its log, step and table lines to
stderr (one place: `withJsonOutput` in cli/lib/log.ts, wrapped around every command); the last commands that printed
JSON with console.log use `printJson`. A test runs every command that reads `--json` against the fake stores and
parses its stdout (`prices ppp --json` printed log lines first).
- **PPP prices compare like with like.** `prices ppp` takes the store's own conversion of the US price (VAT included,
today's rate: Play convertRegionPrices, Apple equalizations; offline base x rate x (1 + VAT)) as par and applies the
PPP price level to it. Defaults: only lower prices (`--only-decreases`), only economies with a price level at most
50 % of the US (`--max-level`), World Bank high-income economies keep par (`--include-high-income` to include
them), mainland China at par on Apple and skipped on Play, never above par (`--max-ratio`, `--min-ratio`; the old
`--floor` / `--ceiling` still work). Germany keeps EUR 5.99 for USD 4.99 (was EUR 3.59). A top-10 revenue market
that drops more than 20 % is warned about (from your sales reports, else a static list). Regions that keep their
price stay out of the sheet (`kept` in `--json`).
- **Creating products never changes a live price.** `play products`, `prices import`, `play subscriptions` and `apple
subscriptions` change prices of existing products only with `--allow-price-changes`, in a terminal too: one "y"
covered them before. With the flag they get their own question listing each old -> new; dry runs show a separate
"price changes on existing products (held)" section.
- **`whats-new` drafts from player-facing notes before the git log.** It takes `whatsNew.source`, then
`docs/RELEASE-.md`, then the CHANGELOG.md section of the version (else Unreleased); version from
`--release`, else package.json. Only without one (or with `--from-git`) it drafts from commit messages, and says so.
- **Internal jargon warnings in release notes.** The draft and `whats-new check` name SDK / vendor names, developer
and version-control words, file paths, issue ids and code identifiers, each with a plain alternative; warnings never
block. One list in cli/lib/jargon.ts; `whatsNew.allowWords` keeps a word. The whats-new skill says to write from what
players feel, never from commit messages.
## 0.8.3 · 2026-10-14
Docs: no private account names in the published package. `npx storebound` prompts work on Node again (the [Y/n]
prompt of `storebound desktop` crashed on Windows).
- **`npx storebound` prompts work on Node.** `storebound desktop` (and every [y/N] or type-the-id prompt) crashed on
Node at the question with "console is not async iterable" (Windows also hit a libuv assertion at exit): prompts now
read stdin through `rt.readLine` (node:readline, closed after one line, EOF is "no"). A test keeps Bun-only stdin
iteration out of cli/, and the Windows release job runs the Node prompt smoke on the runner.
- **Release privacy scan of signed macOS builds leaves out Apple code signatures only.** The Developer ID certificate
in a signature names the developer; the .app, DMG and updater bundle are scanned as copies without signatures, and
any hit elsewhere still fails the release.
## 0.8.2 · 2026-10-14
The desktop app edits everything the consoles do (listings, app info, catalog and prices, TestFlight, store art), with
a dry run before every store write, and reads everything on its own within the stores' rate limits. Play status comes
from the live store page (Live / In review / Rolling out); `art pull` takes icons and screenshots from the stores;
errors are clear messages instead of raw API text.
- **Store art of a live app comes from the stores, not a re-render.** `storebound art pull [--store apple|play]
[--write] [--force]` downloads every App Store screenshot set and app preview per locale and display type (the
original size from `imageAsset.templateUrl`), the App Store icon (the newest build's icon at 1024 px, else the
public artwork) and per Play language the icon, feature graphic and phone / 7" / 10" / TV / Wear screenshots (the
original files, `=s0`, checked against Play's sha256) into the store art layout (`store/renders///NN.png`,
`feature/1024x500.*`, the icons at `app-store-1024.png` / `play.icon`). `store/renders/pulled.json` records each
file's sha256, the store, the URL host (never the URL) and the store's own checksum, so `play images` and
`apple screenshots` / `apple previews` see a pulled file as the store's and upload nothing. An upsert: a re-run
changes nothing, a store change updates an unedited pull, and a local file that differs (your own render, an edit
after the pull) is kept and listed unless `--force`. Read-only on the stores. `storebound onboard` runs it too
(`--no-art` skips it). Pulled art never goes into git: when the app folder is in a repo and the renders folder is
not ignored yet, `art pull --write` (and `init` / `onboard`) add it to the repo root .gitignore (e.g. `/store/renders/`).
- **check / validate with pulled art.** Missing render sources (the icon artwork, the logo, screenshot captions) are
notes instead of problems once the art is pulled; pulled files pass the local store checks as the store's own, and
a set the store had nothing for (a Play language on the default language's graphics, a locale without that set) is
not demanded locally (validate reports the store side).
- **Desktop: the live store art.** The Store art tab shows the art that is live on both stores (thumbnails per store,
language and set; same / differs / not pulled per image) with **Pull from stores** (dry run, dialog, then the files),
and store-only apps get the tab read-only. **Replace with a file** (the lightbox, and each scene's frame in the art
editor) copies a picked PNG / JPEG into the app folder after a dry run with the store checks (`art.replace`; native
picker `image_pick`). docs/console-parity.md: 76 rows, 57 complete, 19 with the reason.
- **Parallel Play reads no longer break each other.** Google Play keeps one open edit per app, so two reads of one
app at once (the desktop's auto-read) deleted each other's edits ("This Edit has been deleted") and the Releases tab
then said "not on Google Play yet". Every Play edit of a package now runs under a machine-wide per-package lock
(`~/.storebound/locks`, wait with timeout and heartbeat; other packages stay parallel), and a read whose edit was
deleted is redone once with a new edit (writes are not). A store that failed to read shows "Couldn't read Google
Play" and keeps its last good releases with "couldn't refresh". When the stores' current versions differ, the card
header says "Current · Play 4.1.9 · App Store 4.1.2" and each lane shows its own state ("4.1.2 · Live (4.1.9 not
submitted)"); "Not on this store" only for an app with no record there (pitfall 58).
- **Google Play status is decided, never "maybe".** `play status`, the portfolio and the desktop read the live version
from the public store page ("Version" in About this app): the same version = Live, 404 = In review (managed
publishing: In review / ready to publish), an older one = In review (live: 1.0.0), Rolling out N% / Halted / Draft
from the API. Only a network failure says "Couldn't check the store page", and it keeps the last known answer with
its time. "In review or not yet published" and "Google may still be reviewing" are gone.
- **Desktop reads everything by itself.** No more "Not read yet" and a Read button on every page: on start, when the
window shows again and every 15 minutes while it is open (Settings: auto-read on / off, interval), every app's
status, releases and submission state, TestFlight, catalog, prices, listings, health, reviews, analytics and checks
are read in the background when their saved copy is older than its TTL (5 min to 6 h). Saved reads show at once
with "read 3 min ago"; a failed refresh keeps them with "couldn't refresh: ". One scheduler keeps it inside
the stores' limits: 4 at a time per store, starts spaced per account, slower below 20 % of App Store Connect's
hourly limit (read from `X-Rate-Limit`) and a pause at 100 left, exponential backoff with jitter on 429 /
RESOURCE_EXHAUSTED, the visible app first. "Refresh all" in the toolbar, a refresh icon per card, writes refresh
what they changed, and a status line: "Reading 12 of 48 · App Store limit 3,412/3,600 left this hour".
- **Health: open a top issue.** Full title, type, counts, versionCode range, Android API range, last report, a sample
stack trace (`storebound health --issue ` / `--signature `, new read `health_issue`), Open in Play Console,
Copy for AI, and a likely cause for known signatures (`cli/lib/crash-hints.ts`; first entry: the Capacitor
`Bridge.getPermissionStates` NullPointerException from `checkPermissions()` during an activity transition).
- **Google API errors in words, not JSON.** Google's error bodies are parsed centrally (`cli/lib/google-errors.ts`)
into typed findings: `api-disabled` (403 SERVICE_DISABLED: service, Cloud project id, the console page to turn it
on), `no-app-access` (403 PERMISSION_DENIED: which app, the Users and permissions step), `auth-revoked` (401,
invalid_grant) and `quota` (429, RESOURCE_EXHAUSTED). `health`, `analytics` and the health JSON say one line with the
URL ("Crash and ANR data needs the Google Play Developer Reporting API turned on in Google Cloud project . Turn
it on: ..."); the raw text stays in `detail` and the log. The desktop Health tab shows that line with "Turn it on in
Google Cloud" and "Check again", the raw text only in a collapsed Details; the Portfolio shows a Cloud project that
blocks several apps once ("1 Google Cloud project needs an API turned on: affects ..."). Pitfall 57.
- **Findings are results, not errors, in the desktop.** `scan`, `check`, `validate` and `health` exit 1 when they find
problems (by design); the sidecar now reports an `outcome` (ok / findings / declined / failed) and, for a real
failure, the command's own `error` line (stderr's last "error: ..." line). The Checks tab lists the findings instead
of "Exit code 1", findings results are cached, and every place that showed a bare "Exit code N" shows the error line.
- **Play "Live" only when the store page proves it.** The Play Developer API has no review state: a production release
reads `completed` while Google still reviews it. `play status` now checks the public store page (no auth, fast
timeout, cached 5 minutes) and adds `publicPage` to its JSON: page 404 = "Sent to Google (in review or not yet
published)", 200 = live for the first release, an update is "Released to production (Google may still be
reviewing)" unless the page shows its versionName, offline = unknown. New release phases `sent` and `released` in
the desktop Releases tab, Portfolio, `release status | plan` and the CLI JSON. Pitfall 56.
- **One row per release across the stores.** Versions are matched without trailing .0 parts (App Store "1.0" and Play
"1.0.0" are one row and one timeline); each side still shows the store's own string.
## 0.8.1 · 2026-10-13
`apple build` exports each build into its own folder and fails loudly on any export error; `apple upload` refuses a
stale IPA whose build number is already on App Store Connect; xcodebuild runs with Apple's tools first on PATH (a
Homebrew rsync broke exportArchive). The Play feature graphic keeps the whole phone on the canvas.
- **Feature graphic fits the phone.** The Play feature graphic (1024 x 500) cut the phone off at the bottom (the game
template's 7 deg phone spanned about y 24..552). `shots.feature.fit` 'contain' (default) sizes the device so its
rotated outline stays inside with a 24 px margin, in both templates; 'bleed' keeps the old crop on purpose;
`shots.feature.scale` (0.5-1.5) resizes it; the config is validated. `shots render` records each device's measured
box in report.json and `shots check` fails a feature graphic whose device leaves the canvas (warns for a
screenshot's front device off a side or the top). Pitfall 55.
- `apple build` exports each build into `build/ios/export/-/` (cleaned first), runs xcodebuild with
Apple's tool folders first on PATH (a Homebrew rsync broke Xcode's IPA step: "exportArchive Copy failed"), shows the
IDEDistribution log's real error lines and exits non-zero on any export failure (pitfalls 53, 54).
- `apple upload` fails with exit 1 when the IPA's build number is already on App Store Connect (a stale IPA after a
failed build) instead of reporting it VALID; `--allow-existing` re-checks, `--build N` picks that build's IPA. The
release and beta flows use both.
- `storebound doctor` flags a non-Apple rsync first on PATH; desktop publish calls rsync by absolute path without
`--chmod`.
- Site: the download notice says per OS: macOS signed and notarized, Windows unsigned for now (SmartScreen: More info ›
Run anyway), Linux unsigned.
## 0.8.0 · 2026-10-13
One product: the CLI and the desktop app share one version, the store accounts and the app folder list, and
`npx storebound desktop` installs and opens the app. `storebound onboard` brings live apps in without questions the
stores and the code already answer. Consoles without an API are driven by the agent (`console plan` / `verify`).
Plus many fixes from real releases.
- **One version.** The desktop app has the CLI's version from now on; the next release is 0.8.0 for both.
`package.json`'s `version` is the authority (`desktopVersion` is gone); `tools/desktop-version.ts` keeps the plugin
manifests and the app's copies equal (CI, and `tests/desktop-release.test.ts` / `src-tauri/tests/d5.rs` as the
drift tests), `desktop-release.ts publish` refuses a folder of another version, and the release order is the app
first, then `npm publish`. The desktop 0.1.0 release candidate was never published, so nothing public changes.
- **`npx storebound desktop` just works.** The app has this CLI's version: it opens on the current folder. Not
installed: it downloads the app for this system (the CLI's own version, else the newest published), checks size,
SHA-256 and the minisign signature, installs it for this user without admin rights (macOS: from the read-only DMG
into /Applications or ~/Applications, no xattr changes; Windows: the per-user NSIS installer, silent; Linux: the
AppImage in ~/.local/bin with a `.desktop` entry and the `storebound://` handler) and opens it. An older app is
updated the same way (never while it runs), a newer one is opened with a note (downgrade only with `--force`). It
asks once (`[Y/n]`); `--yes` skips the question; without a terminal and `--yes` it prints the command and exits 3.
`--dry` / `--json` change nothing, `--download` only saves the checked installer. The publish script also writes
`desktop/dl//latest.json` (redeploy `tools/vds/storebound-desktop-publish` on the server).
- **Store accounts, one store.** Credential profiles in `~/.storebound/credentials` are shared by the CLI and the
desktop app: the app writes its keys there (files 0600, folders 0700) and shows the profiles the CLI made; accounts
of earlier desktop builds move from the OS keychain into profiles on first start (ids, studios and app mappings
kept; the keychain entry is deleted after the profile reads back). A removed account's key file goes to
`credentials/.removed/`. Precedence: env, then a profile the project names, then the project's
`.storebound/credentials.json`, then the default profile (the only one, or `default`). A project names its profile
with `account:` in the config, `"account"` in its `.storebound/credentials.json`, or `--account`.
- **`storebound accounts`** lists the profiles (masked) and which one this folder uses; `accounts import` moves the
store keys of a project's `.storebound/credentials.json` into a profile (key files copied to
`~/.storebound/credentials//`, the same Apple key id or service account e-mail = the same profile, the
keystore and review contact stay in the project, a backup of the project file is kept, safe to run again; `--dry`
first, never prints values); `accounts use ` points a project at one.
- **App folders, one list.** `~/.storebound/projects.json` is the desktop's App folders and the CLI's
`storebound apps folders | add | remove`; `storebound desktop` in an app folder and `ws init` add to it, and `ws`
without a workspace file works over it. The app syncs it at start, when the list is shown and before a
`storebound://open` link, so a folder added in the terminal is selected without a question.
- **Desktop:** a folder whose `package.json` pins another storebound version shows "This project uses storebound X,
the app runs Y". `storebound-desktop --version`. `ws desktop import / export` carry the accounts' profile names.
Onboarding apps that are already live, without owner questions for what the stores and the code prove (lessons
43-52, from setting storebound up on 6 live apps):
- **`storebound onboard [--write]`** (MCP `onboard`, dry run by default). Read-only on both stores, never changes a
store price. Imports the live one-time products and subscriptions of both stores into `products` / `subscriptions`:
ids, type, listings per language, usd = the US price (else Play's own USD price for new regions), Apple's price
schedule base territory as an override when it is not the USA, an item on one store only kept with `platforms`;
`catalog diff` comes out clean. An item with no USD price anywhere is named with its base price, not left out
silently. Fills app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app details and
App Store Connect where the config holds a template value (real values that differ are reported and kept); the App
Review contact goes into `.storebound/credentials.json` `reviewContact` (masked in output, the phone never printed).
Sign-in for review is decided from Apple's review detail (`demoAccountRequired`; a demo account set there is imported
into `reviewDemoAccount`, never printed) and the code's auth signals, never a default yes. With accounts and no
`accounts.deletionUrl` it looks for the deletion page on the app's own site (/delete-account, /account/delete,
/delete, /account-deletion, links in the privacy policy that mention deletion); not found stays a finding. Then the
owner questions that are left, in one batch.
- **App Store keywords count characters, not bytes.** Live READY_FOR_SALE fields hold Thai 95 characters / 265
bytes, Turkish 100 / 105, Polish 99 / 105: `check`, `check --aso`, `listing check`, `listing translate` and
`aso suggest` count characters again (lesson 4 was wrong; lesson 43).
- **`validate`:** a live app with only the 6.5" iPhone set (APP_IPHONE_65) passes, App Store Connect accepts it for
the 6.9" requirement; a 6.9" set is a recommendation (also the two 13" iPad types). Play: a non-default language
without its own icon / feature graphic uses the default language's (a note), only the default language's missing
graphics block.
- **Play Reporting API off:** the finding names the Cloud project and the exact link to enable
`playdeveloperreporting.googleapis.com` for it (`apps check`, `apps list`, `health`, `validate`); `apps list` no
longer fails but lists the Play apps of this machine's app folders and store-only entries.
- **`storebound accounts default `** (`--clear`): the machine's default credential profile, a pointer file in
the credentials folder, shown in `accounts list`. Order: env > the profile a project names > the project's own keys
> this default > the only profile (or the one named `default`).
- **Store-only apps:** `storebound apps add --store apple|play --app-id --account ` adds an app that is
live but has no project on this machine to `~/.storebound/projects.json` (`storeApps`); `apps folders` shows it as
"no local project", `apps remove apple:` takes it out. The desktop shows it read-only ("Store only": status,
reviews, analytics) under the account with that profile, and keeps the entries when it writes the folder list.
- `ws` children get the app's own account choice when the workspace names no account (before: no profile at all).
Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons 35 to 37):
- Fix (`scan`, review-prompt 3.2.2(x)): a bare "rate" next to "bonus" (`const rate = F.genRate(lv, bonus.gen(r))`) is
no longer a reward for rating. The signal needs a rate-us / leave-a-review phrase next to a reward, a reward "for
rating / reviewing", or a reward granted within a few lines of a system review prompt call.
- Fix (`scan`, account findings and the declarations proposal): import, require and export-from lines are never
sign-in / sign-up / deletion evidence (`import './buildings/register'` was), auth endpoints must be an absolute path,
a URL or a `${base}/...` template, credential fields and guest-account creation count; accounts only the config
claims read "from config (accounts.enabled)".
- Fix (`scan`, 5.1.1(v)): `accounts.inApp` alone no longer clears the finding: without deletion code it stays, medium,
"claimed by config (accounts.inApp), not found in code".
- Fix (`privacy-manifest --propose`): NSPrivacyCollectedDataTypes is built from `appPrivacy.types` (Apple's manifest
keys for type and purposes, linked, tracking); NSPrivacyTracking stays true only with app-level domains. `check`
warns once when the app manifest's list is empty but `appPrivacy.types` marks types collected.
- Fix (`console plan apple-app-privacy`): `appPrivacy.types` is authoritative: a type it does not list is "not
collected" (certain), never derived from dataSafety or asked. Without it, `appPrivacy.tracking: false` and no ad /
attribution SDK answer Advertising Data, Customer Support, Environment Scanning, Hands and Head "not collected"
unless a support SDK or AR code is found. `console plan` scans the project for these signals.
- Fix (`console plan play-advertising-id` / `play-app-content`): "Does your app use advertising ID?" comes from ad /
attribution SDKs, the AD_ID permission in the source or merged manifest (or its `tools:node="remove"`),
`ads.appAdsTxt` or `console.appContent.advertisingId`; a Data safety Device ID alone is not the advertising ID. No
signal: No (certain); signals that disagree: asked.
- `scan.ignore[].evidence`: accept a finding by the text of an evidence line (`match` keeps matching the title).
`scan` and `check` name every entry that matches no finding.
- Fix (`init`): `.storebound/credentials.json` starts with empty strings only. Placeholder-looking values
("your-…", `<…>`, "xxx", "/absolute/path/to/…", "example") count as missing in every credential read; `check`
warns about them and `doctor` lists each credential as set, missing or placeholder.
Console facts from a second real game (2026-10-13): the owner now comes back once for Play's App content instead of
once per card.
- Play App content card Saves are the agent's: saving a declaration (privacy policy, ads, app access, advertising ID,
government, financial, health, target audience, the content rating questionnaire and Summary, data safety) is not
public; Google gets it only with the next "Send changes for review" in Publishing overview, which stays the
owner's. New button class `declaration-save`; `declarationSaveAllowed` lets exactly Save / Kaydet through on an App
content card page; `publishingControl` refuses "Send changes for review", "Submit N changes for review", Submit,
Publish and "Restart review" everywhere. The plans' final button is "Send changes for review"; `console fill`
presses the content rating Summary's and the target audience's Save itself.
- `console plan` prints an owner batch first (`ownerBatch`): every owner step of the plan in ONE list, in the order the
agent needs them (open answers, the IARC Terms of Use box with its exact place before the run, Send changes for
review after). `console plan play-app-content --all` (MCP `all: true`) walks every card inline in order and ends
with that one owner list.
- IARC questionnaire: page 1's Next does nothing until the IARC Terms of Use box is ticked (an owner-only agreement).
It is in the owner batch before the run; `console fill` waits for the tick instead of pressing a dead Next.
- Sign in details: with `reviewDemoAccount` in `.storebound/credentials.json` the agent types the demo account
(never printed); only without it is it the owner's.
- New App (App Store Connect): a programmatic value set on Name and SKU left the dialog's state empty; the plan says
"type, don't set value" and `console fill` types every text field key by key.
- `play.appId` captured automatically: `console verify play-app-record --url ` reads the id from
the redirect and shows the `play.appId` change as a dry diff; `--write` upserts it into the config. New read-only
`storebound play app-id`: finds the app by package name in captured app list pages (10 per page, a new app is not
on page 1), then the same dry diff and `--write`.
- Pitfalls 38-41.
TestFlight distribute from real use (2026-10-13, pitfall 42):
- Fix (`apple testflight distribute`): it failed with 400 right after printing the build state, because App Store
Connect refuses `GET /v1/betaGroups?filter[builds]=` although Apple's spec declares the filter (and the spec has no
GET of a build's groups). A build's groups are now read per target group with
`GET /v1/betaGroups/{id}/relationships/builds`. An internal group with all builds is reported as "already
available to (internal, all builds)" and never sent the build (App Store Connect refuses that); internal
groups without all builds and external groups get it through the relationship; a re-run adds nothing.
- Fix (`apple testflight distribute`, `testers`): a group in `apple.testflight.groups` that is not on App Store
Connect yet says so and names `storebound apple testflight groups sync`; a group in neither lists the groups that
exist (was `unknown group "Team" ()`).
- Guard: `tests/asc-query-guard.test.ts` walks every App Store Connect GET with a query that `cli/` builds (template
literals and the URLSearchParams they interpolate) and fails offline on a path that is no declared GET, a query
parameter the operation does not declare in `cli/api/apple.gen.ts`, or one in the new `APPLE_LIVE_REJECTED` list
(declared, refused live). `storebound api apple` refuses those parameters too. The fake App Store Connect in the
tests answers 400 PARAMETER_ERROR.ILLEGAL to such parameters, as the live API does.
- **Releases on git.crt.fyi come from a workflow.** A `v*` tag push runs `.forgejo/workflows/release.yml`: version
checks, the full CI, the Linux and Windows installers, the privacy scan, then one Forgejo release with short notes
(the CHANGELOG summary, install line, links), the installers and SHA256SUMS; older release entries are deleted
(tags stay). The Mac adds the notarized DMG with `desktop-release.ts upload-forgejo`. A dry run (workflow_dispatch)
makes a draft `test-` and deletes it. `desktop.yml` is folded into release.yml; ci.yml and release.yml share
their setup (`.forgejo/actions/`).
## 0.7.1 · 2026-10-13
- Fix: README links and images on npmjs.com resolved against npmjs.com and gave 404 (`docs/README.md`, the logo).
Every README link and image is now absolute (git.crt.fyi), and a test keeps it that way.
## 0.7.0 · 2026-10-13
Agent-first: the AI does the whole job ("do it, don't ask") and the owner presses only the final public buttons, with
every compliance question asked once, in one batch. Consoles without an API get `console plan` / `console verify`
(start URL, steps, answer sheet, never-press list) for App Privacy, Play App content, IARC, Create app and the first
production release. The desktop app sends for review itself (dry-run plan first). The CLI runs on Node 20+
(`npx storebound`). Plus many store fixes from real releases (IAP resubmits, Invalid Binary, Data safety, uploads).
- Fix (`check`): "N Play App content answer(s) are not in the config" no longer counts the two steps that stay the
owner's however complete the config is (ticking the IARC Terms of Use, typing the demo account's username and
password), so a fully answered project checks clean. The site's `check` shot (`tools/site-term-shots.ts`) now shows
that ready project: a fresh `init` plus icon files, `apple.name`, the App content answers and the demo account.
- The CLI runs on Node 20+ (owner decision, 2026-10-13): `npx storebound init` works, `bunx storebound` keeps working.
The npm package's `bin` is one Node ESM bundle, `dist/storebound.mjs` (built by `prepack` with
`bun tools/build-node.ts`, zero runtime dependencies, playwright still an optional peer); `engines` names Node 20.
Everything the CLI needs from its runtime goes through `cli/lib/rt.ts` (spawn / spawnSync with Bun's defaults and
exit codes, which with PATHEXT, sleep, a small HTTP server for shots render / video, glob, hashing, gunzip, stdin,
SQLite, the package folder); templates, the shots pages and Play's Data safety form are embedded by
`tools/gen-embedded.ts` instead of Bun text imports, so the bundle and the compiled desktop sidecar carry them.
`storebound.config.ts` loads on Node too (Node's own type stripping, or a fallback on Node 20). `storebound install`
run with Node writes `npx` MCP commands. `bun run ci` builds the bundle and smoke-tests it with the real `node`
(`--version`, `help`, `check` same as Bun, `scan`, `release plan --help`, `init`, an MCP handshake and tool call);
`tests/node-compat.test.ts` fails on any direct Bun API use in `cli/`. `bin/storebound` falls back to Node when Bun
is not installed. Development, tests and CI stay on Bun.
- `console verify apple-app-privacy` checked against real page text (2026-10-12): the summary sections ("Data Used to
Track You" and the others) list category names only and are skipped; a type's lines may come joined by " | "; Apple's
comma style ("X, and Y") and the straight apostrophe parse. The tracking radio label of a type that already ends in
"Data" no longer reads "crash data data". The Play Data safety page path (`app-content/data-privacy-security`, with
Export / Import CSV) is confirmed.
- Fix (`play build` / `check` versionCode source warning): a variable set from the property first
(`def code = (project.findProperty('versionCode') ?: ...).toInteger()`, then `versionCode code`; also `ext.` and Kotlin
`val`) is followed to its definition and no longer flagged.
- Fix (`apple iap-submit`, `apple resubmit`): after the owner canceled a submission, every IAP version was
DEVELOPER_REJECTED and the plan skipped them all ("change it in App Store Connect (or --new-version)", then
"nothing to submit"). Apple accepts the same version again (201 READY_FOR_REVIEW, then the submit 200), so
DEVELOPER_REJECTED in-app purchase, subscription and subscription group versions now go in as they are, listed as
"add (developer rejected, re-submitted as is)"; an empty draft is reused. `--new-version` is only for APPROVED or
REJECTED products (pitfall 31).
- `play build` and `check`: warn before Gradle runs when the app module's `defaultConfig` does not read
`-PversionCode` / `-PversionName` (a literal, an env var, ...), naming the file and line and the exact Groovy or
Kotlin line to use (both built with AGP 8.13). `android.injected.version.code` does not override `defaultConfig`
for `bundleRelease` (checked with AGP 8.13); the docs no longer suggest it. An AAB with another versionCode than
planned is now a hard stop (it was a warning) and is not copied (pitfall 32).
- Console plan `play-production-release`: the "Do you want to restart your review?" dialog that "Submit N changes
for review" opens while a review is running is a known step; "Restart review" is the owner's button and in
neverPress (pitfall 33).
- Desktop: send for review from the window (owner decision, 2026-10-12). New owner actions (desktop only, never MCP
tools): `owner.apple_submit` (new `storebound apple submit --build [--version x.y.z]`: the steps of `apple
resubmit` for a first submission: build n attached by number, the open review submission reused, the ready IAPs /
subscriptions with it, submitted with the "not ready yet" retry; plan table without `--yes`), `owner.apple_resubmit`
(`apple resubmit --build [--resolve-iaps]`), `owner.apple_iap_submit` (`apple iap-submit [--with-version]`) and
`owner.play_production` (`play release --version-code --production completed | inProgress | draft`). Each is a
dry run from the window; the real run needs the host's token minted after the native dialog, and only then serve
adds `--yes` (apple submit / resubmit) and answers the command's question. `OWNER_ALLOWED_ARGS` ties every
otherwise forbidden flag (`--yes`, `submit`, `--production`, `--csv`, `--allow-price-changes`) to its one owner
action, a dry run never carries an owner flag, and the MCP server still has no submit, resubmit or production
release (`tests/serve.test.ts`, `tests/desktop-d3.test.ts`, `src-tauri/tests/d3_writes.rs`). The submit dialog says
the app goes to review and may go live by itself once approved.
- Releases tab, "Send for review": the App Store submission state from `apple status` (waiting / in review,
UNRESOLVED_ISSUES with its items and which ones get marked resolved, empty drafts labelled harmless), the uploaded
builds to pick by number (processing ones disabled, nothing preselected), Submit for review, Resubmit (with
"also mark rejected IAP items resolved") and Submit in-app purchases (with the editable version); Google Play
versionCodes by number (one already live cannot be picked), Send to production as everyone, a staged share or a
draft (a never-published app), the managed-publishing warning from `play.managedPublishing`, and the console plans
of production countries and "Send changes for review" (no API) with a deep link. Every button shows the plan (dry
run, `--json` with the plan table under serve) in the window first, then the native dialog; the real run's
progress lines stream into the panel and Stop cancels it. The old "submit stays in the console" note is gone.
- App info, "Console steps without an API": every `console plan` form (the new Play store settings category, App
content and its cards, production countries and production release included) with a plan panel (start page and
deep link, steps, answer sheet, never-press buttons, the owner's final button, Copy plan for an AI agent) and the
read-only `console verify` (page text pasted, or the API for the App Store record and age rating). The Play forms
card is folded into it.
- Price gate: `owner.prices_import` passes `--allow-price-changes` itself after the dialog; new
`owner.prices_play_products`, `owner.prices_play_subscriptions`, `owner.prices_apple_subscriptions` (Catalog:
"Price changes") run that sync with the flag. A write that ends with exit 3 (declined: a held price change) shows "Waiting for your
decision", not a failure.
- Data safety card: the deletion question as the CSV answers it (`dataDeletion`, from `accounts.deletionUrl`,
`dataSafety.dataDeletion`, ...; `project.declarations` sends `dataDeletion`, `dataDeletionSource`,
`dataDeletionUrl`), older sidecars still shown from `partialDeletion`.
- TestFlight: "Create missing groups" (`apple testflight groups sync`, dry run first). Dependencies: the Android SDK
as `play build` finds it (`system.tools` `androidSdk`). `project.inspect` adds `playRelease` (managed publishing,
countries, category). Job toasts: upload lines ("312/890 MB (35%), part 4/12") drive the progress bar, and the
last 40 lines are in the toast's log.
- Covered by the existing views: `validate` findings ("production has 0 countries", the category reminder), `check`
warnings (stale web assets without `build.prebuild`, Play category, App content answers owed, privacy manifest
tracking mismatch ITMS-91064, the defaultConfig versionCode / versionName source warning) and `scan` findings
show in Checks; `play images` skipped languages and optional tablet sets show in the dry run of the native dialog.
- Boot check: four send-for-review cases (ready and UNRESOLVED_ISSUES, on the old D1 and the new D3 saved state)
with store answers from `src/dev/submit-fixtures.ts`: the Releases texts, the build and versionCode pickers, the
plan panels, a console plan from the real sidecar, every app tab and Dependencies, by DOM text; the dialog is
answered Cancel, nothing is sent.
- Safety (`apple testflight groups`): run bare, it created the configured beta groups right away, with no plan. Now it only lists the groups and prints `would create "Team", "Friends"`. Creating is
`groups sync` (or `groups --yes`). Flows treat bare `groups` as a read and `groups sync` as a write, and the MCP tool
runs `groups sync` (dry by default).
- Fix (`apple resubmit`, `apple iap-submit`): after an Invalid Binary rejection the submit kept answering 409 "Version
is not ready to be submitted yet" for 25 minutes because the app version item of the UNRESOLVED_ISSUES submission
was still REJECTED (pitfall 30). REJECTED items are now marked resolved before the submit
(`PATCH /v1/reviewSubmissionItems/{id}` `resolved: true`): the app version item in `apple resubmit` (a fixed build
goes in), in-app purchase / subscription items only with the new `--resolve-iaps`. The plan table shows "mark
resolved" rows. On "not ready" the items are read again: one in scope is resolved, one out of scope stops the run
at once with the item and how to resolve it; backoff retries only when nothing is left REJECTED.
- Fix: an empty READY_FOR_REVIEW draft cannot be canceled (409 "Resource is not in cancellable state"). A draft the
run created and could not use is left as is, stray empty drafts get no cancel command, `apple status` labels them
"empty draft (harmless; App Store Connect keeps it, not cancellable)" and `--cancel-submission` treats that 409 as
harmless. `apple status` marks REJECTED items as not resolved.
- Fix (`apple iap-submit`): resubmitting after a rejection created a second, empty draft next to the rejected
submission and stopped with a wrong "list lag" message (pitfall 30). The open review submissions are read first
(`GET /v1/apps/{id}/reviewSubmissions`, READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW, IN_REVIEW, with
their items): one with UNRESOLVED_ISSUES is reused (only missing items added, then resubmitted), a draft is reused,
one waiting for or in review means nothing is sent; a new draft only when none is open. 409
`ITEM_PART_OF_ANOTHER_SUBMISSION` is reported with the other submission's id and that one is used (a draft the run
created is canceled again); it is never read as a duplicate. The submit is retried with backoff and heartbeat lines
while Apple answers "Version is not ready to be submitted yet" (up to 10 minutes).
- New `storebound apple resubmit --build [--version x.y.z]`: attaches build n (by number, VALID) to the rejected
version, reuses the UNRESOLVED_ISSUES submission, adds missing IAP / subscription items and resubmits with the
retry. A plan table without `--yes` (the owner's step); never in MCP or flows. `apple status` lists an
UNRESOLVED_ISSUES submission with its items and their states and prints the resubmit command.
- ITMS-91064 (pitfall 29): a real build was refused as Invalid Binary a minute after the upload because the app's
PrivacyInfo.xcprivacy had NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (tracking came from an ad
SDK whose own manifest declared it). Rule in every manifest: true needs a non-empty list, a non-empty list needs
true; `check`, `scan` and `privacy-manifest` fail on either mismatch with the exact ITMS text. App Privacy
"used for tracking" no longer requires the app manifest's true: `check` finds SDK manifests that declare tracking
(Pods, frameworks, SwiftPM checkouts, Carthage, Capacitor plugins, the last built IPA) and says so as info, names
known tracking SDKs from the dependencies, and only warns when nothing declares it. `privacy-manifest --propose
[--tracking-domains]` prints the app manifest with true only for `appPrivacy.trackingDomains` (the app's own
domains); the scan proposal says false for SDK tracking. `apple upload` reads every manifest in the IPA (app root,
Frameworks, bundles; XML or binary plists) and stops before any network call; `apple build` checks the export.
- Play console steps without an API, from a real first release (2026-10-12; pitfalls 27 and 28). New agent-only
`console plan` / `console verify` forms (also in the MCP `console_plan` / `console_verify` lists):
`play-store-settings` (the App category: `play listing` cannot set it, so a new app showed "Not selected"; the
first Edit > App or game > the Category `[role=option]` > Save), `play-app-content` (every card of
`/app-content/overview` in order with its page, its "Start declaration" button, its plan and one batch of
owner questions) and the cards `play-privacy-policy`, `play-ads`, `play-sign-in-details` (the demo account is
typed by the owner, never printed), `play-advertising-id`, `play-government-apps`, `play-financial-features`,
`play-health-apps`; `play-production-countries` (edits.countryAvailability is read-only) and
`play-production-release` (Edit release > Next > Save; the owner's "Send changes for review", a real click).
New config: `play.category` (checked against Play's game / app lists; default from an exact App Store category
match), `play.countries`, `play.managedPublishing`, `console.contentRating.details` (the IARC follow-ups each Yes
reveals), `console.appContent`, `social`, `products[].randomRewards` (loot boxes Yes / No from the catalog).
`check` warns without a Play category and counts the App content answers still owed; `validate` adds the category
reminder and "production has 0 countries"; `play status` shows the category as a console step; `play release`
warns that an approved production release goes live by itself while managed publishing is off.
- IARC content rating plan: page 1's IARC Terms of Use box is an agreement (never pressed, never ticked: the click
gate now refuses "terms of use"); the questionnaire is one long page whose Yes answers reveal follow-ups; the
Summary's final button is "Save", not "Submit"; App content forms deep-link to their card pages.
- Every console plan ships `inPageLoop`, a ready-made in-page helper (window.__sb.run, polled through window.__log):
one evaluate call times out at about 45 s on big Play pages. Play plans and `console fill` click the inner
`[role=checkbox]` of material checkboxes like the material radios.
- App Store: appstore-first-release and store-iap-setup send first IAPs with `apple iap-submit --with-version`
(verified on a real first release), not the console; `console verify apple-app-privacy` reads "Published … ago
by …" as the published signal.
- Fix (`play data-safety`): `PSL_SUPPORT_DATA_DELETION_BY_USER` is Play's general "Do you provide a way for users to
request that their data is deleted?". storebound answered it from `partialDeletion` (data deletion without deleting
the account), so an app with a delete-account page declared "no". Now `accounts.deletionUrl` answers yes, with
`PSL_DATA_DELETION_URL` set to `partialDeletionUrl` or the delete-account page. Without a page, the answer comes
from the new `dataSafety.dataDeletion: 'yes' | 'no' | 'auto90'`, or else from `deletionRequest` (the same question
under the form's old id, no longer deprecated). `check` flags any answer other than yes next to a delete-account
URL.
- Price changes of existing products need `--allow-price-changes` (`play products`, `play subscriptions`,
`apple subscriptions`, `prices import`; `apple iap` never changes an existing price). `--yes`, also the one
`flow run --apply --yes` adds to write steps, confirms creates, first prices and other updates only. Without the
flag a run writes everything else, prints `price change on ( -> ) needs the owner: re-run with
--allow-price-changes` per product and exits 3 (declined), so a flow stops with "step N declined". The terminal
question (no `--yes`) and the desktop owner action still cover them. Dry runs mark those rows `owner`; flows refuse
the flag in steps and never add it (lib/price-gate.ts, tests/price-gate.test.ts). Activating the purchase option
of an existing Play product is a normal update now (`--yes` confirms it).
- CLI wording: when `console fill` cannot open or fill part of a form (console UI changed, an unknown page or dialog
step), it says what is left and that the calling agent does it with its browser tool (`storebound console plan