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}`);