Simon Boisset
Back to blog

Expo CI/CD workflows (EAS): smarter builds, fingerprinting, and OTA

January 12, 2026

Expo CI/CD workflows (EAS): smarter builds, fingerprinting, and OTA

Expo CI/CD workflows (EAS): smarter builds, fingerprinting, and OTA

In an Expo/React Native project, the real question isn't "how do I build?" but "when do I truly need to build?". Native builds are slow (and expensive in CI), while OTA updates are fast... but they don't cover every kind of change.

The goal of this workflow is to automate the full chain (native builds, OTA updates, submissions) while avoiding unnecessary builds, using Expo fingerprinting and explicit versioning.


Typical structure: staging + production

Two environments, one flow:

  • dev/staging -> preview environment
  • main -> production environment

Same logic in both cases:

  1. compute a fingerprint
  2. decide between native build or OTA
  3. publish automatically

In production, add store submission when a native build happened.


Fingerprinting: automatic decisions

The fingerprint is a hash based on what really impacts the native binary:

  • native dependencies and modules
  • Expo config and plugins
  • config files
  • marketing version (if injected into app.config)

Simple rule:

  • Fingerprint matches an existing build -> OTA
  • Fingerprint differs -> native build

Generic EAS workflow example

This is a real-world inspired example, but intentionally generic. Adjust paths, profile, and channel to your project.

name: Production builds

on:
  push:
    branches:
      - main
    tags:
      - v*.*.*
      - "!v*.*.*-**"
    paths:
      - apps/mobile/**
      - packages/**
      - "!**/*.md"

jobs:
  fingerprint:
    name: Fingerprint
    type: fingerprint
    environment: production

  get_android_build:
    name: Check existing Android build
    needs: [fingerprint]
    type: get-build
    environment: production
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
      profile: production

  get_ios_build:
    name: Check existing iOS build
    needs: [fingerprint]
    type: get-build
    environment: production
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
      profile: production

  build_android:
    name: Build Android
    needs: [get_android_build]
    if: ${{ !needs.get_android_build.outputs.build_id }}
    type: build
    environment: production
    params:
      platform: android
      profile: production

  build_ios:
    name: Build iOS
    needs: [get_ios_build]
    if: ${{ !needs.get_ios_build.outputs.build_id }}
    type: build
    environment: production
    params:
      platform: ios
      profile: production

  submit_android:
    name: Submit Android
    needs: [build_android]
    if: ${{ needs.build_android.outputs.build_id }}
    type: submit
    environment: production
    params:
      build_id: ${{ needs.build_android.outputs.build_id }}
      profile: production

  submit_ios:
    name: Submit iOS
    needs: [build_ios]
    if: ${{ needs.build_ios.outputs.build_id }}
    type: submit
    environment: production
    params:
      build_id: ${{ needs.build_ios.outputs.build_id }}
      profile: production

  update_android:
    name: Update Android
    needs: [get_android_build, get_ios_build]
    if: ${{ needs.get_android_build.outputs.build_id && !needs.get_ios_build.outputs.build_id }}
    type: update
    environment: production
    params:
      channel: production
      platform: android

  update_ios:
    name: Update iOS
    needs: [get_android_build, get_ios_build]
    if: ${{ needs.get_ios_build.outputs.build_id && !needs.get_android_build.outputs.build_id }}
    type: update
    environment: production
    params:
      channel: production
      platform: ios

  update_all:
    name: Update All
    needs: [get_android_build, get_ios_build]
    if: ${{ needs.get_android_build.outputs.build_id && needs.get_ios_build.outputs.build_id }}
    type: update
    environment: production
    params:
      channel: production
      platform: all

Versioning: explicit marketing version, auto-incremented build numbers

I intentionally separate:

  • marketing version (readable, intentional): owned by us
  • build numbers (buildNumber on iOS / versionCode on Android): auto-incremented by EAS

The marketing version lives in package.json:

{
  "name": "my-app",
  "version": "1.4.0"
}

And is injected into app.config.ts:

import type { ConfigContext, ExpoConfig } from "expo/config";
import pkg from "./package.json";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  version: pkg.version,
});

Conclusion & how I can help

This kind of pipeline reduces build costs, speeds up release cycles, and makes production releases safer with clear, automated rules.

šŸ‘‰ Want to implement or improve an Expo CI/CD workflow (fingerprinting, OTA channels, versioning, automated submissions)?

I can help you design the architecture, configure EAS, wire up CI, and apply Expo/React Native best practices end-to-end.

āž”ļø Book a call and we'll build a workflow that fits your product and your team.

Simon Boisset

I'm Simon Boisset, a mobile/full-stack developer. I help teams ship React Native/Expo apps, clean up legacy stacks, and make releases predictable.

Book a call