Skip to content

CI/CD EAS Workflows: From Pull Request to Production

Posted on:March 5, 2026

Welcome, Developer 👋

Most CI/CD articles show you one workflow.

A deploy-to-production example. A preview build on PR. Something that works in isolation, copied from the docs, and left as an exercise to figure out how the pieces connect.

What I want to share here is the full system — six workflow files that cover every stage of the development lifecycle, how they relate to each other, and the decisions behind each one.

This is the EAS Workflows setup I actually run in production.

The Foundation: eas.json

Before looking at any workflow, it’s worth understanding the eas.json that connects everything. It defines the build profiles the workflows reference, so the configuration in YAML only makes sense with this context.

{
  "cli": {
    "version": ">=16.28.0",
    "appVersionSource": "remote"
  },
  "build": {
    "base": {
      "node": "20.11.1",
      "android": { "resourceClass": "large" },
      "ios": {
        "image": "auto",
        "enterpriseProvisioning": "adhoc",
        "resourceClass": "large"
      }
    },
    "development": {
      "extends": "base",
      "distribution": "internal",
      "channel": "development",
      "environment": "development"
    },
    "preview": {
      "extends": "base",
      "distribution": "internal",
      "channel": "preview",
      "environment": "preview"
    },
    "preview-simulator": {
      "extends": "preview",
      "ios": { "simulator": true }
    },
    "production": {
      "extends": "base",
      "channel": "production",
      "autoIncrement": true,
      "environment": "production"
    },
    "e2e-test": {
      "extends": "base",
      "withoutCredentials": true,
      "ios": { "simulator": true },
      "android": { "buildType": "apk" },
      "environment": "preview",
      "channel": "e2e"
    }
  },
  "submit": {
    "production": {
      "android": { "releaseStatus": "draft", "track": "production" },
      "ios": { "ascAppId": "6456749974" }
    }
  }
}

A few things worth noting here.

The base profile is the shared foundation — Node version, resource class, and iOS provisioning settings — that all other profiles extend. Changing the Node version in one place updates it everywhere.

appVersionSource: remote means version numbers are managed by EAS, not committed to the repo. Combined with autoIncrement: true on the production profile, build numbers increment automatically on every production build without any manual step.

The e2e-test profile is intentionally stripped down: withoutCredentials: true, an iOS simulator build, and an Android APK instead of an AAB. It doesn’t need signing because it never goes to the store — it just needs to run on a test runner.

development and preview are both internal distribution, which means they install directly on test devices without going through the App Store or Play Store review process.

The Pattern That Runs Through Everything

Before walking through each workflow, it’s worth naming the pattern that appears in all three deployment workflows:

  1. Fingerprint — compute a hash of the app’s native characteristics
  2. Get build — check if a build already exists for that hash
  3. Build or update — if no build exists, compile a new binary; if one does, push an OTA update
fingerprint
    ├── get_android_build ──► build_android (if no match)
    │                    └──► publish_android_update (if match)
    └── get_ios_build    ──► build_ios (if no match)
                         └──► publish_ios_update (if match)

This is the core logic. It runs independently for Android and iOS, which matters — native changes don’t always affect both platforms the same way. Adding an iOS-only library doesn’t require an Android rebuild. The workflow handles each platform on its own terms.

Three Deployment Workflows

A quick note on branching strategy before diving in. This setup is built around trunk-based development. main is the trunk — the single source of truth that everyone integrates into frequently. Feature branches are short-lived and merged back quickly. There are no long-running develop or staging branches to keep in sync.

That’s why two workflows trigger on main push (development and preview) and production has its own dedicated release-v* branch as the explicit promotion gate. The branch structure is minimal by design.

Development — on every push to main

name: Deploy to development
 
on:
  push:
    branches: ["main"]
 
concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}
 
