iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🚀

Managing Expo OTA Updates Safely with --no-publish and publish:set

に公開

Expo is an excellent tool that performs builds on the server and handles various issues related to React Native.

Expo also includes OTA (Over-The-Air) functionality, which is enabled by default. However, because it prioritizes convenience, several considerations are necessary to use it in production.
I had been managing things using my own versioning rules for Expo, but as I wanted to speed up the release cycle, I decided to think about how to use OTA properly.

Note that some basic parts of Expo's OTA are omitted here. If you want to know more details, the following article might be helpful:

TL;DR

Basically, it felt like it would be robust enough for production if I paid attention to the following points:

  • Strictly follow the rule of adding --no-publish during build.
    • Clearly separate build and publish processes.
  • Always specify release channels for both build and publish.
    • Do not use the default that is applied when neither is specified.
  • Push publish to a channel not used for builds, such as edge.
  • Use a different channel for build than the one used for publish.
  • Perform separated deployments by linking the release channel and the build using publish:set apart from build and publish.

Terminology

Since the vocabulary can be a bit inconsistent, the terms used in this article are defined as follows:

  • Build
    • The ipa or apk file that can be uploaded to the app stores.
  • Release
    • The actual executable code uploaded to Expo.
    • Uploaded to Expo via publish, where the code is then executed.
    • I couldn't confirm if this is an official name, but for convenience, it is referred to as a "Release" in this article.
  • publish
    • The command or task of uploading a release.
    • A publish-id is assigned by Expo for each publish.
  • Release Channel

Why use --no-publish during build?

Expo's build command is intended to prepare your app for store distribution, but it roughly breaks down into the following processes:

  1. Uploading the executable code (Release) that runs inside the build binary to Expo's servers.
  2. Building for the app store (actually, it is just added to the Expo build queue).

Also, keep the following points in mind regarding build:

  • A build has a release channel and is linked one-to-one with it.
  • The release channel for a build is specified at build time and cannot be changed afterwards.

For example, when you run something like expo build:ios --release-channel=production, the latest release is reflected in the production channel before the build starts.
This isn't a problem if a build linked to --release-channel=production hasn't been distributed to the store yet, but if you have already distributed the app, a build that hasn't been verified yet will be published and released, which can lead to accidents.

That's where --no-publish comes in. By adding an option like expo build:ios --no-publish, it prevents the first process of "uploading the release to the Expo server."

For example, if you run a command like expo build:ios --no-publish --release-channel=a-channel-not-yet-published, the following message will appear and the build will stop:

No releases found. Please create one using `expo publish` first.

While this command might feel a bit tedious before the first release, it's better to think that stopping the process allows for safer operations.

Understanding publish, publish:set, and publish:history

Up to this point, we have separated the act of "publish" from the act of "build."
Now, let's explore publish in more depth.

First, these are the commands we will use in this article:

  • publish - Command to generate a release.
  • publish:set - Command to link a release with a release channel.
  • publish:history - Command to check the history of recent releases.
  • publish:details - Command to see detailed information for a single release by specifying a publishId.
  • publish:rollback - Command to revert to the most recent release.

You can link a release-channel to a publish. It can be default, development, main, or anything else, but here we will use a channel named edge for the latest releases.

Run the latest build with the following command:

$ expo publish --release-channel=edge

Then, you can check the build with publish:history:

$ expo publish:history

The command above outputs a table format that's easy for humans to read, but let's try JSON notation by adding --raw (and passing it through jq for readability).

