DEV Community

Robin for HotCodePush

Posted on

Ship a React Native app to devices on every merge to main with GitHub Actions

Every merge to main can reach your users' phones without a store review, as long as the merge changed only JavaScript. This tutorial builds that pipeline for a React Native app with GitHub Actions and HotCodePush, the over-the-air (OTA) update service for React Native, Expo, Capacitor and Cordova: one workflow file, a staged rollout that starts at 10 percent, signed bundles, and a pull request check that tells you how many devices a merge would reach before you merge it.

The whole setup is about 30 lines of YAML and one CLI command in your project. What you get is a release process where nobody types a version number, nobody picks a channel per native version, and a change that needs a store release is caught at upload instead of on a user's phone.

What you need

  • A React Native app on version 0.82 or later with the New Architecture, or an Expo app on SDK 55 to 58.
  • Node.js 22 or later, locally and in CI.
  • A GitHub repository with Actions enabled.
  • A HotCodePush account. The free plan covers 1,000 monthly active devices.
  • At least one release build of your app on a device or emulator, so there is something to update.

Step 1: Add the SDK to your project

One command signs you in, creates the app, installs the SDK, writes the config file and wires the build step into both native projects:

npx hotcodepush init
Enter fullscreen mode Exit fullscreen mode

It writes a hotcodepush.json next to your package.json:

{
  "appId": "your-app-id",
  "channel": "production"
}
Enter fullscreen mode Exit fullscreen mode

There is nothing to add to your JavaScript. With the default settings the SDK checks for updates at start, on resume and in the foreground, downloads in the background and applies at the next cold start. HotCodePush.sync() exists for an on-demand cycle, for example behind a button, and useUpdates() exposes the state and the download progress if you want to show them.

Build a release once and run it on a device or emulator. A debug build loads its JavaScript from Metro and takes no updates, so it cannot show you anything.

npx react-native run-android --mode release
Enter fullscreen mode Exit fullscreen mode

For Expo the commands are npx expo prebuild and npx expo run:android --variant release. The quickstart covers both frameworks step by step.

Step 2: Create a signing key

Signing is optional, but a pipeline that can push code to devices should not ship unsigned bundles. The CLI generates an RSA key pair on your machine and registers the public key with the app:

npx hotcodepush signing-key create
Enter fullscreen mode Exit fullscreen mode

It appends the public key to publicKeys in hotcodepush.json and writes the private key to hotcodepush-private-key.pem. Add that file to .gitignore before your next commit. The private key goes into CI as a secret, never into the repository. Every store build from now on trusts the key, and devices refuse a bundle that does not carry a valid signature. Rotation and removal are covered in the code signing guide.

Step 3: Store two secrets in GitHub

In your repository, open Settings, then Secrets and variables, then Actions, and add:

Secret Value
HOTCODEPUSH_TOKEN An API token from the HotCodePush console
HOTCODEPUSH_SIGNING_KEY The content of hotcodepush-private-key.pem

Step 4: The release workflow

Create .github/workflows/release.yml:

name: Release
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - id: sha
        run: echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
      - uses: hotcodepush-team/release-action@v0.2.0
        with:
          token: ${{ secrets.HOTCODEPUSH_TOKEN }}
          private-key: ${{ secrets.HOTCODEPUSH_SIGNING_KEY }}
          channel: production
          bundle-version: ${{ steps.sha.outputs.short }}
          rollout-percent: 10
          progression: true
Enter fullscreen mode Exit fullscreen mode

That is the whole pipeline. A few things are worth knowing about it.

There is no native build step. The action runs the pinned HotCodePush CLI, which bundles each platform itself with react-native bundle and compiles Hermes bytecode, the same way a release build does. For Expo it runs expo export:embed. The runner needs Node.js and nothing else. No Xcode, no Android SDK, no macOS runner.

