Building a Complete CI/CD Pipeline for Expo Apps with EAS Updates and Push Notifications

Building a Complete CI/CD Pipeline for Expo Apps with EAS Updates and Push Notifications

Note: While building RepoGo, an AI code editor for iOS, I wanted my CI/CD to be effortless. Frankly, App Store submissions feel like an exam moment—I just want to push to main to ship an update. That's what this guide walks you through.

See It In Action

Here's a complete walkthrough of the entire update process from push to user notification:

The Problem: Manual Updates Are a Developer's Nightmare

Picture this: You've just fixed a critical bug in your Expo app. Now you need to:

  1. Build a new version
  2. Submit it to the App Store
  3. Wait days for review
  4. Hope users actually update their app

For minor updates and bug fixes, this process is painfully slow. Your users are stuck with bugs while you wait for App Store approval. There had to be a better way.

That's when I discovered the power of EAS Updates combined with GitHub Actions and push notifications. The result? A fully automated CI/CD pipeline that:

  • ✅ Automatically publishes updates when code is pushed to main
  • ✅ Sends push notifications to all users about the update
  • ✅ Shows an in-app modal prompting users to update
  • ✅ Installs updates seamlessly without App Store approval

Let me show you exactly how I built this system.

Prerequisites

Before we dive into the implementation, make sure you have:

Required Accounts & Setup

  1. Expo Account - Sign up at expo.dev
  2. EAS CLI Installed - Run npm install -g eas-cli
  3. Expo Project Configured - Your app must be set up with EAS
  4. GitHub Account - With a repository for your Expo app
  5. Backend Server - To handle push notification broadcasts (Next.js, Express, etc.)
  6. Push Notifications Configured - Use Expo's push notification service to send notifications to users

Configure EAS Updates

If you haven't already, initialize EAS in your project:

cd native/
eas login
eas update:configure
eas build:configure

This creates an eas.json file with your build and update configurations.

Configure GitHub Secrets

In your GitHub repository, go to Settings → Secrets and variables → Actions and add:

  1. EXPO_TOKEN - The token you got from eas whoami --json
  2. APP_ACTIONS_SECRET - A random secret string (use a password generator)

The APP_ACTIONS_SECRET is used to secure the API endpoint that sends push notifications.

Configure Your Backend

Add the same secret to your backend environment variables:

APP_ACTIONS_SECRET=your-random-secret-here

This allows your API to verify that requests are coming from your GitHub Actions.

Configure Push Notifications

Your app needs to have Expo push notifications set up to send update notifications to users. This includes:

  • Installing expo-notifications package
  • Requesting notification permissions from users
  • Storing push tokens in your database
  • Implementing sendPushNotificationToAllUsers() function in your backend using expo-server-sdk

This guide assumes you already have push notifications configured. If not, refer to the Expo Push Notifications documentation for setup instructions.


Now let's build the system!

The Architecture: Five Key Components

The system has five main parts working together:

  1. EAS Update Workflow - Triggers on push to main and publishes the update
  2. Notification Workflow - Separate workflow that sends push notifications to users
  3. API Route - Receives request from GitHub Action and broadcasts push notifications
  4. AppUpdateProvider - React Context that manages update checking and installation
  5. Linking Hook - Handles notification taps and triggers update checks

Let's break down each component.

Part 1: The EAS Update Workflow

The foundation is a GitHub Action that runs whenever code is pushed to the main branch. It publishes an EAS update and then triggers a second workflow to notify users.

Create .github/workflows/eas-update.yml:

[!NOTE] This workflow is for a monorepo with a native/ directory. If you're not using a monorepo, you can remove the paths filter.

name: EAS Update

on:
  push:
    branches: [main]
    paths:
      - "native/**"
      - ".github/workflows/eas-update.yml"

