Skip to content

Commit eb4fcbe

Browse files
KisaneNekoRadoslaw Nowackiclaude
authored
chore: automate example app publishing to app stores (#5118)
* chore: automate example app publishing The example app in the stores drifts away from the library: it was released eighteen times in nine years against eighty-three library releases, all by hand, and the last one was twenty months ago. Ship it from the release that prompts it instead. What it ships depends on whether native code changed. `runtimeVersion` uses the fingerprint policy, so the workflow can compare the project against the builds already out there: unchanged means the new JavaScript runs on what people already have, and goes out as an update; changed means a build and a trip through the stores. Only a build carries a version to a store, so only a build bumps one, and that bump comes back as a pull request. This replaces the updates workflow, which published updates when a labelled pull request merged. It never ran once: it triggers on push, where the pull_request it tests does not exist, so all sixty-nine runs skipped. Tying both outcomes to the release removes the label nobody remembered to add. Prereleases and stable releases build against separate channels so an update published for an alpha cannot reach the stable app. Where a build lands is decided by the tag rather than by the release's prerelease flag, because .release-it.json pins every release to `preRelease: true` for the 6.0 alpha cycle, and reading that flag would route a stable 6.0.0 to the internal track. Publishing runs after a release already exists, so it cannot fail, revoke or roll back one. A run that does not succeed says so on its own run page, where the mail GitHub already sends the publisher leads, and says the release is unaffected. Store credentials live in EAS rather than here, so EXPO_TOKEN is the only secret this needs. Setup and the known limitations are in CONTRIBUTING.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(ci): add a dry run to the example app workflow Whether EXPO_TOKEN still works could otherwise only be found out by a real run, which builds and submits. A dry run authenticates, works out whether the release would build or update, reports that, and stops. Also check authentication in its own step, so a missing or expired token fails in seconds with a message that says so rather than several minutes later inside a build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the example app fingerprint stable across pod install ExpoModulesJSI.podspec builds an xcframework into its own source directory from a prepare_command, and that directory is fingerprinted. EAS fingerprints after installing pods and the CLI fingerprints before, so the two disagreed and the build stopped with a runtime version mismatch. Ignore the generated products. The sources they are built from are fingerprinted already, so nothing that changes the native runtime stops being noticed. Without this the workflow would also never publish an update: the fingerprint it computes on a runner could never match the one EAS recorded. Drop the build number and version code from the app config as well, now that EAS assigns both remotely and warns on every command that the values in the config are ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: drop the dry run from the example app workflow The dry run existed to answer whether EXPO_TOKEN still worked and whether the store credentials would resolve, without spending a real run. Both have since been answered by building and submitting for real, so what is left is a second path through the workflow kept alive to derisk a first run whose worst outcome is that a demo app does not update. Trim what publishing adds to CONTRIBUTING.md down to what would otherwise go wrong quietly: that nothing reaches the App Store on its own, that the version bump pull request has to be merged before the next release, where the credentials live, and which generated files have to stay out of the fingerprint. The run page and the issue a failed run opens already say the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: replace AI prose with human comments, trim tests * chore: simplify deployment output logs * fix: address review feedback on example app deployment * refactor: set example app version from the release tag * fix: harden the example app deployment workflow * fix: address second round of example app deployment review Pass the release tag from the check job, pin eas-cli, build only the platforms whose native code changed, queue preview and production runs separately, and read the EAS Node version from .nvmrc. * fix: tighten the example app deployment workflow Publish updates before builds so a slow build can't hold them up, fail on a missing fingerprint or commit lookup, work out the profile once in the check job, and drop the 2-hour timeout in favour of the default. --------- Co-authored-by: Radoslaw Nowacki <radoslaw.nowacki@callastack.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ce72155 commit eb4fcbe

8 files changed

Lines changed: 263 additions & 30 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
name: Publish example app
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: Release tag to publish the example app for, e.g. v6.0.0
10+
required: true
11+
platform:
12+
description: Platform to build. Defaults to all.
13+
required: false
14+
default: all
15+
type: choice
16+
options:
17+
- all
18+
- ios
19+
- android
20+
21+
permissions:
22+
contents: read
23+
24+
defaults:
25+
run:
26+
shell: bash
27+
28+
jobs:
29+
check:
30+
name: Check release is on main
31+
runs-on: ubuntu-latest
32+
env:
33+
TAG: ${{ github.event.release.tag_name || inputs.tag }}
34+
outputs:
35+
on-main: ${{ steps.check.outputs.on-main }}
36+
sha: ${{ steps.check.outputs.sha }}
37+
tag: ${{ env.TAG }}
38+
profile: ${{ steps.check.outputs.profile }}
39+
steps:
40+
- id: check
41+
env:
42+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43+
GH_REPO: ${{ github.repository }}
44+
run: |
45+
status=$(gh api "repos/$GH_REPO/compare/main...$TAG" --jq .status)
46+
47+
if [ "$status" = "identical" ] || [ "$status" = "behind" ]; then
48+
sha=$(gh api "repos/$GH_REPO/commits/$TAG" --jq .sha)
49+
echo "on-main=true" >> "$GITHUB_OUTPUT"
50+
echo "sha=$sha" >> "$GITHUB_OUTPUT"
51+
else
52+
echo "on-main=false" >> "$GITHUB_OUTPUT"
53+
echo "::warning::$TAG is not on main ($status), nothing to deploy."
54+
echo "$TAG is not on main ($status), nothing to deploy." >> "$GITHUB_STEP_SUMMARY"
55+
fi
56+
57+
if [ "${TAG#*-}" != "$TAG" ]; then
58+
echo "profile=preview" >> "$GITHUB_OUTPUT"
59+
else
60+
echo "profile=production" >> "$GITHUB_OUTPUT"
61+
fi
62+
63+
publish:
64+
name: Build and submit example app
65+
needs: check
66+
if: needs.check.outputs.on-main == 'true'
67+
runs-on: ubuntu-latest
68+
concurrency:
69+
# One queue per channel, so a pending preview run doesn't cancel a production one
70+
group: publish-example-app-${{ needs.check.outputs.profile }}
71+
steps:
72+
- name: Checkout
73+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
74+
with:
75+
ref: ${{ needs.check.outputs.sha }}
76+
77+
- name: Setup
78+
uses: ./.github/actions/setup
79+
80+
- name: Setup Expo
81+
uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1
82+
with:
83+
eas-version: 24.12.1
84+
token: ${{ secrets.EXPO_TOKEN }}
85+
86+
- name: Check Expo authentication
87+
working-directory: ./example
88+
run: |
89+
if ! eas whoami; then
90+
echo "::error::Could not authenticate with Expo, check EXPO_TOKEN."
91+
exit 1
92+
fi
93+
94+
- name: Set example app version from the release tag
95+
working-directory: ./example
96+
env:
97+
TAG: ${{ needs.check.outputs.tag }}
98+
run: |
99+
version="${TAG#v}"
100+
version="${version%%-*}"
101+
102+
if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
103+
echo "::error::Could not read a major.minor.patch version from $TAG."
104+
exit 1
105+
fi
106+
107+
jq --arg version "$version" '.expo.version = $version' app.json > app.json.tmp
108+
mv app.json.tmp app.json
109+
110+
- name: Decide between an update and a build
111+
id: decide
112+
working-directory: ./example
113+
env:
114+
PROFILE: ${{ needs.check.outputs.profile }}
115+
PLATFORM: ${{ inputs.platform || 'all' }}
116+
run: |
117+
platforms="$PLATFORM"
118+
if [ "$PLATFORM" = "all" ]; then
119+
platforms="ios android"
120+
fi
121+
122+
build=()
123+
update=()
124+
for platform in $platforms; do
125+
hash=$(npx expo-updates fingerprint:generate --platform "$platform" | jq -er .hash)
126+
127+
existing=$(eas build:list \
128+
--platform "$platform" \
129+
--channel "$PROFILE" \
130+
--fingerprint-hash "$hash" \
131+
--status finished \
132+
--limit 1 \
133+
--json \
134+
--non-interactive | jq 'if type == "array" then length else "notanarray" end')
135+
136+
case "$existing" in
137+
''|*[!0-9]*)
138+
echo "::error::Could not read builds for $platform."
139+
exit 1
140+
;;
141+
esac
142+
143+
echo "$platform: fingerprint $hash, $existing matching build(s)"
144+
if [ "$existing" -eq 0 ]; then
145+
build+=("$platform")
146+
else
147+
update+=("$platform")
148+
fi
149+
done
150+
151+
# EAS takes a single platform or "all"
152+
to_platform() {
153+
case $# in
154+
0) echo "" ;;
155+
1) echo "$1" ;;
156+
*) echo all ;;
157+
esac
158+
}
159+
160+
echo "build=$(to_platform "${build[@]}")" >> "$GITHUB_OUTPUT"
161+
echo "update=$(to_platform "${update[@]}")" >> "$GITHUB_OUTPUT"
162+
echo "Building: ${build[*]:-none}. Updating: ${update[*]:-none}."
163+
164+
- name: Publish an update
165+
# Before the build, so a slow or timed-out build can't hold up the update
166+
if: steps.decide.outputs.update != ''
167+
working-directory: ./example
168+
env:
169+
PROFILE: ${{ needs.check.outputs.profile }}
170+
PLATFORM: ${{ steps.decide.outputs.update }}
171+
TAG: ${{ needs.check.outputs.tag }}
172+
run: |
173+
eas update \
174+
--channel "$PROFILE" \
175+
--platform "$PLATFORM" \
176+
--message "$TAG" \
177+
--non-interactive
178+
179+
echo "Published an OTA update for $TAG for $PLATFORM on the $PROFILE channel." >> "$GITHUB_STEP_SUMMARY"
180+
181+
- name: Build and submit to the app stores
182+
# Don't let a failed update for one platform skip the other's build
183+
if: ${{ !cancelled() && steps.decide.outputs.build != '' }}
184+
working-directory: ./example
185+
env:
186+
PROFILE: ${{ needs.check.outputs.profile }}
187+
PLATFORM: ${{ steps.decide.outputs.build }}
188+
TAG: ${{ needs.check.outputs.tag }}
189+
run: |
190+
eas build \
191+
--platform "$PLATFORM" \
192+
--profile "$PROFILE" \
193+
--auto-submit-with-profile "$PROFILE" \
194+
--non-interactive
195+
196+
echo "Built and submitted $TAG for $PLATFORM using the $PROFILE profile." >> "$GITHUB_STEP_SUMMARY"
197+
198+
- name: Report failure
199+
if: failure() || cancelled()
200+
env:
201+
TAG: ${{ needs.check.outputs.tag }}
202+
run: |
203+
echo "::error::Example app deployment failed for $TAG. The release itself is fine, only the demo app did not update."
204+
echo "Check EAS and the stores before re-running, a build may still be running or already submitted. Re-running before it finishes starts a second build." >> "$GITHUB_STEP_SUMMARY"
205+
echo "If the build finished but the store upload failed, resubmit that build with \`eas submit --id <build id>\`. Re-running this workflow will publish an OTA update instead." >> "$GITHUB_STEP_SUMMARY"
206+
echo "If only the update failed, re-running is safe." >> "$GITHUB_STEP_SUMMARY"

