Versioning and Release Hygiene

Three numbers matter at release: the version users see, the build number stores count, and the runtime version updates match.

Syntax"cli": { "appVersionSource": "remote" }

Mixing these three up is how a release gets stuck. They answer different questions.

version

version in the app config is the public one: CFBundleShortVersionString on iOS, versionName on Android. It is what the store listing shows. You choose it, and it can repeat across builds.

Build number

ios.buildNumber and android.versionCode identify a binary to the store. They must strictly increase, and a number is spent the moment it is uploaded, released or not. Setting "appVersionSource": "remote" in the cli block of eas.json hands them to EAS, which increments per build when the profile sets autoIncrement: true; eas build:version:set seeds the starting value. The alternative, "local", keeps them in your config where two people on two branches will eventually pick the same one.

Runtime version

runtimeVersion decides which builds an over-the-air update may reach. With the fingerprint policy it is derived from everything that affects native code, so it moves on its own exactly when a new build is required.

A release checklist

Decide whether the change is JavaScript-only. Bump version if users should see a new number. Build with autoIncrement. Tag the commit. Submit. Then publish an update to the same channel so late installers and existing devices converge on the same JavaScript.

Example

Example · tsx
// lib/release.ts - the three numbers, and the rule that connects them
type BuildIdentity = {
  version: string; // what the store listing shows
  buildNumber: number; // iOS buildNumber / Android versionCode
  runtimeVersion: string; // which updates this build may run
};

export function releaseLabel({ version, buildNumber, runtimeVersion }: BuildIdentity): string {
  return `${version} (${buildNumber}) runtime ${runtimeVersion}`;
}

// An update reaches a build only when the runtime versions are equal. This is
// the whole compatibility rule; there is no partial match and no coercion.
export function canReceiveUpdate(
  build: BuildIdentity,
  update: { runtimeVersion: string }
): boolean {
  return build.runtimeVersion === update.runtimeVersion;
}

const installed: BuildIdentity = {
  version: "1.4.0",
  buildNumber: 52,
  runtimeVersion: "fp-9a1c7e",
};

console.log(releaseLabel(installed));
// Output: 1.4.0 (52) runtime fp-9a1c7e

console.log(canReceiveUpdate(installed, { runtimeVersion: "fp-9a1c7e" }));
// Output: true

console.log(canReceiveUpdate(installed, { runtimeVersion: "fp-41b0d2" }));
// Output: false

When to use it

  • Stop two teammates picking the same versionCode on separate branches.
  • Explain why a published update is not reaching the build on your own phone.
  • Recover after an upload was rejected and its build number is gone.
  • Write down a repeatable sequence so a release does not depend on who runs it.

More examples

Remote build numbers

With remote versioning, buildNumber and versionCode disappear from your app config entirely and EAS keeps the counter. requireCommit refuses to build from a dirty working tree, which is the difference between a store build you can reproduce and one you cannot. Run eas build:version:set once to tell EAS the last number you already shipped.

Example · json
{
  "cli": {
    "version": ">= 16.0.0",
    "appVersionSource": "remote",
    "requireCommit": true
  },
  "build": {
    "preview": {
      "distribution": "internal",
      "channel": "preview",
      "autoIncrement": true
    },
    "production": {
      "channel": "production",
      "autoIncrement": true
    }
  }
}

What the config keeps, and what it hands over

Compare this with a local-versioning project, where every release produces a commit touching buildNumber and versionCode and a merge conflict whenever two releases are in flight. Keep version under your control because it is a product decision; let the counters be machine-owned.

Example · tsx
// app.config.ts - version is yours, build numbers are not
import type { ConfigContext, ExpoConfig } from "expo/config";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: "Trailhead",
  slug: "trailhead",
  // The public number. Bump it when the change is worth telling users about.
  version: "1.4.0",
  ios: { bundleIdentifier: "com.example.trailhead" }, // no buildNumber
  android: { package: "com.example.trailhead" }, // no versionCode
  // Derived from the native side; do not hand-edit it.
  runtimeVersion: { policy: "fingerprint" },
});

The release sequence

Step five is the one people skip. Without it, devices that update over the air and devices that install fresh from the store run different JavaScript against the same backend, and a bug report becomes impossible to place. Running it right after submission keeps one bundle per release.

Example · bash
# 1. Tests must pass before anything is built
npm test -- --ci

# 2. One-time, when adopting remote versioning: seed the counter
eas build:version:set --platform ios

# 3. Build and submit; autoIncrement supplies the build number
eas build --platform all --profile production --auto-submit

# 4. Record exactly what shipped
git tag v1.4.0 && git push --tags

# 5. Same JS to the production channel, so new installs and existing
#    devices converge on one bundle. --environment is required from SDK 55.
eas update --channel production --environment production --message "v1.4.0"

Discussion

  • Be the first to comment on this lesson.