$ expo publish:history --raw | jq -C 
  "queryResult": [
    {
      ...
      "channel": "edge",
      "publicationId": "fa6ac07e-229e-4375-8634-b460ee2cd36c",
      ...
      "appVersion": "1.0.2",
      "publishedTime": "2021-09-23T03:47:28.221Z",
      "platform": "ios"
    },
    {
      ...
      "channel": "edge",
      "publicationId": "676174fe-e4bf-490a-bf29-b70ddc685019",
      ...
      "appVersion": "1.0.2",
      "publishedTime": "2021-09-23T03:47:28.221Z",
      "platform": "android"
    },

Various information is displayed, but the most important part here is the publicationId.
As you can see, once you execute expo publish, releases are performed for the platforms specified in app.json, such as ios and android.

Finally, the release is completed by linking this publicationId to the release-channel used in production. In server terms, this task is equivalent to a deployment.

# iOS deployment
$ expo publish:set --release-channel=production fa6ac07e-229e-4375-8634-b460ee2cd36c

# Android deployment
$ expo publish:set --release-channel=production 676174fe-e4bf-490a-bf29-b70ddc685019

Let's run the publish:history command again:

$ expo publish:history --raw -p ios | jq -C

You should now be able to confirm that fa6ac07e-229e-4375-8634-b460ee2cd36c has been released to both edge and production channels as shown below:

{
  "queryResult": [
    {
      ...
      "channel": "production",
      "publicationId": "fa6ac07e-229e-4375-8634-b460ee2cd36c",
      ...
      "publishedTime": "2021-09-23T03:47:28.221Z",
      "platform": "ios"
    },
    {
      ...
      "channel": "edge",
      "publicationId": "fa6ac07e-229e-4375-8634-b460ee2cd36c",
      ...
      "publishedTime": "2021-09-15T23:28:44.226Z",
      "platform": "ios"
    },

Getting the publicationId in a single line

So far, we have clarified the mechanism of releases. However, retrieving the publicationId manually every time is a bit tedious, so let's create a command for it.
The publish:history command has the --count option to limit the number of results and the --platform option to filter by platform. By combining these, you can retrieve the latest single release with a command like this:

$ expo publish:history --raw --count 1 --release-channel=edge --platform=ios 

Since the result is returned in JSON, you can extract the ID from there.
If you use jq, it would look like this:

$ expo publish:history --raw --count 1 --release-channel=edge --platform=ios \
    | jq -r ".queryResult[0].publicationId"

However, there are environments where jq is not installed or cases where you want to keep everything within Node.js. There are various tools available, but this time we'll use jq.node.

$ yarn add jq.node
$ expo publish:history --raw --count 1 --release-channel=edge --platform=ios \
    | yarn -s jqn 'get("queryResult.0.publicationId")'

Now it is a command that outputs just the publicationId on a single line.

Creating release commands

Now that we can output the publicationId, we can combine it with the release command.

$ expo publish:set --release-channel=production \
    --publish-id=$(expo publish:history --raw --count 1 --release-channel=edge --platform=ios \
    | yarn -s jqn 'get("queryResult.0.publicationId")')

Since it's a bit too complex, it's a good idea to set it up in something like an npm script.

"echo:publish:ios": "expo publish:history --raw --count 1 --release-channel=edge --platform=ios | jqn 'get(\"queryResult.0.publicationId\")'",
"echo:publish:android": "expo publish:history --raw --count 1 --release-channel=edge --platform=android | jqn 'get(\"queryResult.0.publicationId\")'",
"release:production:ios": "expo publish:set --release-channel=production --publish-id=$(yarn -s echo:publish:ios)",
"release:production:android": "expo publish:set --release-channel=production --publish-id=$(yarn -s echo:publish:android)",

Then you should be able to execute it as follows:

$ yarn release:production:ios
$ yarn release:production:android

Commands in Production

Let's summarize the process discussed so far:

  1. (Creating a Release) Distribute the release to the edge channel with expo publish --release-channel=edge.
    • Since no production builds are watching the edge channel, this does not cause any issues.
  2. (Deployment Task) Deploy the latest build with yarn release:production:ios (linking it to the release channel).
    • At this point, OTA is triggered and the changes are reflected.
  3. If necessary, generate binaries with expo build:ios and distribute them to the App Store.
    • By executing the build and deployment beforehand, the release bundled initially will be the latest version.

This approach enables an operation where release and deployment are clearly separated.

Side Notes

How to check which build is included?

When running these OTAs, it can be a bit confusing to know which version is currently active.
Basically, you can use Updates.manifest from expo-updates.
Unfortunately, this manifest does not include the publicationId, but you can verify it via the revisionId. It is a good practice to make this information accessible somewhere within the app.

Note that you can check the revisionId for each release using the expo publish:details command.

Another option is to include the Git revision in manifest.extra at build time.

// app.config.js
export default ({ config }) => {
  return {
    ...config,
    extra: {
      "gitRevision": process.env.GIT_REVISION
    }
  }
}

Then, pass it via an environment variable. You could also set this up to run in CI.

$ GIT_REVISION=$(git log -n 1 --format=%H) expo publish --release-channel=edge

What about canary releases?

One problem remains here. Since an Expo build can only belong to a specific release-channel, it is inherently difficult to create a mechanism where only developers get early access.

Possible methods include:

  • A. Generate a separate build with release-channel=staging and use that staging build.
  • B. Prepare a different release-channel and test it as the next release.
    • Use TestFlight or similar for on-device testing.
  • C. Use feature toggles on the server side to switch functionality.
    • I have tried this partially, but the burden of implementation on the client side each time might be high.
  • D. Use expo-updates to coordinate with the server and determine the update target based on specific rules.
    • I haven't tried this yet due to the high implementation cost, but I'd like to eventually.

In the end, since periodic store releases are necessary whenever an SDK is updated, I feel that a combination of Method B and the previously mentioned custom versioning is a reasonable and solid approach. Regardless, I felt that some ingenuity is required for this point.

GitHubで編集を提案

Discussion