<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mason Roy</title>
    <description>The latest articles on DEV Community by Mason Roy (@mason_roy).</description>
    <link>https://dev.to/mason_roy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4131409%2F2d3489ff-c5f3-4c7f-b52a-2b57fcca142e.png</url>
      <title>DEV Community: Mason Roy</title>
      <link>https://dev.to/mason_roy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mason_roy"/>
    <language>en</language>
    <item>
      <title>How to Run Supabase Edge Functions Locally Without a Separate Runtime</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:58:20 +0000</pubDate>
      <link>https://dev.to/mason_roy/how-to-run-supabase-edge-functions-locally-without-a-separate-runtime-4bhf</link>
      <guid>https://dev.to/mason_roy/how-to-run-supabase-edge-functions-locally-without-a-separate-runtime-4bhf</guid>
      <description>&lt;h1&gt;
  
  
  Running Supabase Edge Functions Locally with tinbase
&lt;/h1&gt;

&lt;p&gt;Supabase Edge Functions are useful when you need server-side logic without building a separate backend service. But during local development, setting up the complete environment around a project can add friction.&lt;/p&gt;

&lt;p&gt;What if you could keep your local backend, database, authentication, and Edge Functions in one lightweight development environment?&lt;/p&gt;

&lt;p&gt;This is where &lt;strong&gt;tinbase&lt;/strong&gt; can be useful. It is a Supabase-compatible backend that can run locally and provides support for Edge Functions alongside database, Auth, Storage, Realtime, and Row Level Security.&lt;/p&gt;

&lt;p&gt;In this tutorial, we will create a simple Edge Function and invoke it from a JavaScript application.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Are Building
&lt;/h2&gt;

&lt;p&gt;The example will have this flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
   ↓
supabase.functions.invoke()
   ↓
tinbase
   ↓
Edge Function
   ↓
JSON Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that the client can continue using the familiar Supabase SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Create a Function
&lt;/h2&gt;

&lt;p&gt;A typical Supabase project keeps Edge Functions inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;supabase/
└── functions/
    └── hello/
        └── index.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;index.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello from the Edge Function!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function simply returns a JSON response.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Start tinbase
&lt;/h2&gt;

&lt;p&gt;With tinbase running locally, the backend can load functions from the project's &lt;code&gt;supabase/functions&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;The goal is to keep the local development environment simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tinbase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You now have a local Supabase-compatible backend that can handle the function alongside your other backend services.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Invoke the Function
&lt;/h2&gt;

&lt;p&gt;Because tinbase is compatible with the Supabase client API, you can invoke the function using &lt;code&gt;supabase-js&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application does not need a custom HTTP wrapper just to communicate with the function.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Passing Data to a Function
&lt;/h2&gt;

