DEV Community

Russel Dsouza
Russel Dsouza

Posted on

Your App Store Submission Should Be a Git Tag, Not a Ritual

Every team I've seen ship a React Native app has the same undocumented ceremony. Someone opens Xcode. Someone bumps a version number by hand. Someone drags screenshots into App Store Connect. Someone remembers, halfway through, that the Android build needs a different keystore.

It takes a day, it happens every release, and roughly none of it is a decision. It's clicking.

Here's the version where you push a tag and go do something else.

  • eas build and eas submit cover the binary path end to end, no local Xcode
  • Store metadata belongs in a JSON file in your repo, not in a web form
  • Build numbers should auto-increment in CI, never by hand
  • Google Play's track model is the part that changes your schedule, not just your tooling
  • Screenshots and review replies stay manual, and should

The parts that are actually mechanical

Sort the release into two piles: things requiring judgement, and things requiring clicking.

Judgement: what the screenshots say, what the release notes claim, whether the feature is ready, how to respond to a rejection.

Clicking: compiling, signing, uploading, incrementing versions, pushing descriptions and keywords, promoting between tracks.

The second pile is most of the day and none of the value.

Build and submit without touching Xcode

Two commands, once eas.json is configured:

eas build --platform all --profile production
eas submit --platform all --latest
Enter fullscreen mode Exit fullscreen mode

The config that makes submit work:

{
  "cli": { "version": ">= 5.0.0" },
  "build": {
    "production": {
      "autoIncrement": true,
      "env": { "APP_ENV": "production" }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "you@example.com",
        "ascAppId": "1234567890",
        "appleTeamId": "ABCD123456"
      },
      "android": {
        "serviceAccountKeyPath": "./secrets/play-service-account.json",
        "track": "internal"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things in there matter more than they look.

autoIncrement kills an entire category of failed uploads. Manually managed build numbers get duplicated roughly every time two people cut a release in the same week, and the error you get back from App Store Connect is not helpful about why.

track is the Google Play release channel, and it's the thing worth understanding properly.

The Google Play track model

Play has four tracks: internal, alpha (closed testing), beta (open testing), and production. Builds get promoted between them.

This is normally described as a testing convenience. For a new developer account it's a scheduling constraint, because personal accounts created after November 2023 have to run closed testing with at least 12 testers opted in for 14 continuous days before production access is even available.

Which means your CI config has a scheduling consequence:

"android": {
  "serviceAccountKeyPath": "./secrets/play-service-account.json",
  "track": "alpha",
  "releaseStatus": "completed"
}
Enter fullscreen mode Exit fullscreen mode

Point the pipeline at alpha on day one, with whatever build you have. The fourteen days run while you keep working. If you wait until the app is finished to think about tracks, you've added two weeks to the end of your timeline instead of overlapping them with the middle.

Automating the upload is the easy part. Automating it early is the part that actually saves time.

Metadata as a file, not a form

App descriptions, keywords, categories, and release notes are content. Content belongs in version control.

{
  "configVersion": 0,
  "apple": {
    "info": {
      "en-US": {
        "title": "Your App",
        "subtitle": "Short value proposition",
        "description": "Full description text.",
        "keywords": ["keyword", "another"],
        "releaseNotes": "What changed in this build."
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
eas metadata:push
Enter fullscreen mode Exit fullscreen mode

The benefit isn't saved keystrokes. It's that your store listing goes through code review like everything else, and you can see in git log when the description changed and who changed it. Ask anyone who has tried to work out why their keywords are different from last month.

Wiring it to a tag

name: release
on:
  push:
    tags: ['v*']

jobs:
  ship:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci
      - run: eas build --platform all --profile production --non-interactive
      - run: eas submit --platform all --latest --non-interactive
      - run: eas metadata:push
Enter fullscreen mode Exit fullscreen mode

--non-interactive matters. Without it the CLI will sit waiting for a prompt nobody is there to answer, and your job times out twenty minutes later with no useful output.

Credentials go in CI secrets. The Play service account JSON should be a base64-encoded secret written to disk at runtime, never a file in the repo. Same for the Apple API key.

What should stay manual

Screenshots. Generating them is automatable and mostly a bad idea. Store screenshots are a message hierarchy, not a screen dump, and the automated ones look exactly like what they are.

Release notes. "Bug fixes and performance improvements" is what happens when you automate this. Write them.

Rejection responses. A rejection is a conversation. Attaching a demo video and explaining your native functionality resolves a surprising share of them, and none of that is scriptable.

The decision to ship. Obvious, but worth stating, because a pipeline this smooth makes it easy to release without anyone actually deciding to.

The part that compounds

The first release you automate takes longer than doing it by hand. The fifth one takes four minutes.

More usefully, it changes what a release costs psychologically. When shipping is a day of clicking, you batch changes, which makes each release bigger, which makes each release riskier. When it's a tag, you ship the one-line fix on Tuesday instead of holding it for the next big push.

Teams doing this across several apps eventually want the pipeline itself to be a product rather than a .github folder copied between repos. letsdeploy.it does that part, carrying builds, metadata, and store assets through submission so each release stops consuming a day.

But the tooling matters less than the reclassification. Look at your last release and mark each step as judgement or clicking. The clicking pile is bigger than you think, and all of it is someone's afternoon.


What's still manual in your release that shouldn't be? I'll bet at least one person reading this is still incrementing build numbers by hand.

Top comments (0)