jobs:
  update:
    runs-on: ubuntu-latest
    permissions:
      contents: read # Required for actions/checkout
    steps:
      - name: Check for EXPO_TOKEN
        run: |
          if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
            echo "Error: EXPO_TOKEN secret is missing."
            exit 1
          fi

      - uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install dependencies
        working-directory: native
        run: bun install --frozen-lockfile

      - name: Setup EAS
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Publish update
        working-directory: native
        env:
          EAS_SKIP_AUTO_FINGERPRINT: "1"
          EXPO_USE_METRO_WORKSPACE_ROOT: "0"
          NODE_OPTIONS: "--max-old-space-size=4096"
          # Required for Metro to inline these values at bundle time
          EXPO_PUBLIC_PROJECT_ID: "your-project-id"
          EXPO_PUBLIC_EDITOR_URL: "https://editor.your-app.com"
          EXPO_PUBLIC_API: "https://your-api.com"
          EXPO_PUBLIC_GITHUB_CLIENT_ID: "your-github-client-id"
        run: bunx eas update --branch production --platform ios --message "Auto-update from GitHub Action" --non-interactive

      - name: Trigger notification workflow
        if: success()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.actions.createWorkflowDispatch({
              owner: context.repo.owner,
              repo: context.repo.repo,
              workflow_id: 'notify-app-update.yml',
              ref: 'main',
              inputs: {
                message: 'A new version is available! Restart the app to get the latest updates.',
                platform: 'ios',
                branch: 'production'
              }
            })

Key Features:

  • Security check: Validates EXPO_TOKEN exists before proceeding
  • Environment variables: Injects all necessary Expo config at build time
  • Workflow dispatch: Triggers the notification workflow only after successful update

Why Two Workflows?

Separating the update and notification steps provides several benefits:

  1. Better error isolation - If notifications fail, the update still succeeded
  2. Reusability - You can manually trigger notifications without deploying
  3. Cleaner logs - Each workflow has a focused responsibility
  4. Flexibility - You can customize notification behavior independently

Part 2: The Notification Workflow

This workflow is triggered by the EAS Update workflow (or manually) and handles sending push notifications to all users.

Create .github/workflows/notify-app-update.yml:

name: Notify App Update

on:
  workflow_dispatch:
    inputs:
      message:
        description: "Notification message to send to users"
        required: true
        type: string
        default: "A new version is available! Restart the app to get the latest updates."
      platform:
        description: "Platform (ios, android, or all)"
        required: true
        type: string
        default: "ios"
      branch:
        description: "Update branch"
        required: true
        type: string
        default: "production"

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - name: Send notification to users
        env:
          API_URL: "https://your-api.com/api/notify-app-update"
          APP_ACTIONS_SECRET: ${{ secrets.APP_ACTIONS_SECRET }}
        run: |
          echo "Sending app update notifications to all users..."
          echo "Message: ${{ inputs.message }}"
          echo "Platform: ${{ inputs.platform }}"
          echo "Branch: ${{ inputs.branch }}"

          RESPONSE=$(curl -X POST "$API_URL" \
            -H "Content-Type: application/json" \
            -H "x-github-actions-secret: $APP_ACTIONS_SECRET" \
            -d '{
              "message": "${{ inputs.message }}",
              "platform": "${{ inputs.platform }}",
              "branch": "${{ inputs.branch }}"
            }' \
            -w "\nHTTP_STATUS:%{http_code}" \
            -s)

          HTTP_STATUS=$(echo "$RESPONSE" | grep "HTTP_STATUS" | cut -d':' -f2)
          BODY=$(echo "$RESPONSE" | sed '/HTTP_STATUS/d')

          echo "Response: $BODY"

          if [ "$HTTP_STATUS" = "200" ]; then
            echo "✅ Successfully sent app update notifications"
          else
            echo "❌ Failed to send notifications (HTTP $HTTP_STATUS)"
            echo "Response body: $BODY"
            exit 1
          fi

Key Features:

  • Workflow dispatch: Can be triggered automatically or manually
  • Input parameters: Customizable message, platform, and branch
  • Error handling: Fails with detailed error messages if notification fails
  • Manual testing: You can trigger this from GitHub UI or Postman to test notifications

Manual Trigger

You can manually trigger this workflow from GitHub:

  1. Go to Actions tab in your repository
  2. Click Notify App Update workflow
  3. Click Run workflow
  4. Fill in custom message, platform, and branch
  5. Click Run workflow

