Machine-translated App Store + Google Play listing metadata for every language
in src/i18n/SUPPORTED_LANGUAGES.
_schema.json— JSON Schema for one<locale>.jsonfile._source-apple.md,_source-play.md— the copies extracted fromdocs/play-launch/app-store-submission-copy.md+docs/play-launch/store-listing.md(archived here for provenance).en.json— source of truth. Any edit to store copy starts here.- 43 sibling
<locale>.jsonfiles — machine-translated fromen.json. ../scripts/upload-store-listings.mjs— uploads all of the above to Apple + Google via their publishing APIs, exceptplay.releaseNotes.../scripts/play-release-notes.mjs— emitsplay.releaseNotesas thewhatsnew-<play-lang>file format thatr0adkll/upload-google-playreads.
apple.whatsNew and play.releaseNotes say the same thing and are not
interchangeable:
| Apple | Play | |
|---|---|---|
| cap | 4000 chars | 500 chars |
| attaches to | the app store version | a track release, not the listing |
| uploaded by | upload-store-listings.mjs |
nothing yet — see below |
Play's 500-character cap is the reason play.releaseNotes is its own shorter
copy rather than a reuse of apple.whatsNew. Pasting the Apple string across
would fit Apple's cap and blow Play's, so
src/__tests__/play-release-notes.test.js pins that the two are never equal,
alongside the cap itself.
Because Play attaches release notes to a track release (edits.tracks →
releases[].releaseNotes) rather than to a listing, uploading them needs a
target track and versionCode. On this repo the Play upload in ci.yml already
owns the release, so play-release-notes.mjs only writes files:
node scripts/play-release-notes.mjs --check # validate, write nothing
node scripts/play-release-notes.mjs # write dist-whatsnew/Nothing consumes the output yet, deliberately. The CI upload step carries an
explicit comment that it sends no release notes because closed testers see the
versionCode; switching that on changes what every tester and user is shown on
each release, which is an owner decision. To wire it, run the script before the
upload step and pass whatsNewDirectory: dist-whatsnew.
| Reviewed (reviewed: true) | 1 |
| Unreviewed (reviewed: false) | 43 |
This was 6 reviewed / 38 unreviewed until 2026-09-19 (the table said 5 while
listing 6 languages — that count was wrong before this edit, not changed by it).
The reviewed set was en, pt-BR, es-419, es, fr, it — the same set
whose in-app security.json was native-reviewer-approved in PR #1507.
apple.whatsNew was rewritten for the 1.0.2 release and machine-translated into
all 43 siblings, so the five non-English reviewed locales now contain one string
no native reviewer has seen. reviewed is file-level and --require-reviewed
gates on it, so leaving those five at true would have uploaded unvetted copy
under a reviewed banner. They were flipped to false. Each one's notes records
that its other four Apple fields are unchanged and still vouched for, so a
re-review only has to look at whatsNew.
en stays true: it is the source every sibling is translated from, so there is
no translation fidelity to vouch for.
The flag doesn't change what gets uploaded by default, but the upload script
supports --require-reviewed if you want to gate strictly.
The script authenticates with two things:
- App Store Connect: an API key
.p8file - Google Play Publishing: a service-account JSON
Both live outside this repo. Set env vars and run:
export ASC_KEY_ID=XXXXXXXXXX
export ASC_ISSUER_ID=2d4c5bd7-1de3-4953-b203-a92e788c2d7c
export ASC_KEY_PATH=~/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8
export ASC_APP_ID=<numeric-app-id-from-App-Store-Connect>
export PLAY_PACKAGE_NAME=<package-name-e.g.-io.veyrnox.wallet>
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/play-publishing-sa.json
# Dry-run — prints what would happen, doesn't touch stores
node scripts/upload-store-listings.mjs
# Restrict to specific locales (comma-separated)
node scripts/upload-store-listings.mjs --only=pt-BR,es-419,fr
# Only upload reviewed languages
node scripts/upload-store-listings.mjs --require-reviewed
# For real (uploads)
node scripts/upload-store-listings.mjs --live
# Apple only / Play only
node scripts/upload-store-listings.mjs --live --apple-only
node scripts/upload-store-listings.mjs --live --play-onlyThe script fails LOUDLY on character-limit overflow before touching any API — if a field is 1 char over its limit, the store would reject the whole submission, so it's better to catch here.
- Screenshots. Every locale needs its own set of screenshots (5 × iPhone,
optionally 5 × iPad on Apple; 2–8 × phone, 2–8 × 7-inch tablet, 2–8 ×
10-inch tablet on Play). Not automated here. Follow-up: screenshot capture
workflow using Fastlane
snapshot(iOS) +screengrab(Android), driving the app in a device farm per-locale. - In-App Purchase / Subscription copy. Safety Plus subscription localisation is separate — App Store Connect → In-App Purchases → Safety Plus → Localizations, and Play Console → Monetize → Products → Subscriptions → each base plan. Follow-up if the current $5.99/month + $49.99/year Store-side copy needs per-language translation.
- App Review Notes. Currently English-only (see
_source-apple.mdsection). Reviewers work in English on both stores, so this is fine.
When a native speaker approves a <locale>.json for shipping:
- In
<locale>.json, set"reviewed": true. - Log the reviewer + date in the file's
notesfield. - If they edited any values, the diff is the review.
No separate approval file — the JSON is self-documenting.
Apple's tight fields are subtitle (30), keywords (100). Play's tight
field is shortDescription (80). Every <locale>.json in this directory
has been checked against the schema-defined limits; the upload script re-checks
before every run.