jobs:
  fingerprint:
    name: Fingerprint
    type: fingerprint
    environment: development
  get_android_build:
    name: Check for existing android build
    needs: [fingerprint]
    type: get-build
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
      profile: development
  get_ios_build:
    name: Check for existing ios build
    needs: [fingerprint]
    type: get-build
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
      profile: development
  build_android:
    name: Build Android
    needs: [get_android_build]
    if: ${{ !needs.get_android_build.outputs.build_id }}
    type: build
    params:
      platform: android
      profile: development
  build_ios:
    name: Build iOS
    needs: [get_ios_build]
    if: ${{ !needs.get_ios_build.outputs.build_id }}
    type: build
    params:
      platform: ios
      profile: development
  publish_android_update:
    name: Publish Android update
    needs: [get_android_build]
    if: ${{ needs.get_android_build.outputs.build_id }}
    type: update
    params:
      branch: development
      platform: android
  publish_ios_update:
    name: Publish iOS update
    needs: [get_ios_build]
    if: ${{ needs.get_ios_build.outputs.build_id }}
    type: update
    params:
      branch: development
      platform: ios

Every merge to main deploys to the development environment automatically. If native code hasn’t changed, an OTA update lands on development devices within minutes. If something native changed, a new internal build is compiled and distributed.

There’s no submit step here. Development builds are internal distribution — they install directly on registered devices and never touch the store.

Preview (UAT) — also on every push to main

name: Deploy to preview
 
on:
  push:
    branches: ["main"]
 
concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}
 
jobs:
  fingerprint:
    name: Fingerprint
    type: fingerprint
    environment: preview
  get_android_build:
    name: Check for existing android build
    needs: [fingerprint]
    type: get-build
    environment: preview
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
      profile: preview
  get_ios_build:
    name: Check for existing ios build
    needs: [fingerprint]
    type: get-build
    environment: preview
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
      profile: preview
  build_android:
    name: Build Android
    needs: [get_android_build]
    if: ${{ !needs.get_android_build.outputs.build_id }}
    type: build
    environment: preview
    params:
      platform: android
      profile: preview
  build_ios:
    name: Build iOS
    needs: [get_ios_build]
    if: ${{ !needs.get_ios_build.outputs.build_id }}
    type: build
    environment: preview
    params:
      platform: ios
      profile: preview
  publish_android_update:
    name: Publish Android update
    needs: [get_android_build]
    if: ${{ needs.get_android_build.outputs.build_id }}
    type: update
    environment: preview
    params:
      branch: preview
      platform: android
  publish_ios_update:
    name: Publish iOS update
    needs: [get_ios_build]
    if: ${{ needs.get_ios_build.outputs.build_id }}
    type: update
    environment: preview
    params:
      branch: preview
      platform: ios

This workflow runs at the same time as the development one — both trigger on main push — but they’re completely independent. A slow build on development doesn’t block a preview update, and vice versa.

The key difference from the development workflow is environment: preview set explicitly on every job, not just the fingerprint. This ensures that every job reads preview environment variables consistently. If a job picks up the wrong environment variables during a get-build lookup, it might fail to match a build that actually exists — resulting in an unnecessary full rebuild.

It’s a small detail, but it’s the kind of thing you only notice after debugging a confusing cache miss at 11pm.

Production — on a dedicated release branch

name: Release to production
 
on:
  push:
    branches: ["release-v*"]
 
concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}
 
jobs:
  fingerprint:
    name: Fingerprint
    type: fingerprint
    environment: production
  get_android_build:
    name: Check for existing android build
    needs: [fingerprint]
    type: get-build
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
      profile: production
  get_ios_build:
    name: Check for existing ios build
    needs: [fingerprint]
    type: get-build
    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
    params:
      platform: android
      profile: production
  build_ios:
    name: Build iOS
    needs: [get_ios_build]
    if: ${{ !needs.get_ios_build.outputs.build_id }}
    type: build
    params:
      platform: ios
      profile: production
  submit_android_build:
    name: Submit Android Build
    needs: [build_android]
    type: submit
    params:
      build_id: ${{ needs.build_android.outputs.build_id }}
  submit_ios_build:
    name: Submit iOS Build
    needs: [build_ios]
    type: submit
    params:
      build_id: ${{ needs.build_ios.outputs.build_id }}
  publish_android_update:
    name: Publish Android update
    needs: [get_android_build]
    if: ${{ needs.get_android_build.outputs.build_id }}
    type: update
    params:
      branch: production
      platform: android
  publish_ios_update:
    name: Publish iOS update
    needs: [get_ios_build]
    if: ${{ needs.get_ios_build.outputs.build_id }}
    type: update
    params:
      branch: production
      platform: ios

