store-publish — one JSON, both stores
Takes a mobile app (Flutter, native iOS/Android, React Native, Expo…) from "nothing in the consoles" to
"build attached, ready to submit" on App Store Connect and Google Play, with the human touching only
the few buttons that have no API. Everything runs through the two official REST APIs with curl,
python3 and openssl — no fastlane, Ruby, PyJWT or Google SDKs.
$SKILL below = the folder containing this file (Claude Code ~/.claude/skills/store-publish, Codex ~/.agents/skills/store-publish or <repo>/.agents/skills/store-publish, invoked as $store-publish). Scripts are in $SKILL/scripts/:
| Script |
Role |
publish.py |
Front door: validate · check · listing · build · ship · status · submit, runs both stores |
asc_publish.py |
App Store Connect: bundle-id · wait-app · app-info · version · screenshots · age-rating · price-free · review-details · all · wait-build · attach-build · release · submit · status |
play_publish.py |
Google Play: check · details · listings · images · all · upload · promote · status |
build_ios.sh / build_android.sh |
Archive + upload the IPA with the API key / build the release AAB |
asc_token.py / play_token.py / asc.sh / play.sh |
Tokens and one-off API calls |
common.py |
Metadata loader, validation, locale mapping (ko↔ko-KR, zh-Hans↔zh-CN …) |
Metadata schema: $SKILL/templates/store-metadata.example.json. API cheat sheets and every gotcha we
hit shipping real apps: $SKILL/references/asc.md, $SKILL/references/play.md, $SKILL/references/locales.md.
Hard rules
- Reply in the user's language. Store texts go in whatever locales the metadata lists.
- Secrets never leave disk. Never print, paste or commit the
.p8 key, the service-account JSON,
bearer tokens or demo passwords. Key IDs / issuer IDs / service-account emails are identifiers, fine to show.
- Confirm before the four outward-facing actions: uploading a build (build numbers / version codes are
permanent), releasing to a Play track other than
internal, submit (App Review), and promote --to production.
Everything else is reversible metadata — do it without asking.
- Read before write, re-run safe. Every step GETs the current state and PATCHes/POSTs accordingly.
Prefer
--dry-run on the first pass: iOS prints the writes, Android lets Google validate the edit and discards it.
- Never stop on a failed step. Record the raw API error (it usually lists the allowed values), keep going,
and finish with the ✅/❌ summary table the scripts print.
- "Done" means verified. Finish with
publish.py status (re-reads both stores) — never report success from
write responses alone.
- Ask with options, not open questions. In Claude Code use
AskUserQuestion (multi-select when several
apply, recommended option first). In runtimes without it (Codex CLI, others) print a numbered list, mark the
recommended one, and wait. Don't ask what the project or the API can tell you.
- Don't invent store copy silently. If the project has no listing text, draft it (benefit first, ≤ 30-char
names, ≤ 80-char Play short description, ≤ 100-char keywords, no
♥ ★) and show the table before pushing.
Workflow
0. Credentials (once per machine)
- App Store Connect:
ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH or ~/.appstoreconnect/config.json
{"keyId","issuerId","keyPath"}. Needs an App Manager (or Admin) key: ASC → Users and Access →
Integrations → App Store Connect API. Test: $SKILL/scripts/asc.sh GET /v1/apps → HTTP 200.
- Google Play:
PLAY_SA_JSON or ~/.config/play/service-account.json. Create a service account in any
Google Cloud project, then Play Console → Users and permissions → Invite new users with the service
account email → grant the app with Release + Store presence permissions. The app itself must already exist
in Play Console (no API creates it). Test: python3 $SKILL/scripts/play_publish.py check --package com.x.app.
- If either is missing, ask for it once (options: "I have the key → path", "Help me create one", "Skip this store")
and offer to save it with
chmod 600.
1. Metadata file
Create store-metadata.json in the project (copy the template). Fill it from what the project already has:
existing listing docs, README, Info.plist / pubspec.yaml / build.gradle (name, ids, version), privacy
policy and support URLs, screenshot folders (the store-screenshots skill produces store-size PNGs; put the
Play feature graphic 1024×500 and icon 512×512 there too). Locale keys can be App Store style (ko, ja,
zh-Hans) or Play style (ko-KR, ja-JP, zh-CN) — they are mapped both ways.
python3 $SKILL/scripts/publish.py validate --meta store-metadata.json
Fix everything it lists (lengths, symbols, wrong screenshot sizes, missing files) before touching the APIs.
2. First release — the human-only steps first
| Store |
Human clicks (no API) |
Then the skill |
| App Store |
New App (My Apps → +): iOS · name · primary language · bundle ID · SKU |
asc_publish.py bundle-id first so the bundle ID is in the dropdown; wait-app polls until it exists |
| App Store |
App Privacy questionnaire (before submit) |
everything else |
| Google Play |
Create app in Play Console, invite the service account |
play_publish.py check |
| Google Play |
App content declarations: privacy policy URL, ads, data safety, content rating, target audience, news/government/financial flags, permission declarations; testers list |
everything else |
Give the user the exact values to type (from the metadata) and tell them which page. Then:
python3 $SKILL/scripts/publish.py listing --meta store-metadata.json --dry-run # look, fix, then
python3 $SKILL/scripts/publish.py listing --meta store-metadata.json
iOS runs app-info → version → screenshots → age-rating → price-free → review-details.
Android runs details → listings → images inside one edit and commits it.
3. Build and ship
Make sure the version in pubspec.yaml / Xcode / Gradle matches app.version, Info.plist has
ITSAppUsesNonExemptEncryption=false, and the Android release signing is configured
(android/key.properties or signingConfigs). Ask, then:
python3 $SKILL/scripts/publish.py build --meta store-metadata.json # iOS archive + upload · Android AAB
python3 $SKILL/scripts/publish.py ship --meta store-metadata.json --track internal
ship waits for Apple to process the build and attaches it to the editable version; on Android it uploads
the AAB to the track with What's New from every locale and commits.
4. Updates (already-published app)
Bump app.version (and the build number / versionCode), write whatsNew in every locale, build, then:
python3 $SKILL/scripts/publish.py ship --meta store-metadata.json --release --build-number 18 --track internal
iOS creates the new version, copies the previous texts, sets What's New, uploads screenshots only if the
version has none, copies review details, waits for the build and attaches it. --submit also files the
review submission. Android uploads the AAB as before; submit --to production --rollout 0.2 promotes it.
5. Verify and hand over
python3 $SKILL/scripts/publish.py status --meta store-metadata.json
Report the table, then list what only the human can do:
- App Store: App Privacy (first version) →
publish.py submit --ios (or the Submit button).
- Google Play: App content declarations and testers → production rollout (
publish.py submit --android,
or the button). New personal developer accounts (after Nov 2023) need a closed test with 12 testers for
14 days before production is unlocked.
When something is off
| Symptom |
Meaning / fix |
ASC HTTP 401 |
token expired or wrong key — delete $TMPDIR/asc.token and retry |
ASC 409 adding a locale |
the app info / version is already released — create the next version first (release), locales go on the editable copy |
ASC whatsNew / marketingUrl "cannot be edited" |
locked on the first version / on released versions — the script retries without them |
| Screenshot set rejected |
error lists valid screenshotDisplayTypes; validate maps sizes → types |
| Build never appears |
check the upload log for Upload succeeded; processing 2–10 min; INVALID → the reason arrives by e-mail (ITMS-xxxx) |
Play 403 |
service account not invited / not granted this app / API not enabled in its Cloud project |
Play 404 on edits |
package not in this developer account — create the app in Play Console |
| Play commit refused |
the message lists the missing console declarations; the script retries with changesNotSentForReview=true when Google asks for it |
| Play "version code already used" |
bump versionCode and rebuild |
| Testers see "App not available" |
first closed-test release is still in Google review, tester account mismatch, or internal + closed test joined at once |
1---2name: store-publish3description: Ship a mobile app to BOTH stores from one metadata file — App Store Connect and Google Play. Fills app info, descriptions, keywords and What's New in every locale, uploads screenshots and graphics, sets age rating, price and review details, builds and uploads the iOS archive with an API key (no Xcode login) and the Android AAB with a service account, attaches builds, releases to Play tracks, handles version updates, and optionally submits for review / promotes to production. Plain curl + python3 + openssl, no fastlane, no SDKs. Use for "publish my app to the stores", "put this on App Store Connect and Google Play", "upload the build / AAB", "update the store listing", "release 1.0.3", "submit for review", "promote to production", 스토어 등록, 앱스토어 등록, 플레이스토어 업로드, 스토어 배포, 심사 제출, 프로덕션 출시, ストア公開, App Store 提出, Google Play リリース, 审核提交, 上架, 商店发布.4---56# store-publish — one JSON, both stores78Takes a mobile app (Flutter, native iOS/Android, React Native, Expo…) from "nothing in the consoles" to9"build attached, ready to submit" on **App Store Connect and Google Play**, with the human touching only10the few buttons that have no API. Everything runs through the two official REST APIs with `curl`,11`python3` and `openssl` — no fastlane, Ruby, PyJWT or Google SDKs.1213`$SKILL` below = the folder containing this file (Claude Code `~/.claude/skills/store-publish`, Codex `~/.agents/skills/store-publish` or `<repo>/.agents/skills/store-publish`, invoked as `$store-publish`). Scripts are in `$SKILL/scripts/`:1415| Script | Role |16|---|---|17| `publish.py` | Front door: `validate · check · listing · build · ship · status · submit`, runs both stores |18| `asc_publish.py` | App Store Connect: `bundle-id · wait-app · app-info · version · screenshots · age-rating · price-free · review-details · all · wait-build · attach-build · release · submit · status` |19| `play_publish.py` | Google Play: `check · details · listings · images · all · upload · promote · status` |20| `build_ios.sh` / `build_android.sh` | Archive + upload the IPA with the API key / build the release AAB |21| `asc_token.py` / `play_token.py` / `asc.sh` / `play.sh` | Tokens and one-off API calls |22| `common.py` | Metadata loader, validation, locale mapping (`ko`↔`ko-KR`, `zh-Hans`↔`zh-CN` …) |2324Metadata schema: `$SKILL/templates/store-metadata.example.json`. API cheat sheets and every gotcha we25hit shipping real apps: `$SKILL/references/asc.md`, `$SKILL/references/play.md`, `$SKILL/references/locales.md`.2627## Hard rules28291. **Reply in the user's language.** Store texts go in whatever locales the metadata lists.302. **Secrets never leave disk.** Never print, paste or commit the `.p8` key, the service-account JSON,31 bearer tokens or demo passwords. Key IDs / issuer IDs / service-account emails are identifiers, fine to show.323. **Confirm before the four outward-facing actions**: uploading a build (build numbers / version codes are33 permanent), releasing to a Play track other than `internal`, `submit` (App Review), and `promote --to production`.34 Everything else is reversible metadata — do it without asking.354. **Read before write, re-run safe.** Every step GETs the current state and PATCHes/POSTs accordingly.36 Prefer `--dry-run` on the first pass: iOS prints the writes, Android lets Google validate the edit and discards it.375. **Never stop on a failed step.** Record the raw API error (it usually lists the allowed values), keep going,38 and finish with the ✅/❌ summary table the scripts print.396. **"Done" means verified.** Finish with `publish.py status` (re-reads both stores) — never report success from40 write responses alone.417. **Ask with options, not open questions.** In Claude Code use `AskUserQuestion` (multi-select when several42 apply, recommended option first). In runtimes without it (Codex CLI, others) print a numbered list, mark the43 recommended one, and wait. Don't ask what the project or the API can tell you.448. **Don't invent store copy silently.** If the project has no listing text, draft it (benefit first, ≤ 30-char45 names, ≤ 80-char Play short description, ≤ 100-char keywords, no `♥ ★`) and show the table before pushing.4647## Workflow4849### 0. Credentials (once per machine)5051- **App Store Connect**: `ASC_KEY_ID` / `ASC_ISSUER_ID` / `ASC_KEY_PATH` or `~/.appstoreconnect/config.json`52 `{"keyId","issuerId","keyPath"}`. Needs an **App Manager** (or Admin) key: ASC → Users and Access →53 Integrations → App Store Connect API. Test: `$SKILL/scripts/asc.sh GET /v1/apps` → HTTP 200.54- **Google Play**: `PLAY_SA_JSON` or `~/.config/play/service-account.json`. Create a service account in any55 Google Cloud project, then Play Console → Users and permissions → **Invite new users** with the service56 account email → grant the app with *Release* + *Store presence* permissions. The app itself must already exist57 in Play Console (no API creates it). Test: `python3 $SKILL/scripts/play_publish.py check --package com.x.app`.58- If either is missing, ask for it once (options: "I have the key → path", "Help me create one", "Skip this store")59 and offer to save it with `chmod 600`.6061### 1. Metadata file6263Create `store-metadata.json` in the project (copy the template). Fill it from what the project already has:64existing listing docs, README, `Info.plist` / `pubspec.yaml` / `build.gradle` (name, ids, version), privacy65policy and support URLs, screenshot folders (the `store-screenshots` skill produces store-size PNGs; put the66Play feature graphic 1024×500 and icon 512×512 there too). Locale keys can be App Store style (`ko`, `ja`,67`zh-Hans`) or Play style (`ko-KR`, `ja-JP`, `zh-CN`) — they are mapped both ways.6869```70python3 $SKILL/scripts/publish.py validate --meta store-metadata.json71```72Fix everything it lists (lengths, symbols, wrong screenshot sizes, missing files) before touching the APIs.7374### 2. First release — the human-only steps first7576| Store | Human clicks (no API) | Then the skill |77|---|---|---|78| App Store | **New App** (My Apps → +): iOS · name · primary language · bundle ID · SKU | `asc_publish.py bundle-id` first so the bundle ID is in the dropdown; `wait-app` polls until it exists |79| App Store | **App Privacy** questionnaire (before submit) | everything else |80| Google Play | **Create app** in Play Console, invite the service account | `play_publish.py check` |81| Google Play | **App content** declarations: privacy policy URL, ads, data safety, content rating, target audience, news/government/financial flags, permission declarations; testers list | everything else |8283Give the user the exact values to type (from the metadata) and tell them which page. Then:8485```86python3 $SKILL/scripts/publish.py listing --meta store-metadata.json --dry-run # look, fix, then87python3 $SKILL/scripts/publish.py listing --meta store-metadata.json88```89iOS runs app-info → version → screenshots → age-rating → price-free → review-details.90Android runs details → listings → images inside one edit and commits it.9192### 3. Build and ship9394Make sure the version in `pubspec.yaml` / Xcode / Gradle matches `app.version`, `Info.plist` has95`ITSAppUsesNonExemptEncryption=false`, and the Android release signing is configured96(`android/key.properties` or `signingConfigs`). **Ask, then:**9798```99python3 $SKILL/scripts/publish.py build --meta store-metadata.json # iOS archive + upload · Android AAB100python3 $SKILL/scripts/publish.py ship --meta store-metadata.json --track internal101```102`ship` waits for Apple to process the build and attaches it to the editable version; on Android it uploads103the AAB to the track with What's New from every locale and commits.104105### 4. Updates (already-published app)106107Bump `app.version` (and the build number / versionCode), write `whatsNew` in every locale, build, then:108109```110python3 $SKILL/scripts/publish.py ship --meta store-metadata.json --release --build-number 18 --track internal111```112iOS creates the new version, copies the previous texts, sets What's New, uploads screenshots only if the113version has none, copies review details, waits for the build and attaches it. `--submit` also files the114review submission. Android uploads the AAB as before; `submit --to production --rollout 0.2` promotes it.115116### 5. Verify and hand over117118```119python3 $SKILL/scripts/publish.py status --meta store-metadata.json120```121Report the table, then list what only the human can do:1221. App Store: **App Privacy** (first version) → `publish.py submit --ios` (or the Submit button).1232. Google Play: **App content** declarations and testers → production rollout (`publish.py submit --android`,124 or the button). New personal developer accounts (after Nov 2023) need a closed test with 12 testers for125 14 days before production is unlocked.126127## When something is off128129| Symptom | Meaning / fix |130|---|---|131| ASC `HTTP 401` | token expired or wrong key — delete `$TMPDIR/asc.token` and retry |132| ASC `409` adding a locale | the app info / version is already released — create the next version first (`release`), locales go on the editable copy |133| ASC `whatsNew` / `marketingUrl` "cannot be edited" | locked on the first version / on released versions — the script retries without them |134| Screenshot set rejected | error lists valid `screenshotDisplayType`s; `validate` maps sizes → types |135| Build never appears | check the upload log for `Upload succeeded`; processing 2–10 min; `INVALID` → the reason arrives by e-mail (ITMS-xxxx) |136| Play `403` | service account not invited / not granted this app / API not enabled in its Cloud project |137| Play `404` on edits | package not in this developer account — create the app in Play Console |138| Play commit refused | the message lists the missing console declarations; the script retries with `changesNotSentForReview=true` when Google asks for it |139| Play "version code already used" | bump `versionCode` and rebuild |140| Testers see "App not available" | first closed-test release is still in Google review, tester account mismatch, or internal + closed test joined at once |