This is perfect for testing or sending custom notifications to users.

GitHub Actions in Action

Here's what your GitHub Actions workflows look like in the GitHub UI:

GitHub Actions workflow runs and secrets configuration

And here's the notification workflow being triggered automatically:

GitHub Actions notification workflow dispatch

Part 3: The API Route for Push Notifications

When the notification workflow runs, it calls this Next.js API route to send push notifications to all users.

import { NextRequest, NextResponse } from "next/server";
import { sendPushNotificationToAllUsers } from "@/server/notifications";

export async function POST(request: NextRequest) {
  try {
    // Verify the request is coming from GitHub Actions
    const authHeader = request.headers.get("x-github-actions-secret");
    const expectedSecret = process.env.APP_ACTIONS_SECRET;

    if (!expectedSecret) {
      console.error("[App Update] APP_ACTIONS_SECRET not configured");
      return NextResponse.json({ error: "Server configuration error" }, { status: 500 });
    }

    if (authHeader !== expectedSecret) {
      console.error("[App Update] Unauthorized request");
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    // Parse request body
    const body = await request.json().catch(() => ({}));
    const { message, platform, branch } = body;

    // Construct notification payload
    const notificationPayload = {
      title: "App Update Available! 🚀",
      body: message || "A new version of the app is ready. Update to get the latest features.",
      sound: "default" as const,
      data: {
        url: "repogo://",
        actionType: "update",
        platform: platform || "ios",
        branch: branch || "production",
        timestamp: new Date().toISOString(),
      },
    };

    // Send the broadcast notification
    const result = await sendPushNotificationToAllUsers(notificationPayload);

    return NextResponse.json({
      ...result,
      message: "App update notifications sent successfully",
    });
  } catch (error) {
    console.error("[App Update] Error:", error);
    return NextResponse.json(
      {
        error: "Failed to send notifications",
        details: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 500 }
    );
  }
}

Security Considerations:

  • Secret verification: The endpoint checks for a matching secret to prevent unauthorized access
  • Graceful degradation: If the secret isn't configured, it returns a 500 error
  • Structured logging: All actions are logged with clear prefixes for debugging

Part 4: The AppUpdateProvider Component

This is the heart of the update system. It's a React Context provider that:

  • Checks for updates periodically (every 5 minutes)
  • Shows a beautiful modal when an update is available
  • Handles both Expo updates and App Store updates
  • Manages the entire update lifecycle
import useAlert from "@/hooks/useAlert";
import Constants from "expo-constants";
import * as Updates from "expo-updates";
import { BottomSheet, Button } from "heroui-native";
import { createContext, useContext, useEffect, useState } from "react";

const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes

interface AppUpdateContextType {
  checkForUpdates: (alertFeedback?: boolean) => Promise<void>;
  isChecking: boolean;
  isUpdating: boolean;
  isModalOpen: boolean;
  updateMethod: "appStore" | "expo" | null;
  handleExpoUpgrade: () => Promise<void>;
  dismissModal: () => void;
}

const AppUpdateContext = createContext<AppUpdateContextType | null>(null);

export const useAppUpdate = () => {
  const context = useContext(AppUpdateContext);
  if (!context) {
    throw new Error("useAppUpdate must be used within an AppUpdateProvider");
  }
  return context;
};

export default function AppUpdateProvider({ children }) {
  const [isChecking, setIsChecking] = useState(false);
  const [isUpdating, setIsUpdating] = useState(false);
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [updateMethod, setUpdateMethod] = useState<"appStore" | "expo" | null>(null);

  const { showAlert } = useAlert();

  const checkForUpdates = async (alertFeedback: boolean = true) => {
    setIsChecking(true);

    if (__DEV__) {
      setTimeout(() => {
        showAlert("info", "No updates available", "You are using the latest version.");
        setIsChecking(false);
      }, 1000);
      return;
    }

    try {
      const { isAvailable } = await Updates.checkForUpdateAsync();
      if (isAvailable) {
        setIsModalOpen(true);
        setUpdateMethod("expo");
      } else {
        if (alertFeedback) {
          showAlert("info", "No updates available", "You are using the latest version.");
        }
      }
    } catch (error) {
      console.log(`Error checking for update: ${error}`);
    } finally {
      setIsChecking(false);
    }
  };

  const handleExpoUpgrade = async () => {
    setIsUpdating(true);
    try {
      const { isAvailable } = await Updates.checkForUpdateAsync();
      if (isAvailable) {
        await Updates.fetchUpdateAsync();
        
        // Close modal before reload
        setIsModalOpen(false);
        setIsUpdating(false);

        // Small delay for UI updates
        await new Promise((resolve) => setTimeout(resolve, 300));
        await Updates.reloadAsync();
      }
    } catch (error) {
      console.log(`Error fetching update: ${error}`);
      setIsUpdating(false);
      setIsModalOpen(false);
    }
  };

  // Periodic update checks (production only)
  useEffect(() => {
    if (__DEV__) return;

    const interval = setInterval(() => checkForUpdates(false), UPDATE_CHECK_INTERVAL);
    return () => clearInterval(interval);
  }, []);

  return (
    <AppUpdateContext.Provider value={{
      checkForUpdates,
      isChecking,
      isUpdating,
      isModalOpen,
      updateMethod,
      handleExpoUpgrade,
      dismissModal: () => setIsModalOpen(false),
    }}>
      {children}
      <UpdateAvailableModal />
    </AppUpdateContext.Provider>
  );
}

Key Features:

  • Development mode detection: Skips update checks in __DEV__ mode
  • Periodic checks: Automatically checks for updates every 5 minutes
  • Graceful error handling: Failures don't crash the app
  • State management: Tracks checking, updating, and modal states separately

The Update Modal

The modal shows users that an update is available and gives them two options:

const UpdateAvailableModal = () => {
  const { isModalOpen, isUpdating, handleExpoUpgrade, dismissModal } = useAppUpdate();

  return (
    <BottomSheet isOpen={isModalOpen} onOpenChange={(open) => !open && dismissModal()}>
      <BottomSheet.Portal>
        <BottomSheet.Overlay />
        <BottomSheet.Content>
          <View className="items-center mb-5">
            <View className="size-20 items-center justify-center rounded-full bg-green-500/10">
              <Ionicons name="cloud-download-outline" size={40} />
            </View>
          </View>

          <View className="mb-8 gap-2 items-center">
            <BottomSheet.Title className="text-center">Update Available</BottomSheet.Title>
            <BottomSheet.Description className="text-center">
              A new version is ready to install
            </BottomSheet.Description>
          </View>

          <View className="gap-3">
            <LoadingButton 
              label="Update Now" 
              onPress={handleExpoUpgrade}
              isLoading={isUpdating}
              isDisabled={isUpdating}
            />
            <Button variant="tertiary" onPress={dismissModal}>
              Later
            </Button>
          </View>
        </BottomSheet.Content>
      </BottomSheet.Portal>
    </BottomSheet>
  );
};

Part 5: The Linking Hook

This hook handles what happens when users tap the push notification. It triggers the update check when the notification's actionType is "update".

import { router } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useEffect } from "react";
import * as Notifications from "expo-notifications";
import { useAppUpdate } from "@/providers/AppUpdateProvider";

