From d1f8f7a98e21c199c86eec9771b2acfb0e6f1891 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 14 Sep 2026 21:35:37 +0200 Subject: [PATCH] chore: automate example app publishing to app stores 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. Publish it from the release that prompts it instead. 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: a red cross beside a fresh release should not read as a broken release. It also says to check EAS and the stores before re-running, because a build may already have been submitted and cancelling the workflow does not cancel a build running on EAS. Where a release 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. Reading that flag would route a stable 6.0.0 to the internal track until somebody remembered to revert the pin. A prerelease goes to the Play internal track, a stable release to production; both upload to App Store Connect, where promoting a build to the App Store stays manual. The example app keeps its own version line rather than adopting the library's, which App Store Connect would reject while the library is on a prerelease version. Its minor version is bumped and recorded through a pull request. Build numbers move to EAS, which assigns them remotely, so a retry cannot collide with a build that has already been submitted. Store credentials live in EAS rather than here, so EXPO_TOKEN is the only secret this needs. Setup, retrying, and the known limitations are in .github/PUBLISHING_EXAMPLE_APP.md. Co-Authored-By: Claude Opus 5 (1M context) --- .github/PUBLISHING_EXAMPLE_APP.md | 146 +++++++++++++ .github/workflows/publish-example-app.yml | 194 ++++++++++++++++++ CONTRIBUTING.md | 2 + example/eas.json | 30 +++ .../__tests__/bump-example-version.test.ts | 98 +++++++++ scripts/bump-example-version.ts | 33 +++ 6 files changed, 503 insertions(+) create mode 100644 .github/PUBLISHING_EXAMPLE_APP.md create mode 100644 .github/workflows/publish-example-app.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/PUBLISHING_EXAMPLE_APP.md b/.github/PUBLISHING_EXAMPLE_APP.md new file mode 100644 index 0000000000..6c08f192e1 --- /dev/null +++ b/.github/PUBLISHING_EXAMPLE_APP.md @@ -0,0 +1,146 @@ +# Publishing the example app + +The example app is the demo of the library that we ship to the App Store and the +Play Store. It is published automatically by the +[`Publish example app`](workflows/publish-example-app.yml) workflow whenever a +library release is published. + +## This does not affect library releases + +The workflow runs **after** a release already exists. By the time it starts, +`release-it` has already published to npm and created the GitHub release, so +nothing the workflow does can fail, revoke or roll back a release. + +A run that does not succeed says so on its own run page, above the logs: the +library release published normally and only the demo app is missing an update. +Whoever published the release is already emailed by GitHub that the run failed, +so that page is where they land. + +Before re-running, check the EAS dashboard and the stores. A build may already +have been submitted, and submitting the same version twice is the one thing +worth avoiding — cancelling the workflow does not cancel a build already running +on EAS, so even a cancelled run can ship on its own. The run page spells this +out. + +## What happens on a release + +The submit profile is chosen from the release tag: a tag containing a hyphen is +a semver prerelease, and anything else is stable. + +| Tag | Android | iOS | +| ---------------- | --------------------------- | ----------------------------- | +| `v6.0.0-alpha.1` | Play Store internal track | App Store Connect, TestFlight | +| `v6.0.0` | Play Store production track | App Store Connect, TestFlight | + +The tag is used rather than the release's prerelease flag because +[`.release-it.json`](../.release-it.json) currently pins every release to +`preRelease: true` for the 6.0 alpha cycle. Reading that flag would send a +stable 6.0.0 release to the internal track if somebody forgot to revert the pin. + +Note that `eas submit` uploads an iOS build to App Store Connect, where it +becomes available in TestFlight. Releasing that build to the App Store is a +separate manual step in App Store Connect, so the iOS half behaves the same for +prereleases and stable releases. + +## Versions + +The example app keeps its own version line, independent of the library. Each +release bumps its minor version, so a library release of `v6.0.0` moves the +example app from `3.16.0` to `3.17.0`. After submitting, the workflow opens a +pull request with that bump. + +That pull request needs merging before the next release. The bump is computed +from `main`, so two releases while it is still open both start from the same +version and ship under it. The stores accept that, because EAS assigns build +numbers separately, which is exactly why it is worth watching for: the two +builds are then hard to tell apart. + +iOS build numbers and Android version codes are **not** kept in the repository. +EAS assigns them remotely via `appVersionSource: remote` and `autoIncrement`, so +retries and reruns cannot collide with an already submitted build. The +`buildNumber` and `versionCode` values still in `example/app.json` are ignored by +EAS, which warns about them on every build. They are kept only because local +`expo run:` builds still read them, and they are no longer authoritative. + +The workflow always builds the example app as it is on `main`, not as it was at +the release tag. `release-it` tags the tip of `main`, so these are the same at +release time, and for a demo app the newer one is the one worth shipping anyway. + +The bump pull request is opened with `GITHUB_TOKEN`, so CI does not run on it. +That is a known GitHub limitation rather than a choice, and it is acceptable +here because the pull request only changes a version string. + +## Retrying a failed deployment + +Check the stores first, as above. Then run the workflow by itself, without +cutting a new library release: + +**Actions → Publish example app → Run workflow → the release tag** + +If the previous run shipped one platform and failed the other, pick that one +platform in the `platform` input. Retrying with `all` would submit a duplicate +to the platform that already shipped. + +Retry **before** merging the version bump pull request that the run opened. +Merging it first moves `main` on, so the retry bumps again and ships the second +platform under a different version from the first. + +## One-time setup + +### Repository secret + +`EXPO_TOKEN` is the only secret this workflow needs. It should be a token for an +Expo bot account belonging to the `react-native-paper` organisation rather than +a personal account, so that it survives people joining and leaving. + +Store credentials deliberately live in EAS, not in GitHub. Nothing about the +Apple or Google accounts is configured in this repository. + +### Repository setting + +Enable **Settings → Actions → General → Allow GitHub Actions to create and +approve pull requests**. Without it the version bump pull request fails with a +403 _after_ the app has already been submitted. + +### Store credentials + +Run once, from `example/`: + +```sh +eas credentials +``` + +- **iOS**: use an App Store Connect API key. Apple ties app-specific passwords to + an individual person's account, so they break when that person rotates their + password or leaves. +- **Android**: upload a Google Play service account key with the + _Release manager_ role. + +### Seeding remote build numbers + +Remote versioning starts from scratch, and both stores reject a build that does +not increase. Seed the remote values from the ones currently in +`example/app.json` before the first automated run, from `example/`: + +```sh +eas build:version:set --platform android # set to at least 38 +eas build:version:set --platform ios # set to at least 26.0.5 +``` + +## Rollback + +A build that has already been submitted cannot be withdrawn by re-running or +deleting anything in GitHub. Roll it back where it was published: + +- **Play Store**: halt the rollout in the Play Console, and promote the previous + release if it was already live. +- **App Store**: expire the TestFlight build, or reject it in App Store Connect + if it had been submitted for review. + +Then land the fix and cut a new release as usual. + +## Reference + +- [EAS Build](https://docs.expo.dev/build/introduction/) +- [EAS Submit](https://docs.expo.dev/submit/introduction/) +- [App version management](https://docs.expo.dev/build-reference/app-versions/) diff --git a/.github/workflows/publish-example-app.yml b/.github/workflows/publish-example-app.yml new file mode 100644 index 0000000000..82638b61dd --- /dev/null +++ b/.github/workflows/publish-example-app.yml @@ -0,0 +1,194 @@ +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: Bump example app version + run: node scripts/bump-example-version.ts example/app.json + + - name: Setup Expo + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + # 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 + working-directory: ./example + env: + SUBMIT_PROFILE: ${{ steps.release.outputs.profile }} + PLATFORM: ${{ steps.release.outputs.platform }} + run: | + eas build \ + --platform "$PLATFORM" \ + --profile production \ + --auto-submit-with-profile "$SUBMIT_PROFILE" \ + --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' }} + env: + TAG: ${{ steps.release.outputs.tag }} + PROFILE: ${{ steps.release.outputs.profile }} + run: | + { + echo "### Example app submitted for \`$TAG\`" + echo + echo "Submit profile: \`$PROFILE\`." + 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." + } >> "$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}`);