# Store Publish

> 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 リリース, 审核提交, 上架, 商店发布.

- Skill: `leehueeng/store-publish` (Agent Skill, multi-file: 9 files)
- Install (CLI): `npx skillmds@latest add leehueeng/store-publish`
- Raw SKILL.md: https://api.skillmd.com/api/skills/leehueeng/store-publish/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: LeeHueeng (https://skillmd.com/u/leehueeng)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/leehueeng/store-publish

---


# 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

1. **Reply in the user's language.** Store texts go in whatever locales the metadata lists.
2. **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.
3. **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.
4. **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.
5. **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.
6. **"Done" means verified.** Finish with `publish.py status` (re-reads both stores) — never report success from
   write responses alone.
7. **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.
8. **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:
1. App Store: **App Privacy** (first version) → `publish.py submit --ios` (or the Submit button).
2. 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 `screenshotDisplayType`s; `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 |