export default function useLinking() {
  const { checkForUpdates } = useAppUpdate();

  useEffect(() => {
    const handleNotificationAction = (notification: Notifications.Notification) => {
      const { url, actionType } = notification.request.content.data;

      if (!url || typeof url !== "string") return;

      switch (actionType) {
        case "update":
          checkForUpdates(true);
          break;

        case "view":
          const path = url.startsWith("http") 
            ? new URL(url).pathname 
            : url;
          router.push(path);
          break;

        case "link":
          WebBrowser.openBrowserAsync(url);
          break;

        default:
          break;
      }
    };

    // Check if app was opened from notification
    const lastResponse = Notifications.getLastNotificationResponse();
    if (lastResponse?.notification) {
      handleNotificationAction(lastResponse.notification);
    }

    // Listen for notifications while app is running
    const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
      handleNotificationAction(response.notification);
    });

    return () => subscription.remove();
  }, [checkForUpdates]);
}

Key Features:

  • Multiple action types: Supports update, view, and link actions
  • Cold start handling: Checks for notification that opened the app
  • Hot notifications: Listens for notification taps while app is running

The Complete Flow

Here's what happens when you push code to main:

  1. EAS Update workflow triggers - Detects changes in the native/ directory
  2. Dependencies installed - Bun installs all required packages
  3. EAS update publishes - New JavaScript bundle is uploaded to Expo servers
  4. Notification workflow triggered - If update succeeds, triggers the notify workflow
  5. API route called - Notification workflow hits your backend endpoint
  6. Push notifications sent - All users receive a notification about the update
  7. User taps notification - Opens the app and triggers checkForUpdates()
  8. Update modal appears - Shows "Update Available" with install button
  9. User taps "Update Now" - App downloads and installs the update
  10. App reloads - User is now on the latest version