Production works the same way as the other two environments, with two important differences.

First, the trigger. Production doesn’t deploy on every main push — it deploys when a release-v* branch is pushed. Creating release-v2.1.0 and pushing it is the explicit signal: this is intentional, ship it. The branch name carries the version, the workflow run is tied to it, and the history stays clean.

Second, the submit step. When a new native build is required, it’s automatically submitted to the App Store and Play Store after it completes. The build_id is passed directly from the build job’s output, so it’s always submitting the exact binary that was just compiled. On the Android side, it lands as a draft on the production track. On iOS, it goes to the configured ASC app.

If no new native build is needed, an OTA update is pushed to the production channel instead. No submit step. No review cycle. The update is live in minutes.

Two E2E Testing Strategies

This is where it gets interesting. There are two separate approaches to E2E testing, each optimised for a different moment in the development cycle.

Before getting into each one, it’s worth clarifying the two job types involved. maestro runs your test flows directly on EAS infrastructure — the build is downloaded onto an EAS-managed device and the tests execute there. maestro-cloud sends the build and flows to Maestro’s own cloud platform, where they run on Maestro’s infrastructure. The first is self-contained within EAS. The second hands off to an external service, which gives you Maestro Cloud’s dashboard, history, and parallelism features — but requires a Maestro Cloud account and subscription.

On pull requests — fast, with repack

name: End-to-end Tests
 
on:
  pull_request:
    branches: ["*"]
 
concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}
 
jobs:
  fingerprint:
    environment: preview
    type: fingerprint
 
  android_get_build:
    needs: [fingerprint]
    type: get-build
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
      platform: android
      profile: preview
 
  android_repack:
    needs: [android_get_build]
    if: ${{ needs.android_get_build.outputs.build_id }}
    type: repack
    params:
      build_id: ${{ needs.android_get_build.outputs.build_id }}
 
  android_build:
    needs: [android_get_build]
    if: ${{ !needs.android_get_build.outputs.build_id }}
    type: build
    params:
      platform: android
      profile: preview
 
  android_maestro:
    after: [android_repack, android_build]
    type: maestro
    image: latest
    params:
      build_id: ${{ needs.android_repack.outputs.build_id || needs.android_build.outputs.build_id }}
      flow_path: [".maestro/flows"]
      record_screen: true
 
  ios_get_build:
    needs: [fingerprint]
    type: get-build
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
      platform: ios
      profile: preview-simulator
 
  ios_repack:
    needs: [ios_get_build]
    if: ${{ needs.ios_get_build.outputs.build_id }}
    type: repack
    params:
      build_id: ${{ needs.ios_get_build.outputs.build_id }}
 
  ios_build:
    needs: [ios_get_build]
    if: ${{ !needs.ios_get_build.outputs.build_id }}
    type: build
    params:
      platform: ios
      profile: preview-simulator
 
  ios_maestro:
    after: [ios_repack, ios_build]
    type: maestro
    image: latest
    params:
      build_id: ${{ needs.ios_repack.outputs.build_id || needs.ios_build.outputs.build_id }}
      flow_path: [".maestro/flows"]
      record_screen: true

The same fingerprint pattern appears again, but with a new job in the middle: repack.

When a matching build is found, repack takes that existing binary and replaces only its JavaScript bundle and metadata. The native layer stays untouched. What used to be a 20-minute build becomes the time it takes to bundle JS — typically a few minutes.

