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:
- Fingerprint — compute a hash of the app’s native characteristics
- Get build — check if a build already exists for that hash
- 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: iosEvery 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: iosThis 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: iosProduction 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: trueThe 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:
| Event | Workflows triggered |
|---|---|
| PR opened / updated | E2E tests (repack) |
Push to main | Deploy 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.