All of this happens without App Store review and without the user leaving the app.

Visual Flow

Push to main
    ↓
[EAS Update Workflow]
    ↓
Install dependencies
    ↓
Publish EAS update
    ↓
Trigger notification workflow
    ↓
[Notify App Update Workflow]
    ↓
Call API endpoint
    ↓
Send push notifications
    ↓
User receives notification
    ↓
User taps notification
    ↓
App checks for update
    ↓
Update modal appears
    ↓
User taps "Update Now"
    ↓
App downloads & installs
    ↓
App reloads with new version

Setting Up the System

Now that you've completed the prerequisites, let's implement the system step by step.

Step 1: Create the GitHub Workflows

Create two workflow files in .github/workflows/:

  1. eas-update.yml - Main workflow that publishes updates
  2. notify-app-update.yml - Notification workflow that alerts users

Copy the workflow code from Parts 1 and 2 above, making sure to update:

  • Your working-directory (if not using native/)
  • Your EXPO_PUBLIC_* environment variables
  • Your API_URL for the notification endpoint

Step 2: Create the API Route

In your backend, create an API endpoint at /api/notify-app-update (or your preferred path).

This endpoint should:

  1. Verify the x-github-actions-secret header
  2. Parse the notification payload
  3. Send push notifications to all users

See Part 3 for the complete implementation.

Step 3: Implement Push Notifications

You'll need a function like sendPushNotificationToAllUsers() that sends Expo push notifications. Here's a basic example:

import { Expo } from 'expo-server-sdk';

const expo = new Expo();

export async function sendPushNotificationToAllUsers(payload: {
  title: string;
  body: string;
  data: any;
}) {
  // Get all user push tokens from your database
  const tokens = await getUserPushTokens();

  const messages = tokens.map(token => ({
    to: token,
    title: payload.title,
    body: payload.body,
    data: payload.data,
  }));

  const chunks = expo.chunkPushNotifications(messages);
  
  for (const chunk of chunks) {
    await expo.sendPushNotificationsAsync(chunk);
  }
}

Step 4: Implement AppUpdateProvider

Add the AppUpdateProvider component from Part 4 to your app. This handles:

  • Checking for updates periodically
  • Showing the update modal
  • Downloading and installing updates
  • Reloading the app

Step 5: Add the Linking Hook

Implement the useLinking() hook from Part 5. This handles notification taps and triggers the update check.

Step 6: Wire Everything Together

  1. Wrap your app in <AppUpdateProvider>
  2. Call useLinking() in your root layout
  3. Build your first production build: eas build --platform ios --profile production
  4. Submit to App Store and get approved
  5. Push code to main to trigger your first automated update
  6. Watch the magic happen!

Testing the System

Before going live, test each component:

  1. Test the workflows: Push to main and verify both workflows run successfully
  2. Test the API: Manually trigger the notification workflow from GitHub UI
  3. Test notifications: Verify users receive push notifications
  4. Test the update flow: Tap the notification and verify the modal appears
  5. Test the update: Tap "Update Now" and verify the app reloads with the new version

