From 4f76fd0ae0a148058b5e9bdf5bd14dd3f7cfb487 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 14 Sep 2026 21:35:37 +0200 Subject: [PATCH 01/11] 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) --- .github/workflows/publish-example-app.yml | 261 ++++++++++++++++++ .github/workflows/updates.yml | 27 -- CONTRIBUTING.md | 22 ++ example/app.json | 2 +- example/eas.json | 35 +++ .../__tests__/bump-example-version.test.ts | 98 +++++++ scripts/bump-example-version.ts | 33 +++ 7 files changed, 450 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/publish-example-app.yml delete mode 100644 .github/workflows/updates.yml create mode 100644 example/eas.json create mode 100644 scripts/__tests__/bump-example-version.test.ts create mode 100644 scripts/bump-example-version.ts diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml new file mode 100644 index 0000000000..62335fe6de --- /dev/null +++ b/.github/workflows/publish-example-app.yml @@ -0,0 +1,261 @@ +name: Publish example app + +# Publishes the example app that demonstrates the library to the App Store and +# Play Store. This runs *after* a library release already exists, and cannot +# affect it: see the failure report in the `report-failure` job below. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to publish the example app for, e.g. v6.0.0 + required: true + platform: + description: Platforms to build and submit. Pick one to retry after a partial failure, so the platform that already shipped is not submitted twice. + required: false + default: all + type: choice + options: + - all + - ios + - android + +permissions: + contents: read + +concurrency: + group: publish-example-app + +jobs: + publish: + name: Build and submit example app + runs-on: ubuntu-latest + # An EAS build for two platforms is slow, but not this slow. Without a + # timeout a stuck run holds the concurrency lock for six hours. + timeout-minutes: 120 + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + + - name: Setup + uses: ./.github/actions/setup + + - name: Resolve release tag and submit profile + id: release + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + PLATFORM: ${{ inputs.platform }} + run: | + if [ -z "$TAG" ]; then + echo "::error::No release tag to publish the example app for." + exit 1 + fi + + # A semver version with a prerelease identifier (v6.0.0-alpha.1) goes + # to the test tracks, a stable one (v6.0.0) goes to production. This + # is derived from the tag rather than from the release's prerelease + # flag on purpose: .release-it.json currently pins every release to + # `preRelease: true` for the 6.0 alpha cycle, so that flag stays true + # for a stable release until somebody remembers to revert it. + if [ "${TAG#*-}" != "$TAG" ]; then + profile=preview + else + profile=production + fi + + # A release always publishes both platforms. Only a manual retry can + # narrow it, to avoid resubmitting a platform that already shipped. + platform=${PLATFORM:-all} + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "profile=$profile" >> "$GITHUB_OUTPUT" + echo "platform=$platform" >> "$GITHUB_OUTPUT" + echo "Publishing $platform for $TAG using the $profile submit profile." + + - name: Setup Expo + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + # A release that changes no native code can ship over the air instead of + # going through a store. `runtimeVersion` is on the fingerprint policy, so + # a build and an update only match when their native side is identical. + - name: Decide between an update and a build + id: decide + working-directory: ./example + env: + PROFILE: ${{ steps.release.outputs.profile }} + PLATFORM: ${{ steps.release.outputs.platform }} + run: | + platforms="$PLATFORM" + if [ "$PLATFORM" = "all" ]; then + platforms="ios android" + fi + + build=false + for platform in $platforms; do + hash=$(npx expo-updates fingerprint:generate --platform "$platform" | jq -r '.hash') + + # A build already on the store with this exact native fingerprint can + # run the new JavaScript as it is. + existing=$(eas build:list \ + --platform "$platform" \ + --channel "$PROFILE" \ + --fingerprint-hash "$hash" \ + --status finished \ + --limit 1 \ + --json \ + --non-interactive | jq 'length') + + echo "$platform: fingerprint $hash, $existing matching build(s)" + [ "$existing" -eq 0 ] && build=true + done + + echo "build=$build" >> "$GITHUB_OUTPUT" + if [ "$build" = true ]; then + echo "Native code changed, building and submitting." + else + echo "No native change, publishing an update instead." + fi + + # Only a build carries a version to the stores, so only a build bumps one. + - name: Bump example app version + if: steps.decide.outputs.build == 'true' + run: node scripts/bump-example-version.ts example/app.json + + # Build numbers are assigned remotely by EAS (`appVersionSource: remote`), + # so a retry can never collide with an already submitted build. + - name: Build and submit to the app stores + id: submit + if: steps.decide.outputs.build == 'true' + working-directory: ./example + env: + PROFILE: ${{ steps.release.outputs.profile }} + PLATFORM: ${{ steps.release.outputs.platform }} + run: | + eas build \ + --platform "$PLATFORM" \ + --profile "$PROFILE" \ + --auto-submit-with-profile "$PROFILE" \ + --non-interactive + + - name: Publish an update + id: update + if: steps.decide.outputs.build == 'false' + working-directory: ./example + env: + PROFILE: ${{ steps.release.outputs.profile }} + TAG: ${{ steps.release.outputs.tag }} + run: | + eas update \ + --branch "$PROFILE" \ + --message "$TAG" \ + --non-interactive + + # Runs whenever the submit step ran at all, however it ended. `eas build` + # can ship one platform and fail the other, and cancelling the workflow + # does not cancel the build on EAS, so in every one of those cases a + # version may have reached a store and has to be recorded. + - name: Open example app version bump pull request + if: ${{ always() && (steps.submit.outcome == 'success' || steps.submit.outcome == 'failure' || steps.submit.outcome == 'cancelled') }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: main + add-paths: example/app.json + branch: chore/example-app-version-${{ steps.release.outputs.tag }} + commit-message: 'chore: bump example app version for ${{ steps.release.outputs.tag }}' + title: 'chore: bump example app version for ${{ steps.release.outputs.tag }}' + labels: example app + body: | + Records the example app version this run used for + ${{ steps.release.outputs.tag }}. + + A build for that version may already have been submitted, so this only + keeps `example/app.json` in sync. iOS build numbers and Android version codes are not in here: + EAS assigns those remotely. + + - name: Summarise + if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success') }} + env: + TAG: ${{ steps.release.outputs.tag }} + PROFILE: ${{ steps.release.outputs.profile }} + BUILT: ${{ steps.decide.outputs.build }} + run: | + { + if [ "$BUILT" = "true" ]; then + echo "### Example app submitted for \`$TAG\`" + echo + echo "Native code changed, so this went to the stores." + if [ "$PROFILE" = "production" ]; then + echo "- Android: Play Store **production** track." + else + echo "- Android: Play Store **internal** track." + fi + echo "- iOS: uploaded to App Store Connect, available in TestFlight." + echo + echo "Releasing an iOS build from TestFlight to the App Store is a" + echo "separate manual step in App Store Connect." + else + echo "### Example app updated over the air for \`$TAG\`" + echo + echo "No native code changed, so this shipped as an update on the" + echo "\`$PROFILE\` channel rather than going through the stores." + echo "Installed apps pick it up on next launch. The store listing" + echo "and its version are unchanged." + fi + } >> "$GITHUB_STEP_SUMMARY" + + report-failure: + name: Report example app deployment problem + needs: publish + # Not `failure()`: that misses a cancelled run, and cancellation is the case + # most likely to leave a build running on EAS with nobody told about it. + if: ${{ always() && needs.publish.result != 'success' }} + runs-on: ubuntu-latest + steps: + # Whoever published the release is already emailed that this run failed. + # What that email cannot tell them is that their release is fine, so say + # it here, where they land when they follow it. + - name: Say that the library release is unaffected + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + echo "::error::The example app deployment for $TAG did not finish. The $TAG library release itself published successfully and is not affected." + + cat >> "$GITHUB_STEP_SUMMARY" < { + const appJsonPath = join(workingDir, 'app.json'); + + writeFileSync( + appJsonPath, + JSON.stringify({ expo: { name: 'Example', version } }, null, 2) + '\n' + ); + + return appJsonPath; +}; + +// stderr is captured rather than inherited so that the messages from the +// expected failures do not land in the test output. +const run = (appJsonPath: string) => + execFileSync('node', [script, appJsonPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + +const readVersion = (appJsonPath: string) => + JSON.parse(readFileSync(appJsonPath, 'utf8')).expo.version; + +beforeEach(() => { + workingDir = mkdtempSync(join(tmpdir(), 'bump-example-version-')); +}); + +afterEach(() => { + rmSync(workingDir, { recursive: true, force: true }); +}); + +it('bumps the minor version', () => { + const appJsonPath = writeAppJson('3.16.0'); + + run(appJsonPath); + + expect(readVersion(appJsonPath)).toBe('3.17.0'); +}); + +it('resets the patch version when bumping the minor version', () => { + const appJsonPath = writeAppJson('3.16.5'); + + run(appJsonPath); + + expect(readVersion(appJsonPath)).toBe('3.17.0'); +}); + +it('prints the version it bumped to', () => { + const appJsonPath = writeAppJson('3.16.0'); + + expect(run(appJsonPath)).toContain('3.17.0'); +}); + +it('leaves the rest of the app config untouched', () => { + const appJsonPath = writeAppJson('3.16.0'); + + run(appJsonPath); + + expect(JSON.parse(readFileSync(appJsonPath, 'utf8')).expo.name).toBe( + 'Example' + ); +}); + +it('increments the minor version past a single digit', () => { + const appJsonPath = writeAppJson('3.9.0'); + + run(appJsonPath); + + expect(readVersion(appJsonPath)).toBe('3.10.0'); +}); + +it('fails when no app config path is given', () => { + expect(() => + execFileSync('node', [script], { stdio: ['ignore', 'pipe', 'pipe'] }) + ).toThrow(/Usage/); +}); + +it('fails when the app config has no expo section', () => { + const appJsonPath = join(workingDir, 'app.json'); + writeFileSync(appJsonPath, JSON.stringify({}) + '\n'); + + expect(() => run(appJsonPath)).toThrow(/got undefined/); +}); + +it('fails when the current version is not a valid version', () => { + const appJsonPath = writeAppJson('not-a-version'); + + expect(() => run(appJsonPath)).toThrow(/got "not-a-version"/); +}); diff --git a/scripts/bump-example-version.ts b/scripts/bump-example-version.ts new file mode 100644 index 0000000000..727eb408ca --- /dev/null +++ b/scripts/bump-example-version.ts @@ -0,0 +1,33 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +type AppConfig = { expo?: { version?: string } }; + +const appConfigPath = process.argv[2]; + +if (!appConfigPath) { + console.error( + 'Usage: node scripts/bump-example-version.ts ' + ); + process.exit(1); +} + +const appConfig: AppConfig = JSON.parse(readFileSync(appConfigPath, 'utf8')); +const expo = appConfig.expo; +const currentVersion = expo?.version; +const parsed = /^(\d+)\.(\d+)\.(\d+)$/.exec(currentVersion ?? ''); + +if (!expo || !parsed) { + console.error( + `Expected a "major.minor.patch" version at expo.version in ${appConfigPath}, got ${JSON.stringify(currentVersion)}.` + ); + process.exit(1); +} + +const [, major, minor] = parsed; +const nextVersion = `${major}.${Number(minor) + 1}.0`; + +expo.version = nextVersion; + +writeFileSync(appConfigPath, JSON.stringify(appConfig, null, 2) + '\n'); + +console.log(`Bumped example app version to ${nextVersion}`); From 8047374e9eb9f0949b25caca33ae19b0b88647ba Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 23 Sep 2026 08:09:29 +0200 Subject: [PATCH 02/11] 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) --- .github/workflows/publish-example-app.yml | 38 ++++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index 62335fe6de..ca3057df47 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -21,6 +21,11 @@ on: - all - ios - android + dry-run: + description: Only check credentials and report what would ship. Builds nothing and submits nothing. + required: false + default: false + type: boolean permissions: contents: read @@ -52,6 +57,7 @@ jobs: env: TAG: ${{ github.event.release.tag_name || inputs.tag }} PLATFORM: ${{ inputs.platform }} + DRY_RUN: ${{ inputs.dry-run }} run: | if [ -z "$TAG" ]; then echo "::error::No release tag to publish the example app for." @@ -77,6 +83,7 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "profile=$profile" >> "$GITHUB_OUTPUT" echo "platform=$platform" >> "$GITHUB_OUTPUT" + echo "dry-run=${DRY_RUN:-false}" >> "$GITHUB_OUTPUT" echo "Publishing $platform for $TAG using the $profile submit profile." - name: Setup Expo @@ -85,6 +92,14 @@ jobs: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} + - name: Check Expo authentication + working-directory: ./example + run: | + if ! eas whoami; then + echo "::error::Could not authenticate with Expo. EXPO_TOKEN is missing, expired, or lacks access to the react-native-paper organisation." + exit 1 + fi + # A release that changes no native code can ship over the air instead of # going through a store. `runtimeVersion` is on the fingerprint policy, so # a build and an update only match when their native side is identical. @@ -128,14 +143,14 @@ jobs: # Only a build carries a version to the stores, so only a build bumps one. - name: Bump example app version - if: steps.decide.outputs.build == 'true' + if: ${{ steps.decide.outputs.build == 'true' && steps.release.outputs.dry-run != 'true' }} run: node scripts/bump-example-version.ts example/app.json # Build numbers are assigned remotely by EAS (`appVersionSource: remote`), # so a retry can never collide with an already submitted build. - name: Build and submit to the app stores id: submit - if: steps.decide.outputs.build == 'true' + if: ${{ steps.decide.outputs.build == 'true' && steps.release.outputs.dry-run != 'true' }} working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} @@ -149,7 +164,7 @@ jobs: - name: Publish an update id: update - if: steps.decide.outputs.build == 'false' + if: ${{ steps.decide.outputs.build == 'false' && steps.release.outputs.dry-run != 'true' }} working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} @@ -184,14 +199,27 @@ jobs: EAS assigns those remotely. - name: Summarise - if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success') }} + if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success' || steps.release.outputs.dry-run == 'true') }} env: TAG: ${{ steps.release.outputs.tag }} PROFILE: ${{ steps.release.outputs.profile }} BUILT: ${{ steps.decide.outputs.build }} + DRY_RUN: ${{ steps.release.outputs.dry-run }} run: | { - if [ "$BUILT" = "true" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "### Dry run for \`$TAG\`" + echo + echo "Expo authentication works, and nothing was built or submitted." + if [ "$BUILT" = "true" ]; then + echo "A real run would **build and submit**: no existing build" + echo "matches the current native fingerprint." + else + echo "A real run would **publish an update** on the \`$PROFILE\`" + echo "channel: the native side already matches a build that is out" + echo "there." + fi + elif [ "$BUILT" = "true" ]; then echo "### Example app submitted for \`$TAG\`" echo echo "Native code changed, so this went to the stores." From 977d00682018b84a2beffdbdaa2d2d9598f8440f Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 25 Sep 2026 11:49:51 +0200 Subject: [PATCH 03/11] 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) --- example/.fingerprintignore | 7 +++++++ example/app.json | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 example/.fingerprintignore diff --git a/example/.fingerprintignore b/example/.fingerprintignore new file mode 100644 index 0000000000..ef2b1ab40c --- /dev/null +++ b/example/.fingerprintignore @@ -0,0 +1,7 @@ +# Generated by `pod install`, not source. +# +# ExpoModulesJSI.podspec has a prepare_command that builds an xcframework into +# this directory. EAS fingerprints after installing pods and the CLI fingerprints +# before, so without this the two disagree and a build fails with a runtime +# version mismatch. The sources it is built from are fingerprinted already. +node_modules/expo-modules-jsi/apple/Products/ diff --git a/example/app.json b/example/app.json index f310353ae6..4dd835b98f 100644 --- a/example/app.json +++ b/example/app.json @@ -16,11 +16,12 @@ "owner": "react-native-paper", "ios": { "bundleIdentifier": "com.callstack.reactnativepaperexample", - "buildNumber": "26.0.5" + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "android": { "package": "com.callstack.reactnativepaperexample", - "versionCode": 38, "permissions": [ "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE" From 1342c6e4853d4843a558ebccb86099103e4265da Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Fri, 25 Sep 2026 15:04:46 +0200 Subject: [PATCH 04/11] 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) --- .github/workflows/publish-example-app.yml | 30 ++++------------------- CONTRIBUTING.md | 21 +++++----------- 2 files changed, 11 insertions(+), 40 deletions(-) diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index ca3057df47..7e8b0c41dd 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -21,11 +21,6 @@ on: - all - ios - android - dry-run: - description: Only check credentials and report what would ship. Builds nothing and submits nothing. - required: false - default: false - type: boolean permissions: contents: read @@ -57,7 +52,6 @@ jobs: env: TAG: ${{ github.event.release.tag_name || inputs.tag }} PLATFORM: ${{ inputs.platform }} - DRY_RUN: ${{ inputs.dry-run }} run: | if [ -z "$TAG" ]; then echo "::error::No release tag to publish the example app for." @@ -83,7 +77,6 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "profile=$profile" >> "$GITHUB_OUTPUT" echo "platform=$platform" >> "$GITHUB_OUTPUT" - echo "dry-run=${DRY_RUN:-false}" >> "$GITHUB_OUTPUT" echo "Publishing $platform for $TAG using the $profile submit profile." - name: Setup Expo @@ -143,14 +136,14 @@ jobs: # Only a build carries a version to the stores, so only a build bumps one. - name: Bump example app version - if: ${{ steps.decide.outputs.build == 'true' && steps.release.outputs.dry-run != 'true' }} + if: steps.decide.outputs.build == 'true' run: node scripts/bump-example-version.ts example/app.json # Build numbers are assigned remotely by EAS (`appVersionSource: remote`), # so a retry can never collide with an already submitted build. - name: Build and submit to the app stores id: submit - if: ${{ steps.decide.outputs.build == 'true' && steps.release.outputs.dry-run != 'true' }} + if: steps.decide.outputs.build == 'true' working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} @@ -164,7 +157,7 @@ jobs: - name: Publish an update id: update - if: ${{ steps.decide.outputs.build == 'false' && steps.release.outputs.dry-run != 'true' }} + if: steps.decide.outputs.build == 'false' working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} @@ -199,27 +192,14 @@ jobs: EAS assigns those remotely. - name: Summarise - if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success' || steps.release.outputs.dry-run == 'true') }} + if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success') }} env: TAG: ${{ steps.release.outputs.tag }} PROFILE: ${{ steps.release.outputs.profile }} BUILT: ${{ steps.decide.outputs.build }} - DRY_RUN: ${{ steps.release.outputs.dry-run }} run: | { - if [ "$DRY_RUN" = "true" ]; then - echo "### Dry run for \`$TAG\`" - echo - echo "Expo authentication works, and nothing was built or submitted." - if [ "$BUILT" = "true" ]; then - echo "A real run would **build and submit**: no existing build" - echo "matches the current native fingerprint." - else - echo "A real run would **publish an update** on the \`$PROFILE\`" - echo "channel: the native side already matches a build that is out" - echo "there." - fi - elif [ "$BUILT" = "true" ]; then + if [ "$BUILT" = "true" ]; then echo "### Example app submitted for \`$TAG\`" echo echo "Native code changed, so this went to the stores." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e71ad11477..ae1a759277 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,25 +111,16 @@ NOTE: You must have a `GITHUB_TOKEN` environment variable available. You can cre ### Publishing the example app -Publishing a release also ships the example app, through the `Publish example app` workflow. It runs after the release already exists, so it cannot affect one: if it fails, the run page says so, and the release is still fine. +Publishing a release also ships the example app, through the `Publish example app` workflow. If no native code changed it goes out as an over-the-air update; otherwise it builds and submits, prereleases to the Play internal track and TestFlight and stable releases to Play production. -What it ships depends on whether native code changed. `runtimeVersion` uses the fingerprint policy, so the workflow compares the project's fingerprint against the builds already out there: +Two things are worth knowing because nothing will tell you otherwise: -- **Nothing native changed**: publishes an update over the air with `eas update`. Installed apps pick it up on next launch, and nothing goes near a store. -- **Native code changed**: builds and submits. Prereleases go to the Play internal track and TestFlight, stable releases to Play production. On iOS, `eas submit` uploads to App Store Connect and promoting the build to the App Store stays manual. +- **Nothing reaches the App Store on its own.** `eas submit` uploads to App Store Connect and the build waits in TestFlight until somebody promotes it there. +- **Merge the version bump pull request before the next release.** The bump is worked out from `main`, so two releases either side of an unmerged one ship under the same version and the stores take both. -Only the build path bumps the example app's version, and that bump comes back as a pull request. Merge it before the next release, or two releases start from the same version and ship under it. +`EXPO_TOKEN` is the only secret this repository holds. The signing and store credentials live in EAS. -Prereleases and stable releases use separate channels (`preview` and `production`), so an update published for an alpha cannot reach people running the stable app. - -The workflow needs an `EXPO_TOKEN` secret, and the store credentials configured in EAS rather than here (`eas credentials` from `example/`: an App Store Connect API key, and a Google Play service account key). Build numbers are assigned remotely by EAS, so before the first run they need seeding above the values currently in `example/app.json`: - -```sh -eas build:version:set --platform android -eas build:version:set --platform ios -``` - -To rerun after a failure, use **Actions → Publish example app → Run workflow**. Check the stores first: a build may already have been submitted, and cancelling the workflow does not cancel one already running on EAS. If only one platform failed, pick it in the `platform` input so the other is not submitted twice. To withdraw a build that did ship, halt the rollout in the Play Console or reject it in App Store Connect. +Whether a release becomes an update or a build is decided by its fingerprint, which has to match the one EAS works out after installing pods. Anything `pod install` writes into a fingerprinted directory belongs in `example/.fingerprintignore`, as `expo-modules-jsi` already does. Generated output only, so a real native change is still noticed. ## Reporting issues From 8e2440e89c1f24072d8799e58cf51f66236a5480 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 29 Sep 2026 09:10:17 +0200 Subject: [PATCH 05/11] chore: replace AI prose with human comments, trim tests --- .github/workflows/publish-example-app.yml | 34 ------- CONTRIBUTING.md | 13 +-- example/.fingerprintignore | 7 +- .../__tests__/bump-example-version.test.ts | 88 +++++-------------- 4 files changed, 22 insertions(+), 120 deletions(-) diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index 7e8b0c41dd..341f79c4ae 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -1,9 +1,5 @@ name: Publish example app -# Publishes the example app that demonstrates the library to the App Store and -# Play Store. This runs *after* a library release already exists, and cannot -# affect it: see the failure report in the `report-failure` job below. - on: release: types: [published] @@ -32,8 +28,6 @@ jobs: publish: name: Build and submit example app runs-on: ubuntu-latest - # An EAS build for two platforms is slow, but not this slow. Without a - # timeout a stuck run holds the concurrency lock for six hours. timeout-minutes: 120 permissions: contents: write @@ -58,20 +52,12 @@ jobs: exit 1 fi - # A semver version with a prerelease identifier (v6.0.0-alpha.1) goes - # to the test tracks, a stable one (v6.0.0) goes to production. This - # is derived from the tag rather than from the release's prerelease - # flag on purpose: .release-it.json currently pins every release to - # `preRelease: true` for the 6.0 alpha cycle, so that flag stays true - # for a stable release until somebody remembers to revert it. if [ "${TAG#*-}" != "$TAG" ]; then profile=preview else profile=production fi - # A release always publishes both platforms. Only a manual retry can - # narrow it, to avoid resubmitting a platform that already shipped. platform=${PLATFORM:-all} echo "tag=$TAG" >> "$GITHUB_OUTPUT" @@ -93,9 +79,6 @@ jobs: exit 1 fi - # A release that changes no native code can ship over the air instead of - # going through a store. `runtimeVersion` is on the fingerprint policy, so - # a build and an update only match when their native side is identical. - name: Decide between an update and a build id: decide working-directory: ./example @@ -112,8 +95,6 @@ jobs: for platform in $platforms; do hash=$(npx expo-updates fingerprint:generate --platform "$platform" | jq -r '.hash') - # A build already on the store with this exact native fingerprint can - # run the new JavaScript as it is. existing=$(eas build:list \ --platform "$platform" \ --channel "$PROFILE" \ @@ -134,13 +115,10 @@ jobs: echo "No native change, publishing an update instead." fi - # Only a build carries a version to the stores, so only a build bumps one. - name: Bump example app version if: steps.decide.outputs.build == 'true' run: node scripts/bump-example-version.ts example/app.json - # Build numbers are assigned remotely by EAS (`appVersionSource: remote`), - # so a retry can never collide with an already submitted build. - name: Build and submit to the app stores id: submit if: steps.decide.outputs.build == 'true' @@ -168,10 +146,6 @@ jobs: --message "$TAG" \ --non-interactive - # Runs whenever the submit step ran at all, however it ended. `eas build` - # can ship one platform and fail the other, and cancelling the workflow - # does not cancel the build on EAS, so in every one of those cases a - # version may have reached a store and has to be recorded. - name: Open example app version bump pull request if: ${{ always() && (steps.submit.outcome == 'success' || steps.submit.outcome == 'failure' || steps.submit.outcome == 'cancelled') }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 @@ -225,14 +199,9 @@ jobs: report-failure: name: Report example app deployment problem needs: publish - # Not `failure()`: that misses a cancelled run, and cancellation is the case - # most likely to leave a build running on EAS with nobody told about it. if: ${{ always() && needs.publish.result != 'success' }} runs-on: ubuntu-latest steps: - # Whoever published the release is already emailed that this run failed. - # What that email cannot tell them is that their release is fine, so say - # it here, where they land when they follow it. - name: Say that the library release is unaffected env: TAG: ${{ github.event.release.tag_name || inputs.tag }} @@ -240,7 +209,6 @@ jobs: echo "::error::The example app deployment for $TAG did not finish. The $TAG library release itself published successfully and is not affected." cat >> "$GITHUB_STEP_SUMMARY" < { - const appJsonPath = join(workingDir, 'app.json'); +const writeAppConfig = (version: string) => { + const directory = mkdtempSync(join(tmpdir(), 'bump-example-version-')); + directories.push(directory); + const appConfigPath = join(directory, 'app.json'); writeFileSync( - appJsonPath, + appConfigPath, JSON.stringify({ expo: { name: 'Example', version } }, null, 2) + '\n' ); - return appJsonPath; + return appConfigPath; }; -// stderr is captured rather than inherited so that the messages from the -// expected failures do not land in the test output. -const run = (appJsonPath: string) => - execFileSync('node', [script, appJsonPath], { - encoding: 'utf8', +const bump = (appConfigPath: string) => { + execFileSync('node', [script, appConfigPath], { stdio: ['ignore', 'pipe', 'pipe'], }); -const readVersion = (appJsonPath: string) => - JSON.parse(readFileSync(appJsonPath, 'utf8')).expo.version; - -beforeEach(() => { - workingDir = mkdtempSync(join(tmpdir(), 'bump-example-version-')); -}); + return JSON.parse(readFileSync(appConfigPath, 'utf8')).expo.version; +}; afterEach(() => { - rmSync(workingDir, { recursive: true, force: true }); + directories.splice(0).forEach((directory) => { + rmSync(directory, { recursive: true, force: true }); + }); }); it('bumps the minor version', () => { - const appJsonPath = writeAppJson('3.16.0'); - - run(appJsonPath); - - expect(readVersion(appJsonPath)).toBe('3.17.0'); -}); - -it('resets the patch version when bumping the minor version', () => { - const appJsonPath = writeAppJson('3.16.5'); - - run(appJsonPath); - - expect(readVersion(appJsonPath)).toBe('3.17.0'); -}); - -it('prints the version it bumped to', () => { - const appJsonPath = writeAppJson('3.16.0'); - - expect(run(appJsonPath)).toContain('3.17.0'); -}); - -it('leaves the rest of the app config untouched', () => { - const appJsonPath = writeAppJson('3.16.0'); - - run(appJsonPath); - - expect(JSON.parse(readFileSync(appJsonPath, 'utf8')).expo.name).toBe( - 'Example' - ); + expect(bump(writeAppConfig('3.16.0'))).toBe('3.17.0'); }); it('increments the minor version past a single digit', () => { - const appJsonPath = writeAppJson('3.9.0'); - - run(appJsonPath); - - expect(readVersion(appJsonPath)).toBe('3.10.0'); -}); - -it('fails when no app config path is given', () => { - expect(() => - execFileSync('node', [script], { stdio: ['ignore', 'pipe', 'pipe'] }) - ).toThrow(/Usage/); -}); - -it('fails when the app config has no expo section', () => { - const appJsonPath = join(workingDir, 'app.json'); - writeFileSync(appJsonPath, JSON.stringify({}) + '\n'); - - expect(() => run(appJsonPath)).toThrow(/got undefined/); + expect(bump(writeAppConfig('3.9.0'))).toBe('3.10.0'); }); it('fails when the current version is not a valid version', () => { - const appJsonPath = writeAppJson('not-a-version'); - - expect(() => run(appJsonPath)).toThrow(/got "not-a-version"/); + expect(() => bump(writeAppConfig('not-a-version'))).toThrow( + /got "not-a-version"/ + ); }); From 8fb6c25e5187ccff82b2d7e06898805517c2d7a9 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 29 Sep 2026 09:30:28 +0200 Subject: [PATCH 06/11] chore: simplify deployment output logs --- .github/workflows/publish-example-app.yml | 75 ++++------------------- 1 file changed, 13 insertions(+), 62 deletions(-) diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index 341f79c4ae..5e5d544972 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -9,7 +9,7 @@ on: description: Release tag to publish the example app for, e.g. v6.0.0 required: true platform: - description: Platforms to build and submit. Pick one to retry after a partial failure, so the platform that already shipped is not submitted twice. + description: Platform to build. Defaults to all. required: false default: all type: choice @@ -75,7 +75,7 @@ jobs: working-directory: ./example run: | if ! eas whoami; then - echo "::error::Could not authenticate with Expo. EXPO_TOKEN is missing, expired, or lacks access to the react-native-paper organisation." + echo "::error::Could not authenticate with Expo, check EXPO_TOKEN." exit 1 fi @@ -146,7 +146,7 @@ jobs: --message "$TAG" \ --non-interactive - - name: Open example app version bump pull request + - name: Open version bump PR if: ${{ always() && (steps.submit.outcome == 'success' || steps.submit.outcome == 'failure' || steps.submit.outcome == 'cancelled') }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: @@ -157,13 +157,7 @@ jobs: commit-message: 'chore: bump example app version for ${{ steps.release.outputs.tag }}' title: 'chore: bump example app version for ${{ steps.release.outputs.tag }}' labels: example app - body: | - Records the example app version this run used for - ${{ steps.release.outputs.tag }}. - - A build for that version may already have been submitted, so this only - keeps `example/app.json` in sync. iOS build numbers and Android version codes are not in here: - EAS assigns those remotely. + body: Version bump for ${{ steps.release.outputs.tag }}. - name: Summarise if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success') }} @@ -172,64 +166,21 @@ jobs: PROFILE: ${{ steps.release.outputs.profile }} BUILT: ${{ steps.decide.outputs.build }} run: | - { - if [ "$BUILT" = "true" ]; then - echo "### Example app submitted for \`$TAG\`" - echo - echo "Native code changed, so this went to the stores." - if [ "$PROFILE" = "production" ]; then - echo "- Android: Play Store **production** track." - else - echo "- Android: Play Store **internal** track." - fi - echo "- iOS: uploaded to App Store Connect, available in TestFlight." - echo - echo "Releasing an iOS build from TestFlight to the App Store is a" - echo "separate manual step in App Store Connect." - else - echo "### Example app updated over the air for \`$TAG\`" - echo - echo "No native code changed, so this shipped as an update on the" - echo "\`$PROFILE\` channel rather than going through the stores." - echo "Installed apps pick it up on next launch. The store listing" - echo "and its version are unchanged." - fi - } >> "$GITHUB_STEP_SUMMARY" + if [ "$BUILT" = "true" ]; then + echo "Built and submitted $TAG using the $PROFILE profile." >> "$GITHUB_STEP_SUMMARY" + else + echo "Published an OTA update for $TAG on the $PROFILE channel." >> "$GITHUB_STEP_SUMMARY" + fi report-failure: - name: Report example app deployment problem + name: Report failure needs: publish if: ${{ always() && needs.publish.result != 'success' }} runs-on: ubuntu-latest steps: - - name: Say that the library release is unaffected + - name: Report failure env: TAG: ${{ github.event.release.tag_name || inputs.tag }} run: | - echo "::error::The example app deployment for $TAG did not finish. The $TAG library release itself published successfully and is not affected." - - cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" From 959a4ba0f8fe5b8b939c344a9df4d9d79e96284f Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 29 Sep 2026 20:52:15 +0200 Subject: [PATCH 07/11] fix: address review feedback on example app deployment --- .github/workflows/publish-example-app.yml | 37 +++++++++++++- CONTRIBUTING.md | 9 +++- example/.fingerprintignore | 2 +- example/fingerprint.config.js | 7 +++ .../__tests__/bump-example-version.test.ts | 50 ------------------- 5 files changed, 51 insertions(+), 54 deletions(-) create mode 100644 example/fingerprint.config.js delete mode 100644 scripts/__tests__/bump-example-version.test.ts diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index 5e5d544972..e98afb33ef 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -25,8 +25,31 @@ concurrency: group: publish-example-app jobs: + check: + name: Check release is on main + runs-on: ubuntu-latest + outputs: + on-main: ${{ steps.check.outputs.on-main }} + steps: + - id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + status=$(gh api "repos/$GH_REPO/compare/main...$TAG" --jq .status) + + if [ "$status" = "identical" ] || [ "$status" = "behind" ]; then + echo "on-main=true" >> "$GITHUB_OUTPUT" + else + echo "on-main=false" >> "$GITHUB_OUTPUT" + echo "$TAG is not on main ($status), nothing to deploy." + fi + publish: name: Build and submit example app + needs: check + if: needs.check.outputs.on-main == 'true' runs-on: ubuntu-latest timeout-minutes: 120 permissions: @@ -36,7 +59,7 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: main + ref: ${{ github.event.release.tag_name || inputs.tag }} - name: Setup uses: ./.github/actions/setup @@ -81,6 +104,7 @@ jobs: - name: Decide between an update and a build id: decide + shell: bash working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} @@ -104,6 +128,13 @@ jobs: --json \ --non-interactive | jq 'length') + case "$existing" in + ''|*[!0-9]*) + echo "::error::Could not read builds for $platform." + exit 1 + ;; + esac + echo "$platform: fingerprint $hash, $existing matching build(s)" [ "$existing" -eq 0 ] && build=true done @@ -139,10 +170,12 @@ jobs: working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} + PLATFORM: ${{ steps.release.outputs.platform }} TAG: ${{ steps.release.outputs.tag }} run: | eas update \ --branch "$PROFILE" \ + --platform "$PLATFORM" \ --message "$TAG" \ --non-interactive @@ -175,7 +208,7 @@ jobs: report-failure: name: Report failure needs: publish - if: ${{ always() && needs.publish.result != 'success' }} + if: ${{ always() && needs.publish.result != 'success' && needs.publish.result != 'skipped' }} runs-on: ubuntu-latest steps: - name: Report failure diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10f44bc66e..3e3fa7278f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,7 +109,14 @@ yarn release 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). -We use EAS for auto-deployments of the example app. Releases with no native changes ship OTA. Releases with native code changes will be pushed to Stores automatically and job will create a PR with version bump, merge it to avoid two releases shipping under the same version. Android will also be auto-submitted for review, iOS still requires manual submission. Pre-releases are shipped to internal track and TestFlight only. +Releases from main also deploy the example app via EAS. If there were no native code changes, it'll ship an OTA update. + +Otherwise, for stable releases: + +- The Android build is automatically submitted to Google Play for review +- The iOS build is automatically uploaded to App Store Connect and needs to be manually submitted for review + +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. ## Reporting issues diff --git a/example/.fingerprintignore b/example/.fingerprintignore index 1b7a849b9e..67f117a40a 100644 --- a/example/.fingerprintignore +++ b/example/.fingerprintignore @@ -1,2 +1,2 @@ -# prevents build failures due to version mismatch +# built by pod install, would make the fingerprint differ from the one EAS computes node_modules/expo-modules-jsi/apple/Products/ diff --git a/example/fingerprint.config.js b/example/fingerprint.config.js new file mode 100644 index 0000000000..501ccf5ba3 --- /dev/null +++ b/example/fingerprint.config.js @@ -0,0 +1,7 @@ +/** @type {import('expo/fingerprint').Config} */ +module.exports = { + sourceSkips: [ + 'ExpoConfigVersions', + 'PackageJsonAndroidAndIosScriptsIfNotContainRun', + ], +}; diff --git a/scripts/__tests__/bump-example-version.test.ts b/scripts/__tests__/bump-example-version.test.ts deleted file mode 100644 index cbaba4c8fe..0000000000 --- a/scripts/__tests__/bump-example-version.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterEach, expect, it } from '@jest/globals'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const script = join(__dirname, '..', 'bump-example-version.ts'); - -const directories: string[] = []; - -const writeAppConfig = (version: string) => { - const directory = mkdtempSync(join(tmpdir(), 'bump-example-version-')); - directories.push(directory); - const appConfigPath = join(directory, 'app.json'); - - writeFileSync( - appConfigPath, - JSON.stringify({ expo: { name: 'Example', version } }, null, 2) + '\n' - ); - - return appConfigPath; -}; - -const bump = (appConfigPath: string) => { - execFileSync('node', [script, appConfigPath], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - - return JSON.parse(readFileSync(appConfigPath, 'utf8')).expo.version; -}; - -afterEach(() => { - directories.splice(0).forEach((directory) => { - rmSync(directory, { recursive: true, force: true }); - }); -}); - -it('bumps the minor version', () => { - expect(bump(writeAppConfig('3.16.0'))).toBe('3.17.0'); -}); - -it('increments the minor version past a single digit', () => { - expect(bump(writeAppConfig('3.9.0'))).toBe('3.10.0'); -}); - -it('fails when the current version is not a valid version', () => { - expect(() => bump(writeAppConfig('not-a-version'))).toThrow( - /got "not-a-version"/ - ); -}); From 57b9cf86d85087d6b2d864be801ad2543f92137a Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 30 Sep 2026 11:31:06 +0200 Subject: [PATCH 08/11] refactor: set example app version from the release tag --- .github/workflows/publish-example-app.yml | 34 +++++++++++------------ scripts/bump-example-version.ts | 33 ---------------------- 2 files changed, 16 insertions(+), 51 deletions(-) delete mode 100644 scripts/bump-example-version.ts diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index e98afb33ef..1ed567b204 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -52,9 +52,6 @@ jobs: if: needs.check.outputs.on-main == 'true' runs-on: ubuntu-latest timeout-minutes: 120 - permissions: - contents: write - pull-requests: write steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -146,9 +143,23 @@ jobs: echo "No native change, publishing an update instead." fi - - name: Bump example app version + - name: Set example app version from the release tag if: steps.decide.outputs.build == 'true' - run: node scripts/bump-example-version.ts example/app.json + shell: bash + working-directory: ./example + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + version="${TAG#v}" + version="${version%%-*}" + + if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Could not read a major.minor.patch version from $TAG." + exit 1 + fi + + jq --arg version "$version" '.expo.version = $version' app.json > app.json.tmp + mv app.json.tmp app.json - name: Build and submit to the app stores id: submit @@ -179,19 +190,6 @@ jobs: --message "$TAG" \ --non-interactive - - name: Open version bump PR - if: ${{ always() && (steps.submit.outcome == 'success' || steps.submit.outcome == 'failure' || steps.submit.outcome == 'cancelled') }} - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - base: main - add-paths: example/app.json - branch: chore/example-app-version-${{ steps.release.outputs.tag }} - commit-message: 'chore: bump example app version for ${{ steps.release.outputs.tag }}' - title: 'chore: bump example app version for ${{ steps.release.outputs.tag }}' - labels: example app - body: Version bump for ${{ steps.release.outputs.tag }}. - - name: Summarise if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success') }} env: diff --git a/scripts/bump-example-version.ts b/scripts/bump-example-version.ts deleted file mode 100644 index 727eb408ca..0000000000 --- a/scripts/bump-example-version.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs'; - -type AppConfig = { expo?: { version?: string } }; - -const appConfigPath = process.argv[2]; - -if (!appConfigPath) { - console.error( - 'Usage: node scripts/bump-example-version.ts ' - ); - process.exit(1); -} - -const appConfig: AppConfig = JSON.parse(readFileSync(appConfigPath, 'utf8')); -const expo = appConfig.expo; -const currentVersion = expo?.version; -const parsed = /^(\d+)\.(\d+)\.(\d+)$/.exec(currentVersion ?? ''); - -if (!expo || !parsed) { - console.error( - `Expected a "major.minor.patch" version at expo.version in ${appConfigPath}, got ${JSON.stringify(currentVersion)}.` - ); - process.exit(1); -} - -const [, major, minor] = parsed; -const nextVersion = `${major}.${Number(minor) + 1}.0`; - -expo.version = nextVersion; - -writeFileSync(appConfigPath, JSON.stringify(appConfig, null, 2) + '\n'); - -console.log(`Bumped example app version to ${nextVersion}`); From 121126e7edc5d17c15a0f15ae77a21032344c7cf Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 30 Sep 2026 12:14:27 +0200 Subject: [PATCH 09/11] fix: harden the example app deployment workflow --- .github/workflows/publish-example-app.yml | 58 +++++++++++++---------- example/.fingerprintignore | 2 +- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index 1ed567b204..d0ca481096 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -21,6 +21,10 @@ on: permissions: contents: read +defaults: + run: + shell: bash + concurrency: group: publish-example-app @@ -30,6 +34,7 @@ jobs: runs-on: ubuntu-latest outputs: on-main: ${{ steps.check.outputs.on-main }} + sha: ${{ steps.check.outputs.sha }} steps: - id: check env: @@ -41,9 +46,10 @@ jobs: if [ "$status" = "identical" ] || [ "$status" = "behind" ]; then echo "on-main=true" >> "$GITHUB_OUTPUT" + echo "sha=$(gh api "repos/$GH_REPO/commits/$TAG" --jq .sha)" >> "$GITHUB_OUTPUT" else echo "on-main=false" >> "$GITHUB_OUTPUT" - echo "$TAG is not on main ($status), nothing to deploy." + echo "$TAG is not on main ($status), nothing to deploy." >> "$GITHUB_STEP_SUMMARY" fi publish: @@ -56,7 +62,7 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event.release.tag_name || inputs.tag }} + ref: ${{ needs.check.outputs.sha }} - name: Setup uses: ./.github/actions/setup @@ -99,9 +105,24 @@ jobs: exit 1 fi + - name: Set example app version from the release tag + working-directory: ./example + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + version="${TAG#v}" + version="${version%%-*}" + + if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Could not read a major.minor.patch version from $TAG." + exit 1 + fi + + jq --arg version "$version" '.expo.version = $version' app.json > app.json.tmp + mv app.json.tmp app.json + - name: Decide between an update and a build id: decide - shell: bash working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} @@ -112,6 +133,11 @@ jobs: platforms="ios android" fi + if [ -z "$platforms" ]; then + echo "::error::No platforms to check." + exit 1 + fi + build=false for platform in $platforms; do hash=$(npx expo-updates fingerprint:generate --platform "$platform" | jq -r '.hash') @@ -123,7 +149,7 @@ jobs: --status finished \ --limit 1 \ --json \ - --non-interactive | jq 'length') + --non-interactive | jq 'if type == "array" then length else "notanarray" end') case "$existing" in ''|*[!0-9]*) @@ -143,24 +169,6 @@ jobs: echo "No native change, publishing an update instead." fi - - name: Set example app version from the release tag - if: steps.decide.outputs.build == 'true' - shell: bash - working-directory: ./example - env: - TAG: ${{ steps.release.outputs.tag }} - run: | - version="${TAG#v}" - version="${version%%-*}" - - if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::Could not read a major.minor.patch version from $TAG." - exit 1 - fi - - jq --arg version "$version" '.expo.version = $version' app.json > app.json.tmp - mv app.json.tmp app.json - - name: Build and submit to the app stores id: submit if: steps.decide.outputs.build == 'true' @@ -185,7 +193,7 @@ jobs: TAG: ${{ steps.release.outputs.tag }} run: | eas update \ - --branch "$PROFILE" \ + --channel "$PROFILE" \ --platform "$PLATFORM" \ --message "$TAG" \ --non-interactive @@ -205,8 +213,8 @@ jobs: report-failure: name: Report failure - needs: publish - if: ${{ always() && needs.publish.result != 'success' && needs.publish.result != 'skipped' }} + needs: [check, publish] + if: ${{ always() && (needs.check.result == 'failure' || (needs.publish.result != 'success' && needs.publish.result != 'skipped')) }} runs-on: ubuntu-latest steps: - name: Report failure diff --git a/example/.fingerprintignore b/example/.fingerprintignore index 67f117a40a..b3c5af777d 100644 --- a/example/.fingerprintignore +++ b/example/.fingerprintignore @@ -1,2 +1,2 @@ # built by pod install, would make the fingerprint differ from the one EAS computes -node_modules/expo-modules-jsi/apple/Products/ +**/expo-modules-jsi/apple/Products/** From a9fa195e282acd44af4ca53865139d8383492984 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 21 Oct 2026 17:43:04 +0200 Subject: [PATCH 10/11] 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. --- .github/workflows/publish-example-app.yml | 92 ++++++++++------------- example/.nvmrc | 1 + example/eas.json | 1 - 3 files changed, 42 insertions(+), 52 deletions(-) create mode 120000 example/.nvmrc diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index d0ca481096..49d661e185 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -26,21 +26,24 @@ defaults: shell: bash concurrency: - group: publish-example-app + # One queue per channel, so a pending preview run doesn't cancel a production one + group: publish-example-app-${{ contains(github.event.release.tag_name || inputs.tag, '-') && 'preview' || 'production' }} jobs: check: name: Check release is on main runs-on: ubuntu-latest + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} outputs: on-main: ${{ steps.check.outputs.on-main }} sha: ${{ steps.check.outputs.sha }} + tag: ${{ env.TAG }} steps: - id: check env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - TAG: ${{ github.event.release.tag_name || inputs.tag }} run: | status=$(gh api "repos/$GH_REPO/compare/main...$TAG" --jq .status) @@ -67,17 +70,12 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Resolve release tag and submit profile + - name: Resolve submit profile id: release env: - TAG: ${{ github.event.release.tag_name || inputs.tag }} + TAG: ${{ needs.check.outputs.tag }} PLATFORM: ${{ inputs.platform }} run: | - if [ -z "$TAG" ]; then - echo "::error::No release tag to publish the example app for." - exit 1 - fi - if [ "${TAG#*-}" != "$TAG" ]; then profile=preview else @@ -86,7 +84,6 @@ jobs: platform=${PLATFORM:-all} - echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "profile=$profile" >> "$GITHUB_OUTPUT" echo "platform=$platform" >> "$GITHUB_OUTPUT" echo "Publishing $platform for $TAG using the $profile submit profile." @@ -94,7 +91,7 @@ jobs: - name: Setup Expo uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: - eas-version: latest + eas-version: 24.12.1 token: ${{ secrets.EXPO_TOKEN }} - name: Check Expo authentication @@ -108,7 +105,7 @@ jobs: - name: Set example app version from the release tag working-directory: ./example env: - TAG: ${{ steps.release.outputs.tag }} + TAG: ${{ needs.check.outputs.tag }} run: | version="${TAG#v}" version="${version%%-*}" @@ -133,12 +130,8 @@ jobs: platforms="ios android" fi - if [ -z "$platforms" ]; then - echo "::error::No platforms to check." - exit 1 - fi - - build=false + build=() + update=() for platform in $platforms; do hash=$(npx expo-updates fingerprint:generate --platform "$platform" | jq -r '.hash') @@ -159,23 +152,33 @@ jobs: esac echo "$platform: fingerprint $hash, $existing matching build(s)" - [ "$existing" -eq 0 ] && build=true + if [ "$existing" -eq 0 ]; then + build+=("$platform") + else + update+=("$platform") + fi done - echo "build=$build" >> "$GITHUB_OUTPUT" - if [ "$build" = true ]; then - echo "Native code changed, building and submitting." - else - echo "No native change, publishing an update instead." - fi + # EAS takes a single platform or "all" + to_platform() { + case $# in + 0) echo "" ;; + 1) echo "$1" ;; + *) echo all ;; + esac + } + + echo "build=$(to_platform "${build[@]}")" >> "$GITHUB_OUTPUT" + echo "update=$(to_platform "${update[@]}")" >> "$GITHUB_OUTPUT" + echo "Building: ${build[*]:-none}. Updating: ${update[*]:-none}." - name: Build and submit to the app stores - id: submit - if: steps.decide.outputs.build == 'true' + if: steps.decide.outputs.build != '' working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} - PLATFORM: ${{ steps.release.outputs.platform }} + PLATFORM: ${{ steps.decide.outputs.build }} + TAG: ${{ needs.check.outputs.tag }} run: | eas build \ --platform "$PLATFORM" \ @@ -183,14 +186,16 @@ jobs: --auto-submit-with-profile "$PROFILE" \ --non-interactive + echo "Built and submitted $TAG for $PLATFORM using the $PROFILE profile." >> "$GITHUB_STEP_SUMMARY" + - name: Publish an update - id: update - if: steps.decide.outputs.build == 'false' + # Don't let a failed build for one platform skip the other's update + if: ${{ !cancelled() && steps.decide.outputs.update != '' }} working-directory: ./example env: PROFILE: ${{ steps.release.outputs.profile }} - PLATFORM: ${{ steps.release.outputs.platform }} - TAG: ${{ steps.release.outputs.tag }} + PLATFORM: ${{ steps.decide.outputs.update }} + TAG: ${{ needs.check.outputs.tag }} run: | eas update \ --channel "$PROFILE" \ @@ -198,28 +203,13 @@ jobs: --message "$TAG" \ --non-interactive - - name: Summarise - if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success') }} - env: - TAG: ${{ steps.release.outputs.tag }} - PROFILE: ${{ steps.release.outputs.profile }} - BUILT: ${{ steps.decide.outputs.build }} - run: | - if [ "$BUILT" = "true" ]; then - echo "Built and submitted $TAG using the $PROFILE profile." >> "$GITHUB_STEP_SUMMARY" - else - echo "Published an OTA update for $TAG on the $PROFILE channel." >> "$GITHUB_STEP_SUMMARY" - fi + echo "Published an OTA update for $TAG for $PLATFORM on the $PROFILE channel." >> "$GITHUB_STEP_SUMMARY" - report-failure: - name: Report failure - needs: [check, publish] - if: ${{ always() && (needs.check.result == 'failure' || (needs.publish.result != 'success' && needs.publish.result != 'skipped')) }} - runs-on: ubuntu-latest - steps: - name: Report failure + if: failure() || cancelled() env: - TAG: ${{ github.event.release.tag_name || inputs.tag }} + TAG: ${{ needs.check.outputs.tag }} run: | echo "::error::Example app deployment failed for $TAG. The release itself is fine, only the demo app did not update." echo "Check EAS and the stores before re-running, a build may already have been submitted." >> "$GITHUB_STEP_SUMMARY" + echo "If the build finished but the store upload failed, resubmit that build with \`eas submit --id \`. Re-running this workflow will publish an OTA update instead." >> "$GITHUB_STEP_SUMMARY" diff --git a/example/.nvmrc b/example/.nvmrc new file mode 120000 index 0000000000..3d48ffa461 --- /dev/null +++ b/example/.nvmrc @@ -0,0 +1 @@ +../.nvmrc \ No newline at end of file diff --git a/example/eas.json b/example/eas.json index b4f19d3a15..482e583d8e 100644 --- a/example/eas.json +++ b/example/eas.json @@ -4,7 +4,6 @@ }, "build": { "production": { - "node": "24.13.0", "autoIncrement": true, "android": { "buildType": "app-bundle" From 69f201a1a95f643c9bc7d790976c0c098ba62945 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 21 Oct 2026 18:19:18 +0200 Subject: [PATCH 11/11] 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. --- .github/workflows/publish-example-app.yml | 81 ++++++++++------------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml index 49d661e185..7c9a86531f 100644 --- a/.github/workflows/publish-example-app.yml +++ b/.github/workflows/publish-example-app.yml @@ -25,10 +25,6 @@ defaults: run: shell: bash -concurrency: - # One queue per channel, so a pending preview run doesn't cancel a production one - group: publish-example-app-${{ contains(github.event.release.tag_name || inputs.tag, '-') && 'preview' || 'production' }} - jobs: check: name: Check release is on main @@ -39,6 +35,7 @@ jobs: on-main: ${{ steps.check.outputs.on-main }} sha: ${{ steps.check.outputs.sha }} tag: ${{ env.TAG }} + profile: ${{ steps.check.outputs.profile }} steps: - id: check env: @@ -48,19 +45,29 @@ jobs: status=$(gh api "repos/$GH_REPO/compare/main...$TAG" --jq .status) if [ "$status" = "identical" ] || [ "$status" = "behind" ]; then + sha=$(gh api "repos/$GH_REPO/commits/$TAG" --jq .sha) echo "on-main=true" >> "$GITHUB_OUTPUT" - echo "sha=$(gh api "repos/$GH_REPO/commits/$TAG" --jq .sha)" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" else echo "on-main=false" >> "$GITHUB_OUTPUT" + echo "::warning::$TAG is not on main ($status), nothing to deploy." echo "$TAG is not on main ($status), nothing to deploy." >> "$GITHUB_STEP_SUMMARY" fi + if [ "${TAG#*-}" != "$TAG" ]; then + echo "profile=preview" >> "$GITHUB_OUTPUT" + else + echo "profile=production" >> "$GITHUB_OUTPUT" + fi + publish: name: Build and submit example app needs: check if: needs.check.outputs.on-main == 'true' runs-on: ubuntu-latest - timeout-minutes: 120 + concurrency: + # One queue per channel, so a pending preview run doesn't cancel a production one + group: publish-example-app-${{ needs.check.outputs.profile }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -70,24 +77,6 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Resolve submit profile - id: release - env: - TAG: ${{ needs.check.outputs.tag }} - PLATFORM: ${{ inputs.platform }} - run: | - if [ "${TAG#*-}" != "$TAG" ]; then - profile=preview - else - profile=production - fi - - platform=${PLATFORM:-all} - - echo "profile=$profile" >> "$GITHUB_OUTPUT" - echo "platform=$platform" >> "$GITHUB_OUTPUT" - echo "Publishing $platform for $TAG using the $profile submit profile." - - name: Setup Expo uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: @@ -122,8 +111,8 @@ jobs: id: decide working-directory: ./example env: - PROFILE: ${{ steps.release.outputs.profile }} - PLATFORM: ${{ steps.release.outputs.platform }} + PROFILE: ${{ needs.check.outputs.profile }} + PLATFORM: ${{ inputs.platform || 'all' }} run: | platforms="$PLATFORM" if [ "$PLATFORM" = "all" ]; then @@ -133,7 +122,7 @@ jobs: build=() update=() for platform in $platforms; do - hash=$(npx expo-updates fingerprint:generate --platform "$platform" | jq -r '.hash') + hash=$(npx expo-updates fingerprint:generate --platform "$platform" | jq -er .hash) existing=$(eas build:list \ --platform "$platform" \ @@ -172,38 +161,39 @@ jobs: echo "update=$(to_platform "${update[@]}")" >> "$GITHUB_OUTPUT" echo "Building: ${build[*]:-none}. Updating: ${update[*]:-none}." - - name: Build and submit to the app stores - if: steps.decide.outputs.build != '' + - name: Publish an update + # Before the build, so a slow or timed-out build can't hold up the update + if: steps.decide.outputs.update != '' working-directory: ./example env: - PROFILE: ${{ steps.release.outputs.profile }} - PLATFORM: ${{ steps.decide.outputs.build }} + PROFILE: ${{ needs.check.outputs.profile }} + PLATFORM: ${{ steps.decide.outputs.update }} TAG: ${{ needs.check.outputs.tag }} run: | - eas build \ + eas update \ + --channel "$PROFILE" \ --platform "$PLATFORM" \ - --profile "$PROFILE" \ - --auto-submit-with-profile "$PROFILE" \ + --message "$TAG" \ --non-interactive - echo "Built and submitted $TAG for $PLATFORM using the $PROFILE profile." >> "$GITHUB_STEP_SUMMARY" + echo "Published an OTA update for $TAG for $PLATFORM on the $PROFILE channel." >> "$GITHUB_STEP_SUMMARY" - - name: Publish an update - # Don't let a failed build for one platform skip the other's update - if: ${{ !cancelled() && steps.decide.outputs.update != '' }} + - name: Build and submit to the app stores + # Don't let a failed update for one platform skip the other's build + if: ${{ !cancelled() && steps.decide.outputs.build != '' }} working-directory: ./example env: - PROFILE: ${{ steps.release.outputs.profile }} - PLATFORM: ${{ steps.decide.outputs.update }} + PROFILE: ${{ needs.check.outputs.profile }} + PLATFORM: ${{ steps.decide.outputs.build }} TAG: ${{ needs.check.outputs.tag }} run: | - eas update \ - --channel "$PROFILE" \ + eas build \ --platform "$PLATFORM" \ - --message "$TAG" \ + --profile "$PROFILE" \ + --auto-submit-with-profile "$PROFILE" \ --non-interactive - echo "Published an OTA update for $TAG for $PLATFORM on the $PROFILE channel." >> "$GITHUB_STEP_SUMMARY" + echo "Built and submitted $TAG for $PLATFORM using the $PROFILE profile." >> "$GITHUB_STEP_SUMMARY" - name: Report failure if: failure() || cancelled() @@ -211,5 +201,6 @@ jobs: TAG: ${{ needs.check.outputs.tag }} run: | echo "::error::Example app deployment failed for $TAG. The release itself is fine, only the demo app did not update." - echo "Check EAS and the stores before re-running, a build may already have been submitted." >> "$GITHUB_STEP_SUMMARY" + 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" echo "If the build finished but the store upload failed, resubmit that build with \`eas submit --id \`. Re-running this workflow will publish an OTA update instead." >> "$GITHUB_STEP_SUMMARY" + echo "If only the update failed, re-running is safe." >> "$GITHUB_STEP_SUMMARY"