iTranslated by AI
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-publishduringbuild.- Clearly separate build and publish processes.
- Always specify release channels for both build and publish.
- Do not use the
defaultthat is applied when neither is specified.
- Do not use the
- Push
publishto a channel not used for builds, such asedge. - Use a different channel for
buildthan the one used forpublish. - Perform separated deployments by linking the release channel and the build using
publish:setapart frombuildandpublish.
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-idis assigned by Expo for each publish.
- Release Channel
- The destination specified during build.
- It's a concept close to
NODE_ENV. - https://docs.expo.dev/distribution/release-channels/
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:
- Uploading the executable code (Release) that runs inside the build binary to Expo's servers.
- 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 apublishId. -
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:
- (Creating a Release) Distribute the release to the
edgechannel withexpo publish --release-channel=edge.- Since no production builds are watching the
edgechannel, this does not cause any issues.
- Since no production builds are watching the
- (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.
- If necessary, generate binaries with
expo build:iosand 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=stagingand use that staging build.- I used to do this, but I stopped because the build-to-deploy process was quite tedious.
- Reference: https://zenn.dev/terrierscript/articles/2021-02-28-expo-app-json-flavor
- 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-updatesto 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.
Discussion