The action is a thin wrapper. Its inputs are the flags of npx hotcodepush release create under the same names. If you prefer the CLI directly, the equivalent step is:

      - run: npx hotcodepush@0.2.5 release create --channel production --bundle-version "${{ steps.sha.outputs.short }}" --rollout-percent 10 --progression --yes --json
        env:
          HOTCODEPUSH_TOKEN: ${{ secrets.HOTCODEPUSH_TOKEN }}
          HOTCODEPUSH_SIGNING_KEY: ${{ secrets.HOTCODEPUSH_SIGNING_KEY }}
Enter fullscreen mode Exit fullscreen mode

Pin the action to a fixed version. The project keeps no moving major tag, so @v0.2.0 is the right form.

The bundle version is a label, not a compatibility rule. The short commit hash makes every release findable in the console and in your git log. It decides nothing about which devices get the release. That decision is the fingerprint's, which the next section explains.

Step 5: What happens on the first merge

Push a JavaScript change to main and watch the job. The CLI bundles both platforms, signs them, uploads them, computes the project's fingerprint, a hash of the native dependencies read from your lockfile, and compares it with the store builds the app has registered. Then it creates the release on the production channel at 10 percent and waits until the release is live.

On the devices, the SDK sees the new release on its next check, downloads it in the background and applies it at the next cold start. The docs state that every change to a release applies within about a minute, and that a check downloads nothing when nothing changed. With the default next_start strategy, your users run the new code the next time they open the app after the download. The update delivery concept walks through the exact path.

Step 6: Let the rollout progress on its own

The workflow started the release at 10 percent and turned on progression. With the default progression, the release moves through 10, 50 and 100 percent. Each step waits at least 3,600 seconds and at least 50 applied updates before the next one. If the failure rate crosses the threshold, the release pauses on its own and the console tells you. Nobody has to come back on Monday to flip the release to 100 percent, and nobody has to watch a dashboard on Friday evening.

You can tune the steps and the thresholds through the progression-rollout-percents, progression-minimum-seconds, progression-minimum-update-count and failure-threshold-percent inputs. The counters behind the pause are explained in Rollouts and auto-pause. Rolling back is one command, npx hotcodepush release revoke, or one click in the console, and devices return to their previous bundle.

Step 7: A pull request check that shows the reach

The second workflow is the one teams end up liking most. It runs the same release with dry-run: true on every pull request. Nothing is published. The job prints the audience the merge would reach: how many store builds in the field match the bundle's fingerprint and how many devices run them.

name: Release preview
on:
  pull_request:

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - uses: hotcodepush-team/release-action@v0.2.0
        with:
          token: ${{ secrets.HOTCODEPUSH_TOKEN }}
          channel: production
          dry-run: true
Enter fullscreen mode Exit fullscreen mode

Secrets are not available to pull requests from forks, so this check fits team repositories, not public open source projects with outside contributors.

What happens when a merge changes native code?

This is the question every OTA pipeline has to answer honestly. Say a pull request bumps a package with native code, or adds a new native module. The fingerprint moves. At upload the CLI warns that the bundle's fingerprint matches no store build. The dry run on the pull request already showed a reach of zero devices, so the reviewer knew before the merge.

The release still gets created, but no device on the old store build takes it. Those devices keep running what they have, and they keep receiving releases built for their fingerprint. As soon as you ship a new store build, its devices pick up the new line. You never create a channel per native version and you never type a target version. The compatibility concept describes the rule, and npx hotcodepush fingerprint diff tells you locally what moved.

Where this leaves your release process

  • A merge to main is a release. There is no release branch, no release train and no version to type.
  • The rollout starts small and widens on its own, with a pause if failures appear.
  • A native change is caught at upload and shown on the pull request, not discovered on a user's phone.
  • The pipeline needs a Linux runner with Node.js only, the cheapest runner GitHub offers, and no macOS minutes.

The same two workflows work for Expo, Capacitor and Cordova apps with the build step adjusted. The CI pipeline page has the variants, and the GitHub Actions page has the shortest version of this post. If you run into something the pipeline does not handle, say so in the comments.

Top comments (0)