&lt;p&gt;Edge Functions become more useful when they accept input.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Hello, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;!`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client can send data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;er&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>backend</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Mobile App CI/CD with EAS Build and GitHub Actions</title>
      <dc:creator>Mason Roy</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:11:30 +0000</pubDate>
      <link>https://dev.to/mason_roy/mobile-app-cicd-with-eas-build-and-github-actions-48p6</link>
      <guid>https://dev.to/mason_roy/mobile-app-cicd-with-eas-build-and-github-actions-48p6</guid>
      <description>&lt;p&gt;The first time you ship a React Native app, the code is the easy part. The hard part is everything after git push: signing certificates that expire, provisioning profiles that don't match, a Fastlane script from a two-year-old blog post, a keystore.jks that lives on one laptop, and a build that succeeds locally on macOS but fails on Ubuntu because Xcode isn't there.&lt;/p&gt;

&lt;p&gt;React Native CI/CD is the discipline of turning that mess into a repeatable pipeline: every push runs lint and tests, every merge to main reaches users quickly, and every release goes to TestFlight and Google Play with no one touching Xcode. In 2026, one of the cleanest ways to build that pipeline is a combination of two tools: Expo Application Services (EAS) for the native heavy lifting, and GitHub Actions for everything else.&lt;/p&gt;

&lt;p&gt;This guide walks through a pipeline you can actually copy: what belongs on each side, how to wire them together, how to handle secrets and code signing without leaking them, and how to add over-the-air (OTA) updates so most releases skip the app stores entirely.&lt;/p&gt;

&lt;p&gt;What "mobile app CI/CD" actually means&lt;/p&gt;

&lt;p&gt;Mobile app CI/CD is the automated pipeline that takes a React Native or Expo commit and turns it into a signed, distributable build without a human running Xcode or Android Studio. It combines continuous integration (lint, tests, type checks on every push) with continuous delivery (signed builds and store submissions on every release), and adds an OTA update layer so JavaScript-only changes reach users in minutes instead of days.&lt;br&gt;
That paragraph is the whole idea. The rest of the article is about how to implement it without spending your weekend debugging code-signing errors.&lt;/p&gt;

&lt;p&gt;There are three moving parts in every serious pipeline:&lt;/p&gt;

&lt;p&gt;CI checks: TypeScript, ESLint, unit tests, format checks. These are cheap, fast, and run fine on Linux.&lt;br&gt;
Native builds: compiling .ipa and .aab binaries. These need macOS machines (for iOS), signing keys, and a lot of setup.&lt;br&gt;
Distribution: uploading to TestFlight and Google Play internal testing, and pushing OTA updates.&lt;/p&gt;

&lt;p&gt;The mistake most teams make is trying to do all three in the same place. GitHub Actions can compile an iOS build; it will also take 15–20 minutes per attempt on a small macOS runner, drain your included minutes at a 10x multiplier, and force you to manage certificates by hand. EAS was built to do that specific job well. The pragmatic split is: GitHub Actions owns the code, EAS owns the binary.&lt;/p&gt;

&lt;p&gt;The split-brain problem, and why it's actually the right answer&lt;/p&gt;

&lt;p&gt;Expo supports both approaches: you can drive EAS from any CI service, or use Expo's own EAS Workflows. Running two systems felt wrong to me at first, until I looked at what each is actually good at.&lt;/p&gt;

&lt;p&gt;Concern GitHub Actions  EAS Build / Workflows&lt;br&gt;
Lint, TypeScript, unit tests    Native fit, generous free tier  Works, but uses paid CI minutes&lt;br&gt;
iOS .ipa compilation    Slow, hand-rolled signing   Purpose-built&lt;br&gt;
Android .aab compilation    Workable but manual One command&lt;br&gt;
Code signing (iOS certs, Android keystore)  Stored in Actions secrets, managed by hand  Managed credentials, stored encrypted and shared across the team&lt;br&gt;
OTA updates (EAS Update)    Triggered via CLI   First-class&lt;br&gt;
PR preview builds   Complex eas build --profile preview&lt;br&gt;
Skipping unnecessary native builds  DIY Fingerprint + get-build jobs in EAS Workflows&lt;br&gt;
Cost model  Cheap for Linux jobs, macOS burns minutes 10x faster    Plan fee plus usage-based build pricing&lt;/p&gt;

&lt;p&gt;The rule I've settled on: if a step needs Xcode, Ruby, or a keychain, it belongs in EAS. Everything else (the fast feedback loop developers actually feel) belongs in GitHub Actions. That way a broken test blocks a PR in a couple of minutes, and a full native build only runs when it actually needs to.&lt;/p&gt;

&lt;p&gt;Show Image Photo by Luca Bravo on Unsplash&lt;/p&gt;

&lt;p&gt;Step 1: Set up eas.json with real build profiles&lt;/p&gt;

&lt;p&gt;eas.json is the config file EAS reads to decide how to build your app. It lives at the root of your Expo project alongside app.json and package.json. Most tutorials show a single production profile. That's a trap. You need at least three, because dev, QA, and prod are different environments with different API URLs and distribution methods.&lt;/p&gt;

&lt;p&gt;Here's a working starting point:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "cli": {&lt;br&gt;
    "version": "&amp;gt;= 16.0.0",&lt;br&gt;
    "appVersionSource": "remote"&lt;br&gt;
  },&lt;br&gt;
  "build": {&lt;br&gt;
    "development": {&lt;br&gt;
      "developmentClient": true,&lt;br&gt;
      "distribution": "internal",&lt;br&gt;
      "environment": "development",&lt;br&gt;
      "ios": { "simulator": true }&lt;br&gt;
    },&lt;br&gt;
    "preview": {&lt;br&gt;
      "distribution": "internal",&lt;br&gt;
      "channel": "preview",&lt;br&gt;
      "environment": "preview"&lt;br&gt;
    },&lt;br&gt;
    "production": {&lt;br&gt;
      "channel": "production",&lt;br&gt;
      "environment": "production",&lt;br&gt;
      "autoIncrement": true&lt;br&gt;
    }&lt;br&gt;
  },&lt;br&gt;
  "submit": {&lt;br&gt;
    "production": {&lt;br&gt;
      "ios": {&lt;br&gt;
        "ascAppId": "1234567890"&lt;br&gt;
      },&lt;br&gt;
      "android": {&lt;br&gt;
        "track": "internal"&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;A few non-obvious decisions in there:&lt;/p&gt;

&lt;p&gt;appVersionSource: "remote" delegates the build number to EAS. This is the setting that finally kills the "who bumped the version last?" merge conflict. EAS tracks build numbers server-side, and autoIncrement bumps them on production builds.&lt;br&gt;
channel binds the build to an EAS Update channel. A preview-channel build only receives OTA updates published to the preview channel, so a QA release can't be accidentally overwritten by a main push.&lt;br&gt;
environment tells EAS which set of EAS environment variables (development, preview, or production) to load for the build. Put values like APP_ENV or EXPO_PUBLIC_API_URL there with eas env:create instead of hardcoding them in eas.json, so builds, updates, and local dev all read the same values.&lt;br&gt;
ios.simulator: true on development produces a build that runs in the iOS simulator on any Mac. Great for design review, useless on a physical device or for App Store submission. Add a second development profile without it if your team tests on real phones.&lt;br&gt;
The submit block has no credentials in it. No Apple ID, no path to a Google service account JSON. Those live in EAS (see Step 3), which is what lets submission run non-interactively from CI. A serviceAccountKeyPath pointing at a gitignored file is the most common reason --auto-submit works on a laptop and fails in CI.&lt;/p&gt;

&lt;p&gt;Full reference: Configuring EAS Build with eas.json.&lt;/p&gt;

&lt;p&gt;Step 2: The GitHub Actions workflow&lt;/p&gt;

&lt;p&gt;This is the file that ties everything together. It lives at .github/workflows/ci.yml. The design goal:&lt;/p&gt;

&lt;p&gt;Every PR runs lint + tests, then kicks off a preview EAS build.&lt;br&gt;
Every merge to main publishes an OTA update (added in Step 4).&lt;br&gt;
Every version tag (v1.4.0) runs a production EAS build and submits it to the stores.&lt;/p&gt;

&lt;p&gt;Store builds are deliberately tied to tags, not to every merge. A native build plus submission on every merge burns build credits, floods TestFlight with near-identical binaries, and is unnecessary when most merges are JavaScript-only.&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
name: CI/CD&lt;/p&gt;

&lt;p&gt;on:&lt;br&gt;
  pull_request:&lt;br&gt;
    branches: [main]&lt;br&gt;
  push:&lt;br&gt;
    branches: [main]&lt;br&gt;
    tags: ["v*"]&lt;/p&gt;

&lt;p&gt;permissions:&lt;br&gt;
  contents: read&lt;/p&gt;

&lt;p&gt;concurrency:&lt;br&gt;
  group: ${{ github.workflow }}-${{ github.ref }}&lt;br&gt;
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  quality:&lt;br&gt;
    name: Lint, type-check, test&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - run: npm run lint&lt;br&gt;
      - run: npm run type-check&lt;br&gt;
      - run: npm test -- --ci --coverage&lt;/p&gt;

&lt;p&gt;preview-build:&lt;br&gt;
    name: EAS preview build&lt;br&gt;
    needs: quality&lt;br&gt;
    # Secrets are not available to PRs from forks, so skip those.&lt;br&gt;
    if: &amp;gt;-&lt;br&gt;
      github.event_name == 'pull_request' &amp;amp;&amp;amp;&lt;br&gt;
      github.event.pull_request.head.repo.full_name == github.repository&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - uses: expo/expo-github-action@v8&lt;br&gt;
        with:&lt;br&gt;
          eas-version: latest&lt;br&gt;
          token: ${{ secrets.EXPO_TOKEN }}&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - run: eas build --profile preview --platform all --non-interactive --no-wait&lt;/p&gt;

&lt;p&gt;production-release:&lt;br&gt;
    name: EAS production build + submit&lt;br&gt;
    needs: quality&lt;br&gt;
    if: github.event_name == 'push' &amp;amp;&amp;amp; startsWith(github.ref, 'refs/tags/v')&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - uses: expo/expo-github-action@v8&lt;br&gt;
        with:&lt;br&gt;
          eas-version: latest&lt;br&gt;
          token: ${{ secrets.EXPO_TOKEN }}&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - run: eas build --profile production --platform all --auto-submit --non-interactive --no-wait&lt;/p&gt;

&lt;p&gt;Read that carefully. The shape matters more than the details.&lt;/p&gt;

&lt;p&gt;quality runs on Ubuntu, not macOS. JS-only jobs have no business on a macOS runner. Standard Linux runners bill at about $0.006/minute versus about $0.062/minute for macOS, and macOS drains your included minutes 10x faster.&lt;br&gt;
--no-wait on both build jobs. The Actions job kicks off the EAS build and returns immediately. EAS runs the build on its own infrastructure, and Actions doesn't sit there burning minutes waiting on a native compile. With --auto-submit, the submission is queued on EAS and runs when the build finishes, so you don't need to wait for it either. The trade-off: the Actions job goes green when the build is queued, not when it succeeds. Watch build status in the EAS dashboard, or drop --no-wait if you want the job to fail when the build fails.&lt;br&gt;
--auto-submit on production. EAS builds the binary, then hands it directly to EAS Submit. It lands in TestFlight and the Google Play internal track on its own. Promoting it from there to public release is still a manual (and deliberate) step in App Store Connect and Play Console.&lt;br&gt;
EXPO_TOKEN authenticates the runner to your Expo account. For a team, create it from a robot user with the minimum role needed rather than using a personal access token. A personal token carries everything your account can do, and it disappears when you leave the team.&lt;br&gt;
npm run type-check assumes you have that script in package.json (typically tsc --noEmit).&lt;br&gt;
--platform all on every PR adds up. The EAS Free plan includes 15 Android and 15 iOS builds per month. See Step 5 for how to avoid building when native code hasn't changed.&lt;/p&gt;

&lt;p&gt;Expo's guide on triggering builds from CI goes deeper into edge cases like monorepos and other CI providers.&lt;/p&gt;

&lt;p&gt;Step 3: Handling secrets and code signing without losing your mind&lt;/p&gt;

&lt;p&gt;Code signing is where mobile CI/CD historically goes to die. iOS wants a distribution certificate, a provisioning profile, and a private key. Android wants a keystore, a key alias, and two passwords.&lt;/p&gt;

&lt;p&gt;How bad is losing them? It depends on the platform. iOS certificates and profiles can always be revoked and regenerated from your Apple Developer account. Android is the scary one: if you are not enrolled in Play App Signing and you lose your keystore, you cannot update your existing app. With Play App Signing (the default for new apps), Google holds the app signing key and you can request an upload key reset, which is painful but survivable.&lt;/p&gt;

&lt;p&gt;iOS. EAS handles this almost entirely with managed credentials. On your first interactive eas build, it offers to generate and store the certificate and provisioning profile for you. Say yes. They are stored encrypted on EAS servers and are the same for every developer and every CI run. If you have existing credentials (say, from a legacy Fastlane pipeline), you can upload them once with eas credentials.&lt;/p&gt;

&lt;p&gt;Two things to know so CI doesn't surprise you:&lt;/p&gt;

&lt;p&gt;Distribution certificates expire after a year. EAS does not silently replace them; regenerating requires authenticating with Apple. An expired certificate does not affect apps already on the store, only your ability to make new builds.&lt;br&gt;
Non-interactive runs can't prompt for an Apple ID and 2FA code. Run eas credentials --platform ios once on your machine, create an App Store Connect API key, and choose the option to use it for EAS Submit. That key is what lets --auto-submit work from CI.&lt;/p&gt;

&lt;p&gt;Android. EAS can generate a keystore or you can upload your existing one. For submission, EAS needs a Google Service Account key. Upload it to your project's credentials once, either in the EAS dashboard (Credentials → Android → your application identifier → Service Credentials) or with:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
eas credentials --platform android&lt;/p&gt;

&lt;h1&gt;
  
  
  Google Service Account → Upload a Google Service Account Key
&lt;/h1&gt;

&lt;p&gt;Don't commit the JSON, and don't reference it with serviceAccountKeyPath in eas.json if you want CI to work. Also note that Google requires the very first upload of a new app to be done manually in Play Console before API submissions work.&lt;/p&gt;

&lt;p&gt;Other secrets. For build-time values like SENTRY_AUTH_TOKEN or NPM_TOKEN, use EAS environment variables. The older eas secret:* commands are deprecated in favor of eas env:*:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
eas env:create --name SENTRY_AUTH_TOKEN --value "xxxx" \&lt;br&gt;
  --environment production --visibility secret&lt;/p&gt;

&lt;p&gt;Variables have three visibility levels: plain text, sensitive, and secret. Secret values are never readable outside EAS servers, not even in the dashboard or CLI.&lt;/p&gt;

&lt;p&gt;On the GitHub Actions side, the only secret you need is EXPO_TOKEN. All the app-signing material stays inside EAS, which means:&lt;/p&gt;

&lt;p&gt;Signing keys never touch the GitHub runner's disk or environment, so a leaked workflow log can't expose them.&lt;br&gt;
A new developer joining the team gets access via Expo organization membership, not by copying files around.&lt;br&gt;
There is one source of truth for credentials instead of a base64 blob per repo.&lt;/p&gt;

&lt;p&gt;One honest caveat: EXPO_TOKEN is still a powerful secret. Anyone who has it can trigger builds and, depending on the role behind it, manage credentials. That's another reason to use a scoped robot user, and to never expose it to workflows triggered by fork PRs.&lt;/p&gt;

&lt;p&gt;This is the single biggest reason to keep native builds off GitHub Actions. Managing an Apple distribution certificate inside ${{ secrets.IOS_P12 }} works, until it doesn't, and then you find out on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;Show Image Photo by FLY:D on Unsplash&lt;/p&gt;

&lt;p&gt;Step 4: Add OTA updates so most releases skip the stores&lt;/p&gt;

&lt;p&gt;The best CI/CD pipeline is the one you don't have to run. In a mature React Native app, the large majority of changes are JavaScript-only: a copy tweak, a style fix, a new screen using components that are already in the binary. These don't need a new native build. They just need to reach existing users' phones.&lt;/p&gt;

&lt;p&gt;That's what EAS Update is for. Add one job to the workflow:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
  ota-update:&lt;br&gt;
    name: EAS OTA update&lt;br&gt;
    needs: quality&lt;br&gt;
    if: github.event_name == 'push' &amp;amp;&amp;amp; github.ref == 'refs/heads/main'&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v5&lt;br&gt;
      - uses: actions/setup-node@v6&lt;br&gt;
        with:&lt;br&gt;
          node-version: 22&lt;br&gt;
          cache: npm&lt;br&gt;
      - uses: expo/expo-github-action@v8&lt;br&gt;
        with:&lt;br&gt;
          eas-version: latest&lt;br&gt;
          token: ${{ secrets.EXPO_TOKEN }}&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - name: Publish update&lt;br&gt;
        env:&lt;br&gt;
          COMMIT_MESSAGE: ${{ github.event.head_commit.message }}&lt;br&gt;
        run: &amp;gt;-&lt;br&gt;
          eas update --channel production --environment production&lt;br&gt;
          --message "$COMMIT_MESSAGE" --non-interactive&lt;/p&gt;

&lt;p&gt;Two details in that last step are easy to get wrong:&lt;/p&gt;

&lt;p&gt;Pass the commit message through env, never inline. Writing --message "${{ github.event.head_commit.message }}" directly in run: pastes untrusted text into a shell script. A commit message containing a quote or $(...) will break the step at best and execute arbitrary commands with access to your EXPO_TOKEN at worst.&lt;br&gt;
--environment is required on SDK 55 and later. It tells eas update which EAS environment variables to bundle with. On SDK 54 and earlier, omitting it falls back to local .env files.&lt;/p&gt;

&lt;p&gt;Now every merge to main publishes an OTA bundle within a few minutes. By default, the app downloads the update in the background on launch and applies it on the following launch, so expect users to see a change one cold start later unless you add your own reload logic with expo-updates.&lt;/p&gt;

&lt;p&gt;If pushing every merge straight to production users makes you nervous (it should, a little), point this job at a staging channel instead and promote to production with eas update:republish, or use the rollout percentage options to release gradually.&lt;/p&gt;

&lt;p&gt;Two caveats:&lt;/p&gt;

&lt;p&gt;Native changes are not OTA-updatable. If you add expo-camera this morning, it needs a new binary. The runtime version protects you here: an update is only delivered to binaries with a matching runtime version. Set "runtimeVersion": { "policy": "fingerprint" } in app.json and the runtime version changes automatically whenever anything affecting the native layer changes, so a JS bundle that expects a missing native module never reaches an old binary. When that happens, cut a new tag to ship a fresh binary.&lt;br&gt;
Fingerprints must be computed consistently. With the fingerprint policy, the hash calculated when you run eas update in GitHub Actions needs to match the one calculated during the EAS build. Lockfile drift or files that exist in one environment but not the other will produce a mismatch, and your update will target a runtime no binary has. If updates aren't arriving, compare fingerprints first (npx @expo/fingerprint fingerprint:generate).&lt;/p&gt;

&lt;p&gt;Also remember the store rules: OTA updates are for fixes and incremental improvements, not for changing what your app fundamentally does after review.&lt;/p&gt;

&lt;p&gt;Step 5: Cutting build time with fingerprints&lt;/p&gt;

&lt;p&gt;The same fingerprint that guards your OTA updates can also save you from running native builds you don't need. The fingerprint hashes the parts of your project that affect the native binary: dependencies, app.json, native folders, config plugins. If the hash matches an existing build, the native side hasn't changed, and a new compile is wasted money.&lt;/p&gt;

&lt;p&gt;This is not something plain eas build does for you automatically. It's a pattern you assemble in EAS Workflows from three pre-packaged job types:&lt;/p&gt;

&lt;p&gt;fingerprint computes the hash for each platform&lt;br&gt;
get-build looks for an existing build with that hash&lt;br&gt;
build runs only if nothing was found (and optionally repack injects the new JS bundle into the existing binary, so testers still get an installable artifact)&lt;br&gt;
yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  .eas/workflows/pr-preview.yml
&lt;/h1&gt;

&lt;p&gt;name: PR preview&lt;br&gt;
on:&lt;br&gt;
  pull_request:&lt;br&gt;
    branches: [main]&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  fingerprint:&lt;br&gt;
    type: fingerprint&lt;/p&gt;

&lt;p&gt;get_ios_build:&lt;br&gt;
    needs: [fingerprint]&lt;br&gt;
    type: get-build&lt;br&gt;
    params:&lt;br&gt;
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}&lt;br&gt;
      profile: preview&lt;/p&gt;

&lt;p&gt;build_ios:&lt;br&gt;
    needs: [get_ios_build]&lt;br&gt;
    if: ${{ !needs.get_ios_build.outputs.build_id }}&lt;br&gt;
    type: build&lt;br&gt;
    params:&lt;br&gt;
      platform: ios&lt;br&gt;
      profile: preview&lt;/p&gt;

&lt;p&gt;Expo reports cutting its own CI build times by up to 78% with the fingerprint + repack approach. For a JS-only PR, you go from a full native compile to roughly the time it takes to compute a hash and bundle JavaScript.&lt;/p&gt;

&lt;p&gt;If you adopt this, move the preview-build job out of GitHub Actions and into an EAS Workflow like the one above, and leave lint and tests where they are. If you'd rather stay entirely in GitHub Actions, check the expo-github-action README for its fingerprint-related sub-actions, which can compare fingerprints on a PR and decide between building and publishing an update.&lt;/p&gt;

&lt;p&gt;Where RapidNative fits into all of this&lt;/p&gt;

&lt;p&gt;The problem with every tutorial like this one, including this one, is that it assumes you already have a working Expo project. The wiring above is straightforward when you already have eas.json, an EXPO_TOKEN, and a project with sensible module boundaries. It's much less straightforward if you're starting from a boilerplate someone copy-pasted three years ago.&lt;/p&gt;

&lt;p&gt;RapidNative generates Expo apps from natural-language prompts, and the export ships with the pieces that make this pipeline possible on day one:&lt;/p&gt;

&lt;p&gt;eas.json with development, preview, and production profiles already scaffolded, matching the structure in Step 1.&lt;br&gt;
app.json with the runtime version set to the fingerprint policy, so OTA updates are automatically scoped to compatible binaries.&lt;br&gt;
A monorepo layout (mobile/ for the React Native app, web/ if you generated a web version). In that case, set working-directory: mobile on the workflow steps above.&lt;br&gt;
&amp;lt;!-- VERIFY BEFORE PUBLISHING: state the Expo SDK / React Native / TypeScript versions the export currently ships with. The original draft said RN 0.81 (Expo SDK 54); as of September 2026 the current release is SDK 57, and SDK 56 shipped with RN 0.85. --&amp;gt; A current Expo SDK and TypeScript setup, so you're not starting your pipeline with an SDK upgrade.&lt;/p&gt;

&lt;p&gt;The reason this matters: we kept watching teams get a great AI-generated app, then lose two days rebuilding the project's scaffolding into something CI could actually consume. The whole reason RapidNative uses Expo over bare React Native is that Expo is the shortest path from "code exists" to "binary is signed and on TestFlight."&lt;/p&gt;

&lt;p&gt;If you're not using RapidNative, none of this pipeline requires it. If you are, you can drop the workflow above into .github/workflows/ci.yml, add an EXPO_TOKEN, run one interactive eas build to set up credentials, and push. You can try RapidNative here.&lt;/p&gt;

&lt;p&gt;Show Image Photo by Redd Francisco on Unsplash&lt;/p&gt;

&lt;p&gt;FAQ&lt;br&gt;
How much does EAS Build cost compared to GitHub Actions?&lt;/p&gt;

&lt;p&gt;As of September 2026, EAS has a Free plan with 15 Android and 15 iOS builds per month, a Starter plan at $19/month that includes $45 of build credit, and a Production plan at $199/month that includes $225 of build credit and two build concurrencies. Beyond the included credit, builds are billed per build based on platform and machine size.&lt;/p&gt;

&lt;p&gt;GitHub Actions is free for public repos and includes 2,000 minutes/month for private repos on the Free plan (3,000 on Pro and Team). macOS runners consume those minutes at a 10x multiplier, and overage on standard runners is about $0.006/minute for Linux versus $0.062/minute for macOS.&lt;/p&gt;

&lt;p&gt;On raw compute alone, GitHub Actions is often the cheaper option: a 20-minute iOS build on a standard macOS runner is a little over a dollar. What you're paying EAS for is everything around the compile: managed credentials, submission, internal distribution links, OTA hosting, and not maintaining Fastlane and a CI keychain. Price out your own build volume, and count engineer hours honestly when you do.&lt;/p&gt;

&lt;p&gt;Can I use GitHub Actions without EAS for React Native?&lt;/p&gt;

&lt;p&gt;Yes. You'd need macOS runners with Xcode, a CI-managed keychain, Fastlane (or equivalent) for signing and upload, and a lot of YAML. It's a valid choice for teams with existing native mobile expertise who don't want a vendor dependency. There's also a middle path: eas build --local runs the EAS build process on your own runner. For most React Native teams, especially those already on Expo modules, the maintenance cost of a hand-rolled pipeline ends up exceeding the price of EAS fairly quickly.&lt;/p&gt;

&lt;p&gt;Do I need EAS Workflows if I already use GitHub Actions?&lt;/p&gt;

&lt;p&gt;EAS Workflows is Expo's own CI/CD product. If your repo is mostly mobile and you want one dashboard, plus pre-packaged jobs like fingerprint, get-build, repack, submit, and Maestro tests, use Workflows. If you have a broader repo (backend services, web app, mobile app) and GitHub Actions is already the source of truth, keep it and use EAS for build, submit, and update. A hybrid (tests in Actions, native jobs in Workflows) works fine too. Note that Workflows jobs consume EAS CI minutes on your plan.&lt;/p&gt;

&lt;p&gt;What breaks first when a React Native CI pipeline goes wrong?&lt;/p&gt;

&lt;p&gt;In rough order of frequency: expired or mismatched iOS provisioning profiles, Android keystore password confusion after a team member leaves, EXPO_TOKEN belonging to the wrong account or a user who left, submission failing in CI because Apple or Google credentials only existed on someone's laptop, and native module additions reaching users over OTA because the runtime version was pinned by hand instead of fingerprinted. Managed credentials in EAS take most of the pain out of the first two; the rest are process problems solved by a robot user, credentials stored in EAS, and the fingerprint policy.&lt;/p&gt;

&lt;p&gt;The point of all this&lt;/p&gt;

&lt;p&gt;A mobile CI/CD pipeline is a boring, un-fun piece of infrastructure that has an outsized effect on how fast you can ship. When it works, no one notices. When it doesn't, every release is a two-day fire drill and your team stops shipping between store submissions.&lt;/p&gt;

&lt;p&gt;The pipeline in this article is deliberately not the most sophisticated one possible. There's no matrix build across Node versions, no Slack-notified rollout gates, no per-branch environment promotion. Those are worth adding later, but only after the core loop (push → tested → signed → shipped) runs on its own.&lt;/p&gt;

&lt;p&gt;If you're building the pipeline from scratch, start with the eas.json in Step 1 and the workflow in Step 2. If you're building it into an existing project, budget a day for the first successful production build and submission (mostly iOS signing and store credentials) and another day for the OTA update wiring.&lt;/p&gt;

&lt;p&gt;Either way, the goal is the same: a git push that ends with your users getting the new version, and nobody touching Xcode along the way.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>mobile</category>
      <category>reactnative</category>
    </item>
  </channel>
</rss>
