Expo EAS Workflows Tutorial: CI/CD for React Native without GitHub Actions in 2026
A React Native lead engineer's hands-on guide to Expo EAS Workflows: the YAML-based CI/CD system for building, submitting, and OTA-updating your app without GitHub Actions. Includes a full production workflow file, monorepo setup, secrets handling, and a comparison table.
Expo EAS Workflows is a YAML-defined CI/CD system that runs on Expo's build infrastructure and orchestrates eas build, eas submit, eas update, and custom scripts as chained jobs, so you can build, sign, submit, and OTA-update a React Native app from a single push without wiring up GitHub Actions runners, macOS credentials, or Fastlane. Honestly, after migrating three production apps off GitHub Actions in the last year, I've settled on Workflows as the default for teams that already pay for EAS Build. This guide is the exact setup I use.
EAS Workflows are YAML files stored in .eas/workflows/ that define jobs for build, submit, update, and custom scripts running on Expo's macOS and Linux runners.
Native jobs (type: build, type: submit) inherit your existing eas.json profiles and credentials, so you don't rewrite them for CI.
Workflows beat GitHub Actions on setup cost and macOS pricing for React Native, but Actions still wins for non-mobile jobs and complex matrix testing.
You trigger workflows with push, pull_request, label, schedule, or manual eas workflow:run. No runner tokens required.
Secrets propagate from EAS project-level environment variables and can be scoped per environment (development, preview, production).
Monorepos work out of the box when you point the workflow's working_directory at the app package inside your workspace.
What are Expo EAS Workflows?
EAS Workflows are declarative CI/CD pipelines defined in YAML files under .eas/workflows/ and executed on Expo's cloud runners. Each workflow is a set of jobs. A job is either a first-class Expo action (build an iOS binary, submit to TestFlight, publish an OTA update) or an arbitrary shell script that runs on a Linux or macOS worker. Jobs declare dependencies with needs:, so you get a DAG rather than a linear pipeline.
What makes Workflows different from a generic CI is the tight coupling with the rest of EAS. A type: build job reuses the exact profile from your eas.json, resolves credentials through eas credentials, and streams the same logs you see when you run eas build locally. There is no bring-your-own runner, no Xcode installer step, and no keystore juggling. If you've already invested in EAS Build, half your Workflows setup is done.
Under the hood, Workflows launched in general availability in early 2025 after roughly a year in preview, and the schema stabilised in EAS CLI 15.x. You can inspect a live workflow with eas workflow:view <run-id> or in the Expo dashboard, and jobs stream logs in real time. The full schema is documented in the EAS Workflows documentation.
EAS Workflows vs GitHub Actions: which should you pick?
The honest answer: use Workflows for React Native mobile pipelines, keep Actions for everything else. I run mixed setups on two of my three current apps. Workflows handle native builds, submissions, and OTA updates, while Actions handle backend deploys, docs sites, and Playwright web tests. The reason comes down to cost per macOS minute and setup complexity. Actions macOS runners are billed at roughly ten times the Linux rate; Workflows charge the same as a normal EAS build, which you'd pay anyway.
Dimension
EAS Workflows
GitHub Actions
macOS build cost
Included in EAS Build minutes
~10x Linux rate on hosted runners
Credential management
Automatic via eas credentials
Manual (base64 P12, provisioning profiles as secrets)
My rule of thumb: if the job touches Xcode, Gradle, or the Expo dashboard, it goes in Workflows. If it touches Postgres, Cloudflare, or a Node backend, it stays in Actions. Both can be triggered from the same push, so there's no need to pick one.
Setting up your first EAS Workflow
Assuming you already have an EAS project (eas init has been run and eas.json exists), you need three things to enable Workflows: EAS CLI 15 or later, a connected GitHub or GitLab repository, and a workflow file. Install or upgrade the CLI first.
# Upgrade EAS CLI to the latest 15.x
npm install -g eas-cli@latest
# Confirm version and log in
eas --version
eas login
# Connect the repo (opens a browser to link GitHub/GitLab)
eas project:info
eas github:link
Now create the workflows directory and drop in a minimal file that builds a preview iOS and Android binary on every push to main.
Commit the file and push. From the terminal, run eas workflow:run preview-build.yml once to verify locally, then push to main to see the automatic trigger. In the Expo dashboard, the workflow appears under Workflows → Runs with per-job logs. Because both build jobs are top-level with no needs:, they run in parallel, so you're not waiting for iOS before Android starts.
Job types: build, submit, update, and custom
Every job declares a type. The four you'll actually use are build, submit, update, and custom. Each one maps to an EAS CLI command but runs in a managed context so you don't reauthenticate. Here's what each one does, and when I reach for it.
build
Runs eas build against the profile named in params.profile. Outputs are the same as a manual build: a signed .ipa or .apk/.aab, plus a build ID you can pass to downstream jobs. Use this for every release candidate (internal, TestFlight, and store).
submit
Runs eas submit and uploads a build artefact to App Store Connect or Google Play. It needs a needs: [build_job_name] and can pull the build ID with the ${{ needs.build_ios.outputs.build_id }} syntax.
update
Runs eas update to publish a new OTA JS bundle to a specified branch. This is how I ship weekly patches without going through the App Store. If you haven't set up EAS Update yet, my React Native OTA updates with EAS Update guide covers the runtime channel/branch model and rollback flow that Workflows will drive.
custom
Runs an arbitrary shell script on a Linux or macOS runner. I use this for TypeScript checks, unit tests, and posting to Slack. Custom jobs support image: latest for a Ubuntu LTS worker and image: macos-sonoma when you need Xcode tooling for a preflight step.
# .eas/workflows/release.yml
name: Release to stores
on:
push:
tags: ['v*']
jobs:
test:
name: Typecheck and unit tests
type: custom
image: latest
steps:
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Unit tests
run: npm test -- --ci
build_ios:
name: iOS production build
needs: [test]
type: build
params:
platform: ios
profile: production
submit_ios:
name: Submit to App Store
needs: [build_ios]
type: submit
params:
platform: ios
profile: production
build_id: ${{ needs.build_ios.outputs.build_id }}
publish_update:
name: Publish OTA update
needs: [submit_ios]
type: update
params:
branch: production
message: ${{ github.event.head_commit.message }}
Notice how needs: creates a dependency chain: tests must pass before the build starts, the submit waits for the build, and the OTA update fires only after the submit succeeds. If any upstream job fails, downstream jobs are skipped and the whole run is marked failed.
Triggers, pull requests, and manual runs
Workflows support four trigger types out of the box: push, pull_request, schedule, and manual invocation via eas workflow:run. Pull request triggers are where Workflows really shine, because you can conditionally kick off an iOS simulator build only when a reviewer adds a specific label. That's the exact pattern I use to keep unnecessary macOS minutes off the bill.
Scheduled triggers use standard cron syntax and run against a specific branch. I use them for nightly release-candidate builds. Manual runs are useful for the "ship a hotfix" moment where you don't want to wait for a push. Trigger from your terminal with eas workflow:run release.yml --input version=1.4.2, and read ${{ inputs.version }} inside the workflow.
Managing secrets and environment variables
Secrets are the part I got wrong on my first migration. Workflows do not read your .env file. They inherit environment variables you've configured in the EAS dashboard under Project settings → Environment variables. Each variable has a visibility (plain text, sensitive, or secret) and an environment scope (development, preview, production, or all).
Once configured, variables are exposed inside jobs via ${{ env.MY_VAR }} or through the process environment inside custom steps. Set the scope on the workflow itself so the build job pulls the right values.
# .eas/workflows/staging-release.yml
name: Staging release
on:
push:
branches: [staging]
jobs:
build_and_submit:
name: Build and submit to internal track
type: build
environment: preview # pulls preview-scoped env vars
params:
platform: android
profile: preview
env:
# Override or add job-specific values
SENTRY_RELEASE: ${{ github.sha }}
notify_slack:
name: Notify Slack channel
needs: [build_and_submit]
type: custom
steps:
- name: Post to webhook
run: |
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Staging build ${{ github.sha }} finished\"}"
For deeply sensitive values (signing keys, private API tokens), mark the variable secret in the dashboard. Secret variables are write-only after creation and get automatically redacted from logs. The Expo environment variables reference spells out the visibility rules.
EAS Workflows in a monorepo
If you're on a Turborepo or Nx setup, Workflows work but need a working_directory hint. The runner starts in the repo root, so a job pointed at the wrong package will build the wrong app or fail on missing eas.json. For a two-app monorepo where the mobile package lives at apps/mobile, the workflow looks like this:
# .eas/workflows/mobile-release.yml
name: Mobile release
on:
push:
branches: [main]
paths: ['apps/mobile/**', 'packages/**']
jobs:
build_ios:
name: iOS production build
type: build
working_directory: apps/mobile
params:
platform: ios
profile: production
Two things matter here. First, paths: keeps the workflow from running when someone touches only the web app or backend. Second, working_directory tells the runner where eas.json lives. The build itself uses the monorepo-aware Metro config you already have set up.
For deeper monorepo patterns like shared component libraries, Turbo remote caching, and pnpm workspace hoisting, I wrote a full guide on the React Native monorepo setup with Turborepo and Expo that covers the app-side setup Workflows depends on.
Notifications, concurrency, and cost controls
Two levers control cost: concurrency and job scope. By default, every workflow run counts against your EAS Build minutes bucket for each type: build or type: submit job, while custom jobs on Linux runners are effectively free within reasonable limits. That means the way you structure jobs directly affects your invoice.
Use concurrency: at the workflow level to cancel in-progress runs when a newer commit lands. This is a big deal for chatty branches. Without it, a rapid-fire push of five commits kicks off five iOS builds, each burning a slot.
For notifications, EAS surfaces run status in the dashboard and via email to project members. For Slack, Discord, or Teams, drop a custom job at the end that curls a webhook (the pattern I showed in the secrets section). There's no native Slack integration yet, and honestly a five-line curl is fine.
My production setup: a complete workflow file
Here is the workflow I currently ship to production on a two-app codebase. It runs on tag pushes, executes typechecks and tests in parallel with iOS and Android builds, submits both, publishes an OTA fallback for JS-only fixes, and posts to Slack. The critical detail is that tests are a hard gate: if TypeScript or Jest fails, no build starts. Combined with my EAS deployment workflow, this replaces about 250 lines of Fastlane and GitHub Actions YAML.
# .eas/workflows/production-release.yml
name: Production release
on:
push:
tags: ['v*.*.*']
concurrency:
group: production-release
cancel_in_progress: false
jobs:
test:
name: Typecheck and tests
type: custom
image: latest
steps:
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npx tsc --noEmit
- name: Unit tests
run: npm test -- --ci --coverage
build_ios:
name: iOS production build
needs: [test]
type: build
params:
platform: ios
profile: production
build_android:
name: Android production build
needs: [test]
type: build
params:
platform: android
profile: production
submit_ios:
name: App Store submission
needs: [build_ios]
type: submit
params:
platform: ios
profile: production
build_id: ${{ needs.build_ios.outputs.build_id }}
submit_android:
name: Play Store submission
needs: [build_android]
type: submit
params:
platform: android
profile: production
build_id: ${{ needs.build_android.outputs.build_id }}
ota_baseline:
name: Publish OTA baseline
needs: [submit_ios, submit_android]
type: update
params:
branch: production
message: 'Release ${{ github.ref_name }}'
notify:
name: Notify team
needs: [ota_baseline]
type: custom
steps:
- name: Post to Slack
run: |
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Shipped ${{ github.ref_name }} to both stores\"}"
The whole file is 60 lines. The equivalent Actions workflow with matching macOS builds and Fastlane submission is closer to 300. If you're evaluating CI/CD for a React Native project in 2026 and you already pay for EAS Build, moving here is a weekend of work and a permanent reduction in yak-shaving. Pair this with the OTA channel model in the OTA guide and you have a full pipeline from commit to end-user in under 15 minutes.
Frequently Asked Questions
How much do EAS Workflows cost?
Workflows themselves are free. You pay for the underlying EAS Build minutes when a type: build or type: submit job runs. Custom Linux jobs have generous free limits on all EAS plans, and macOS custom jobs consume the same pool as regular builds. The dashboard bills the same way as a manual eas build.
Can I trigger EAS Workflows manually?
Yes. Run eas workflow:run <file>.yml from your terminal, or click the Run button on the workflow in the Expo dashboard. Manual runs accept typed inputs via --input key=value that you can read inside jobs with ${{ inputs.key }}.
Do EAS Workflows support parallel jobs?
Yes. Any two jobs without a shared needs: dependency run in parallel. In practice, iOS and Android builds always run concurrently on separate workers, and only jobs downstream of both wait for the union to complete.
Can EAS Workflows replace Fastlane?
For most React Native apps, yes. The type: submit job handles App Store Connect and Google Play uploads that Fastlane's deliver and supply lanes cover. You may still want Fastlane for advanced metadata syncs, App Store screenshots automation, or provisioning profile rotation.
Do EAS Workflows work with GitLab or Bitbucket?
GitLab is supported through eas github:link-style connection using GitLab OAuth as of EAS CLI 15.4. Bitbucket does not have a native integration; you can still run any workflow manually via eas workflow:run from a Bitbucket Pipelines step, but native trigger support is on Expo's roadmap.
Capture heap snapshots from a Hermes-powered React Native app in Expo, read retainer paths in Chrome DevTools, and catch the three most common leaks with numbers, traces, and CI regression tests.
Ship native Apple Maps and Google Maps in a React Native app with expo-maps: install the config plugin, render your first map, drop markers, handle camera events, and decide when to stay on react-native-maps.