‎.github/workflows/updates.yml‎

Lines changed: 0 additions & 27 deletions
This file was deleted.

‎CONTRIBUTING.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ yarn release
109109

110110
NOTE: You must have a `GITHUB_TOKEN` environment variable available. You can create a GitHub access token with the "repo" access [here](https://github.com/settings/tokens).
111111

112+
Releases from main also deploy the example app via EAS. If there were no native code changes, it'll ship an OTA update.
113+
114+
Otherwise, for stable releases:
115+
116+
- The Android build is automatically submitted to Google Play for review
117+
- The iOS build is automatically uploaded to App Store Connect and needs to be manually submitted for review
118+
119+
Pre-release builds are shipped to the internal track on Google Play for Android and TestFlight for iOS. They shouldn't be submitted to the App Store for review, or the stable build with the same version would be rejected.
120+
112121
## Reporting issues
113122

114123
You can report issues on our [bug tracker](https://github.com/callstack/react-native-paper/issues). Please follow the issue template when opening an issue.

‎example/.fingerprintignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# built by pod install, would make the fingerprint differ from the one EAS computes
2+
**/expo-modules-jsi/apple/Products/**

‎example/.nvmrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../.nvmrc

‎example/app.json‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616
"owner": "react-native-paper",
1717
"ios": {
1818
"bundleIdentifier": "com.callstack.reactnativepaperexample",
19-
"buildNumber": "26.0.5"
19+
"infoPlist": {
20+
"ITSAppUsesNonExemptEncryption": false
21+
}
2022
},
2123
"android": {
2224
"package": "com.callstack.reactnativepaperexample",
23-
"versionCode": 38,
2425
"permissions": [
2526
"READ_EXTERNAL_STORAGE",
2627
"WRITE_EXTERNAL_STORAGE"
@@ -37,7 +38,7 @@
3738
}
3839
},
3940
"runtimeVersion": {
40-
"policy": "sdkVersion"
41+
"policy": "fingerprint"
4142
},
4243
"updates": {
4344
"url": "https://u.expo.dev/ba8f5139-58fe-48f0-b2f2-2d675b6eb2d4"

‎example/eas.json‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"cli": {
3+
"appVersionSource": "remote"
4+
},
5+
"build": {
6+
"production": {
7+
"autoIncrement": true,
8+
"android": {
9+
"buildType": "app-bundle"
10+
},
11+
"channel": "production"
12+
},
13+
"preview": {
14+
"extends": "production",
15+
"channel": "preview"
16+
}
17+
},
18+
"submit": {
19+
"production": {
20+
"android": {
21+
"track": "production"
22+
},
23+
"ios": {
24+
"ascAppId": "1548934513"
25+
}
26+
},
27+
"preview": {
28+
"extends": "production",
29+
"android": {
30+
"track": "internal"
31+
}
32+
}
33+
}
34+
}

‎example/fingerprint.config.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** @type {import('expo/fingerprint').Config} */
2+
module.exports = {
3+
sourceSkips: [
4+
'ExpoConfigVersions',
5+
'PackageJsonAndroidAndIosScriptsIfNotContainRun',
6+
],
7+
};

0 commit comments

Comments
 (0)