Expo CI/CD workflows (EAS): smarter builds, fingerprinting, and OTA
January 12, 2026
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 environmentmain-> production environment
Same logic in both cases:
- compute a fingerprint
- decide between native build or OTA
- 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 (
buildNumberon iOS /versionCodeon 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.
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.