From fb463b24e1656fabc551e3719cbe2c9afb48a05d Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 14 Sep 2026 21:35:37 +0200 Subject: [PATCH 1/2] 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 4f0f1ea0c1df3ea59b954de11bb27b7759eaeb1f Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Wed, 23 Sep 2026 08:09:29 +0200 Subject: [PATCH 2/2] 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."