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: 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 getandroidbuild: name: Check existing Android build needs: [fingerprint] type: get-build environment: production params: fingerprinthash: ${{ needs.fingerprint.outputs.androidfingerprinthash }} profile: production getiosbuild: name: Check existing iOS build needs: [fingerprint] type: get-build environment: production params: fingerprinthash: ${{ needs.fingerprint.outputs.iosfingerprinthash }} profile: production buildandroid: name: Build Android needs: [getandroidbuild] if: ${{ !needs.getandroidbuild.outputs.buildid }} type: build environment: production params: platform: android profile: production buildios: name: Build iOS needs: [getiosbuild] if: ${{ !needs.getiosbuild.outputs.buildid }} type: build environment: production params: platform: ios profile: production submitandroid: name: Submit Android needs: [buildandroid] if: ${{ needs.buildandroid.outputs.buildid }} type: submit environment: production params: buildid: ${{ needs.buildandroid.outputs.buildid }} profile: production submitios: name: Submit iOS needs: [buildios] if: ${{ needs.buildios.outputs.buildid }} type: submit environment: production params: buildid: ${{ needs.buildios.outputs.buildid }} profile: production updateandroid: name: Update Android needs: [getandroidbuild, getiosbuild] if: ${{ needs.getandroidbuild.outputs.buildid && !needs.getiosbuild.outputs.buildid }} type: update environment: production params: channel: production platform: android updateios: name: Update iOS needs: [getandroidbuild, getiosbuild] if: ${{ needs.getiosbuild.outputs.buildid && !needs.getandroidbuild.outputs.buildid }} type: update environment: production params: channel: production platform: ios updateall: name: Update All needs: [getandroidbuild, getiosbuild] if: ${{ needs.getandroidbuild.outputs.buildid && needs.getiosbuild.outputs.buildid }} 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. Related links Blog index: https://simonboisset.com/en/blog Website: https://simonboisset.com/en