Lessons Learned

1. Separate Workflows Are Better

Initially, I had everything in one workflow. Splitting into two workflows provides:

  • Better error isolation: Update succeeds even if notifications fail
  • Easier debugging: Clear logs for each step
  • Manual control: Can trigger notifications independently
  • Flexibility: Easy to modify notification logic without touching deployment

2. Don't Block the Main Thread

When downloading and installing updates, the UI can freeze if you're not careful. Always:

  • Show loading states
  • Close modals before reloading
  • Add small delays between state changes and reloads

3. Handle Development Mode

In development, Updates.checkForUpdateAsync() will throw errors. Always check for __DEV__ and skip update logic.

4. Graceful Degradation

If push notifications fail, the update is still published. Users will get the update the next time they open the app (thanks to the periodic check).

5. Test on Real Devices

EAS updates work differently on real devices vs. simulators. Always test on a physical device with a production build.

6. Secure Your Endpoints

Always use secrets to verify requests from GitHub Actions. Never expose your notification endpoint without authentication.

Troubleshooting Common Issues

Issue: "EXPO_TOKEN is missing"

Solution: Make sure you've added EXPO_TOKEN to your GitHub repository secrets.

# Get your token
eas login
eas whoami --json

Copy the token and add it to Settings → Secrets and variables → Actions in GitHub.

Issue: "Update publishes but users don't get notified"

Possible causes:

  1. Wrong secret: Verify APP_ACTIONS_SECRET matches in both GitHub and your backend
  2. Wrong API URL: Check the URL in notify-app-update.yml matches your backend
  3. Users have notifications disabled: Users must grant notification permissions
  4. No push tokens stored: Ensure users' push tokens are saved in your database

Issue: "Modal doesn't appear when notification is tapped"

Solution: Make sure:

  1. useLinking() is called in your root layout
  2. The notification data includes actionType: "update"
  3. AppUpdateProvider wraps your entire app
  4. You're testing on a production build (not development)

Issue: "App crashes when checking for updates"

Solution: This usually happens in development mode. Make sure you have:

if (__DEV__) {
  // Skip update check in development
  return;
}

Issue: "Workflow fails with 'Not authorized'"

Solution: Check that:

  1. Your EXPO_TOKEN is valid (tokens can expire)
  2. You're logged into the correct Expo account
  3. The project exists in your Expo account

Issue: Using npm or yarn instead of Bun?

Solution: Replace the Bun steps with Node setup:

- name: Setup Node
  uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'

- name: Install dependencies
  working-directory: native
  run: npm ci

Or for yarn:

- name: Setup Node
  uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'yarn'

- name: Install dependencies
  working-directory: native
  run: yarn install --frozen-lockfile

The Results

Since implementing this system, I've been able to:

  • Deploy bug fixes in minutes instead of days
  • Update 100% of active users within hours
  • Eliminate App Store review delays for JavaScript-only changes
  • Improve user experience with seamless updates

The entire system is automated. I push code to main, and users get notified automatically. No manual steps. No coordination. Just smooth, automatic updates.

When to Use This Approach

This system is perfect for:

  • Bug fixes that need to go out immediately
  • UI tweaks and style changes
  • Content updates and feature flags
  • Small feature additions that don't require native code changes

Important: EAS updates only work for JavaScript and asset changes. If you modify native code (iOS/Android), you'll still need to submit a new build to the App Store.

Conclusion

Building a complete CI/CD pipeline for Expo apps transformed how I ship updates. What used to take days now takes minutes. What used to require user action now happens automatically.

The combination of EAS Updates, GitHub Actions, push notifications, and a smart React Context creates a seamless update experience that feels native and professional.

If you're building an Expo app and haven't set up automated updates yet, I highly recommend it. Your users will appreciate always having the latest version, and you'll sleep better knowing you can fix bugs instantly.


Have questions about setting this up for your Expo app? Want to discuss CI/CD strategies for React Native? Reach out—I love talking about developer tooling and automation.