When no matching build exists — because native code actually changed — a full build runs instead.

The android_maestro job uses after rather than needs. This is deliberate. needs only runs if the upstream job succeeded. But here, either android_repack or android_build will be skipped (only one of the two will actually run). Using after means the Maestro job runs once whichever path completes, regardless of whether the other was skipped.

The build ID is then resolved with a simple OR expression:

build_id: ${{ needs.android_repack.outputs.build_id || needs.android_build.outputs.build_id }}

One of them will have a value. The other will be empty. The OR handles it.

For iOS, the same structure applies but using the preview-simulator profile — a simulator build, no signing required, which is faster and avoids credentials management entirely in the test environment.

On push to main — thorough, with Maestro Cloud

name: End-to-End Tests iOS
 
on:
  push:
    branches: ["main"]
 
concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}
 
jobs:
  build_ios_for_e2e:
    type: build
    runs_on: macos-large
    params:
      platform: ios
      profile: e2e-test
 
  maestro_test:
    needs: [build_ios_for_e2e]
    type: maestro-cloud
    environment: preview
    image: auto
    params:
      build_id: ${{ needs.build_ios_for_e2e.outputs.build_id }}
      maestro_project_id: "proj_01jz6zs8c9fdq9cv94pxphek33"
      flows: .maestro/flows
      name: iOS E2E Tests ${{ needs.build_ios_for_e2e.outputs.build_id }}
name: End-to-End Tests Android
 
on:
  push:
    branches: ["main"]
 
concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}
 
jobs:
  build_android_for_e2e:
    type: build
    runs_on: linux-large
    params:
      platform: android
      profile: e2e-test
 
  maestro_test:
    needs: [build_android_for_e2e]
    type: maestro-cloud
    environment: preview
    image: auto
    params:
      build_id: ${{ needs.build_android_for_e2e.outputs.build_id }}
      maestro_project_id: "proj_01jz6zs8c9fdq9cv94pxphek33"
      flows: .maestro/flows
      name: Android E2E Tests ${{ needs.build_android_for_e2e.outputs.build_id }}

This workflow takes a different approach entirely. No fingerprint check, no repack. It builds a fresh binary every time using the e2e-test profile, then runs the test suite on Maestro Cloud.

The reason is the moment it runs. PRs need fast feedback — repack is the right tool there. But once code lands on main, you want a clean, unambiguous test run against a fresh binary. No cache. No reuse. No shortcuts.

The e2e-test profile handles the rest: withoutCredentials means no signing setup, ios.simulator: true targets the simulator, and android.buildType: apk produces a lightweight binary that doesn’t need store distribution. The profile is built for this job and nothing else.

There are two separate workflow files here — one for iOS, one for Android. This lets them run in parallel and fail independently. A flaky Android test doesn’t block you from seeing iOS results.

Concurrency on Every Workflow

Every single one of these workflows has the same concurrency block:

concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}

If a new commit arrives while a workflow is still running on the same branch, the old run is cancelled. This prevents queued builds from piling up, avoids conflicting store submissions, and keeps the feedback loop tight.

The group is scoped to the filename and the branch ref, so two different release branches can run concurrently without cancelling each other.

The System as a Whole

Laid out together, the full pipeline looks like this:

EventWorkflows triggered
PR opened / updatedE2E tests (repack)
Push to mainDeploy to dev, deploy to preview, E2E tests (Maestro Cloud)
Push to release-v*Release to production

Every commit is tested. Every merge to main deploys to two environments. Production only moves when you deliberately push a release branch.

The intelligence is in the fingerprint check — the same one that runs across all three deployment workflows. It’s what turns a potentially 20-minute build into a 3-minute OTA update when nothing native has changed, and ensures a full native build runs exactly when it needs to.

Conclusion

The workflows aren’t doing anything you couldn’t do yourself. They’re just doing it consistently, for every commit, without anyone having to remember.

See you in the next post. Stay focused, Developer.