Inconsistent versioning and forgetting to increment the expo app version? Not great after spending 20 minutes for an app build. When I see this:

Version code X has already been used. Try another version code.
frustrated girlie

Here's what expo says about app versioning:

EAS Build can help manage developer-facing build versions automatically by incrementing these versions for you if you opt into using the remote version source, which is the recommended behaviour. Optionally, you can choose to use a local app version source, which means you control versions manually in their respective config files.

If you're using EAS remote builds and have autoincrement enabled, you don’t need to worry about versioning. Eas handles it for you automatically.

But then your app builds depend either on eas, or on you to remember to increment the correct version number.

I have an alternative for you. You don't need to worry about autoincrementing, bumping the version, and you don't need to rely on remote eas builds to manage your versions.

My solution uses Release Please, GitHub Actions and Conventional Commits.

What's nice about this is that I just push to GitHub, and GitHub does all the heavy lifting.

It decides when to build and what version to build - Is it a fix, patch or a breaking change. I don't care. My release ends up in TestFlight or the Play test track.

About EAS Cloud Builds

EAS Build is free to start with. At the time of writing, the free plan allows up to 30 low-priority builds and a maximum of 15 iOS builds per month.

eas free plan example

If you opt for usage-based pricing, it's faster but can also get costly if you release often.

Automate Expo App Versioning with GitHub and Release Please - eas build usage pricing
Usage-based pricing taken 18.12.2024. Always check the official page.

I Prefer GitHub

I prefer GitHub for managing my versions. Not because it's cheaper, better, or whatever. I have an entire continuous integration and delivery setup over there. I use it because I like the process I've set up.

To make my process clearer, I’ve visualized it in the following diagram:

My process with GitHub continuous delivery
My process with GitHub

I develop a feature on a branch, then I make a PR to production branch. Before I can merge to production branch, my pal GitHub says:

- I have to do some checks before you can merge...Alright, carry on.

I merge.

Then my other buddy Release please steps in:

- Hey, I see a couple of fixes. I'd better increment your version from x.x.1 to x.x.2. I'll make a PR and you check if it's ok.

- Sure, merge it.

- Alright, I'll continue with rest of my tasks.

- Wait? What tasks?!

- Rest of the release tasks you've asked me to do. If there's nothing to do, I'll just chill until next time.

How much will GitHub cost?

At first, nothing. Every account has free 2000 CI minutes every month.

Automate Expo App Versioning with GitHub and Release Please - build minutes cost

My build takes approximately 20 minutes for Android and around 30 minutes for iOS.

Building on a macOS machine consumes 10 times as many GitHub Actions free CI minutes. The multiplier for GitHub-hosted runners (e.g., the 10x multiplier for macOS) applies only to the free minutes included in your plan. After you exceed the free minutes allowance, GitHub charges you the standard per-minute rates for the type of runner you use.

Therefore, with 2000 free minutes, a macOS I can perform approximately 7 macOS builds within the free 2000-minute allowance. After the free allowance, GitHub will charge ~2.4$ per ios build and ~0.30$ per android build.

It's more expensive than EAS if you want to go with GitHub.

How to manage expo versions

If you look at your expo project, there are exactly 4 versions of your project.

  • package.json has a version. It usually does not matter, but it needs to be there
  • expo.version is user facing version. I guess it's a pretty representation of the version that user sees.
  • expo.android.versionCode - is build version for android
  • expo.ios.buildCode - is build version for iOS

Wouldn't it be great if there was just one version?

My way is to consolidate all different version properties and just worry about package.json version.

All the rest comes from package.json version. My app.config.ts then looks like:

import { ExpoConfig } from '@expo/config-types';
import { coerce } from 'semver';

import pkg from './package.json';

const currentPkgVersion = coerce(pkg.version)!;
const numericVersion = currentPkgVersion.major * 10000 + currentPkgVersion.minor * 1000 + currentPkgVersion.patch;

const expoVersion = pkg.version;
const config: ExpoConfig = {
  version: expoVersion,
  ios: {
    buildNumber: expoVersion,
  },
  android: {
    versionCode: numericVersion,
  }
}

app.config.ts

This solves my issue of maintaining version consistency.

What about version incrementing

You can automate version bumps by enforcing the conventional commits style first. If you don't want to memorize commit format, use https://commitizen-tools.github.io/commitizen/.

I wrote about conventional commits in my guidelines for tidy commits. What's cool about these kinds of commits is the consistent structure. The key advantage is that this structure allows for automatic detection of version numbers based on the type of changes made.

You also need a process that understands conventional commit log and bumps the version accordingly. That's why the release please process.

What you get with release-please+commitizen:

  1. Automatic Versioning: If you use conventional commits, then there's a consensus how versioning should be done. fix => patch, feat => minor, feat!,  fix!refactor!=> major
  2. Changelog magic: It creates a neat categorized changelog with links to everything you've done.
  3. Commit and Tag: Once the version and changelog are ready, it automatically commits them and tags your release in Git.

How to setup version auto increment

Set release-please-config.json. It tells what kind of projects need to be versioned, where they are and what type they are. Default is node. My app is in the subdirectory called app:

{
  "include-component-in-tag": true,
  "include-v-in-tag": true,
  "tag-separator": "@",
  "release-type": "node",
  "separate-pull-requests": true,
  "release-search-depth": 20,
  "commit-search-depth": 20,
  "packages": {
    "app": {
      "path": "app"
    }
  },
}

Set .release-please-manifest.json. It's the source of truth for your app version. Unfortunately, I can't skip version repetition. It would be great if release-please would just read package.json version, but it is how it is:

{
  "app": "1.0.0"
}

And example workflow looks like:

name: Release Please

on:
  push:
    branches:
      - main

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    outputs:
      app--tag_name: ${{ steps.release.outputs.app--tag_name}}
      app--release_created: ${{ steps.release.outputs.app--release_created}}
    steps:
      - uses: actions/checkout@v4
      - uses: googleapis/release-please-action@v4
        id: release
        with:
          token:  ${{ secrets.GITHUB_TOKEN }}
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json

  release-play-internal-testing:
    needs: release-please
    runs-on: ubuntu-latest
    if: ${{needs.release-please.outputs.app--release_created}}
    steps:
    # ...
  release-testflight:
    needs: release-please
    runs-on: macos-latest
    if: ${{needs.release-please.outputs.app--release_created}}
    steps:
    # ...
  


Next time Release Please detects a change, it will first read the manifest file, then bump the version to the next major, minor, or patch release. And then make a PR with modified package.json and updated CHANGELOG file.

What you need to do is review that PR and merge it. After merge goes through, other pipeline for publishing should be triggered. Specifics on how to setup publishing is for another post.