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

Type: blog post
Language: en-US
Canonical URL: https://simonboisset.com/en/blog/expo-ci-cd-workflows-fingerprint-ota
Published: January 12, 2026
Summary: 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...

# 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.

```yml
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`:

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

And is injected into `app.config.ts`:

```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.*

## Related links
- [Blog index](https://simonboisset.com/en/blog)
- [Website](https://simonboisset.com/en)
