Skip to content

Commit 4bd1cee

Browse files
Radoslaw Nowackiclaude
andcommitted
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 CONTRIBUTING.md, alongside the release process they belong to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 84529e0 commit 4bd1cee

5 files changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
name: Publish example app
2+
3+
# Publishes the example app that demonstrates the library to the App Store and
4+
# Play Store. This runs *after* a library release already exists, and cannot
5+
# affect it: see the failure report in the `report-failure` job below.
6+
7+
on:
8+
release:
9+
types: [published]
10+
workflow_dispatch:
11+
inputs:
12+
tag:
13+
description: Release tag to publish the example app for, e.g. v6.0.0
14+
required: true
15+
platform:
16+
description: Platforms to build and submit. Pick one to retry after a partial failure, so the platform that already shipped is not submitted twice.
17+
required: false
18+
default: all
19+
type: choice
20+
options:
21+
- all
22+
- ios
23+
- android
24+
25+
permissions:
26+
contents: read
27+
28+
concurrency:
29+
group: publish-example-app
30+
31+
jobs:
32+
publish:
33+
name: Build and submit example app
34+
runs-on: ubuntu-latest
35+
# An EAS build for two platforms is slow, but not this slow. Without a
36+
# timeout a stuck run holds the concurrency lock for six hours.
37+
timeout-minutes: 120
38+
permissions:
39+
contents: write
40+
pull-requests: write
41+
steps:
42+
- name: Checkout
43+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
44+
with:
45+
ref: main
46+
47+
- name: Setup
48+
uses: ./.github/actions/setup
49+
50+
- name: Resolve release tag and submit profile
51+
id: release
52+
env:
53+
TAG: ${{ github.event.release.tag_name || inputs.tag }}
54+
PLATFORM: ${{ inputs.platform }}
55+
run: |
56+
if [ -z "$TAG" ]; then
57+
echo "::error::No release tag to publish the example app for."
58+
exit 1
59+
fi
60+
61+
# A semver version with a prerelease identifier (v6.0.0-alpha.1) goes
62+
# to the test tracks, a stable one (v6.0.0) goes to production. This
63+
# is derived from the tag rather than from the release's prerelease
64+
# flag on purpose: .release-it.json currently pins every release to
65+
# `preRelease: true` for the 6.0 alpha cycle, so that flag stays true
66+
# for a stable release until somebody remembers to revert it.
67+
if [ "${TAG#*-}" != "$TAG" ]; then
68+
profile=preview
69+
else
70+
profile=production
71+
fi
72+
73+
# A release always publishes both platforms. Only a manual retry can
74+
# narrow it, to avoid resubmitting a platform that already shipped.
75+
platform=${PLATFORM:-all}
76+
77+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
78+
echo "profile=$profile" >> "$GITHUB_OUTPUT"
79+
echo "platform=$platform" >> "$GITHUB_OUTPUT"
80+
echo "Publishing $platform for $TAG using the $profile submit profile."
81+
82+
- name: Bump example app version
83+
run: node scripts/bump-example-version.ts example/app.json
84+
85+
- name: Setup Expo
86+
uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1
87+
with:
88+
eas-version: latest
89+
token: ${{ secrets.EXPO_TOKEN }}
90+
91+
# Build numbers are assigned remotely by EAS (`appVersionSource: remote`),
92+
# so a retry can never collide with an already submitted build.
93+
- name: Build and submit to the app stores
94+
id: submit
95+
working-directory: ./example
96+
env:
97+
SUBMIT_PROFILE: ${{ steps.release.outputs.profile }}
98+
PLATFORM: ${{ steps.release.outputs.platform }}
99+
run: |
100+
eas build \
101+
--platform "$PLATFORM" \
102+
--profile production \
103+
--auto-submit-with-profile "$SUBMIT_PROFILE" \
104+
--non-interactive
105+
106+
# Runs whenever the submit step ran at all, however it ended. `eas build`
107+
# can ship one platform and fail the other, and cancelling the workflow
108+
# does not cancel the build on EAS, so in every one of those cases a
109+
# version may have reached a store and has to be recorded.
110+
- name: Open example app version bump pull request
111+
if: ${{ always() && (steps.submit.outcome == 'success' || steps.submit.outcome == 'failure' || steps.submit.outcome == 'cancelled') }}
112+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
113+
with:
114+
token: ${{ secrets.GITHUB_TOKEN }}
115+
base: main
116+
add-paths: example/app.json
117+
branch: chore/example-app-version-${{ steps.release.outputs.tag }}
118+
commit-message: 'chore: bump example app version for ${{ steps.release.outputs.tag }}'
119+
title: 'chore: bump example app version for ${{ steps.release.outputs.tag }}'
120+
labels: example app
121+
body: |
122+
Records the example app version this run used for
123+
${{ steps.release.outputs.tag }}.
124+
125+
A build for that version may already have been submitted, so this only
126+
keeps `example/app.json` in sync. iOS build numbers and Android version codes are not in here:
127+
EAS assigns those remotely.
128+
129+
- name: Summarise
130+
if: ${{ !cancelled() && steps.submit.outcome == 'success' }}
131+
env:
132+
TAG: ${{ steps.release.outputs.tag }}
133+
PROFILE: ${{ steps.release.outputs.profile }}
134+
run: |
135+
{
136+
echo "### Example app submitted for \`$TAG\`"
137+
echo
138+
echo "Submit profile: \`$PROFILE\`."
139+
if [ "$PROFILE" = "production" ]; then
140+
echo "- Android: Play Store **production** track."
141+
else
142+
echo "- Android: Play Store **internal** track."
143+
fi
144+
echo "- iOS: uploaded to App Store Connect, available in TestFlight."
145+
echo
146+
echo "Releasing an iOS build from TestFlight to the App Store is a"
147+
echo "separate manual step in App Store Connect."
148+
} >> "$GITHUB_STEP_SUMMARY"
149+
150+
report-failure:
151+
name: Report example app deployment problem
152+
needs: publish
153+
# Not `failure()`: that misses a cancelled run, and cancellation is the case
154+
# most likely to leave a build running on EAS with nobody told about it.
155+
if: ${{ always() && needs.publish.result != 'success' }}
156+
runs-on: ubuntu-latest
157+
steps:
158+
# Whoever published the release is already emailed that this run failed.
159+
# What that email cannot tell them is that their release is fine, so say
160+
# it here, where they land when they follow it.
161+
- name: Say that the library release is unaffected
162+
env:
163+
TAG: ${{ github.event.release.tag_name || inputs.tag }}
164+
run: |
165+
echo "::error::The example app deployment for $TAG did not finish. The $TAG library release itself published successfully and is not affected."
166+
167+
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
168+
## The \`$TAG\` library release is not affected
169+
170+
It is published on npm and on GitHub Releases exactly as expected, and
171+
**nothing here changes that**. This workflow only ever runs *after* a
172+
release already exists, so it cannot fail, revoke or roll back one.
173+
174+
- Users of the library need to do nothing. Installing \`react-native-paper\` is unaffected.
175+
- Nothing has to be re-released to fix this.
176+
- Only the demo app we ship to the App Store and Play Store did not update.
177+
178+
## Before re-running
179+
180+
**Check the EAS dashboard and the stores first.** A build may already
181+
have been submitted, and submitting the same version twice is the one
182+
thing worth avoiding. Cancelling this workflow does not cancel a build
183+
already running on EAS, so a cancelled run can still ship on its own.
184+
185+
Once you have confirmed what did and did not reach the stores, re-run
186+
this workflow on its own, without cutting a new library release:
187+
188+
**Actions → Publish example app → Run workflow → tag \`$TAG\`**
189+
190+
If only one platform failed, pick it in the \`platform\` input so the
191+
other one is not submitted a second time. Retry before merging the
192+
version bump pull request this run opened, since merging it first
193+
moves \`main\` on and the retry would ship under a different version.
194+
EOF

‎CONTRIBUTING.md‎

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

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

112+
### Publishing the example app
113+
114+
Publishing a release also builds the example app and submits it to the app stores, 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.
115+
116+
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.
117+
118+
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`:
119+
120+
```sh
121+
eas build:version:set --platform android
122+
eas build:version:set --platform ios
123+
```
124+
125+
The example app's version is bumped and comes back as a pull request. Merge it before the next release, or two releases start from the same version and ship under it.
126+
127+
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.
128+
112129
## Reporting issues
113130

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

‎example/eas.json‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"cli": {
3+
"appVersionSource": "remote"
4+
},
5+
"build": {
6+
"production": {
7+
"node": "24.13.0",
8+
"autoIncrement": true,
9+
"android": {
10+
"buildType": "app-bundle"
11+
}
12+
}
13+
},
14+
"submit": {
15+
"production": {
16+
"android": {
17+
"track": "production"
18+
},
19+
"ios": {
20+
"ascAppId": "1548934513"
21+
}
22+
},
23+
"preview": {
24+
"extends": "production",
25+
"android": {
26+
"track": "internal"
27+
}
28+
}
29+
}
30+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { afterEach, beforeEach, expect, it } from '@jest/globals';
2+
import { execFileSync } from 'node:child_process';
3+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
7+
const script = join(__dirname, '..', 'bump-example-version.ts');
8+
9+
let workingDir: string;
10+
11+
const writeAppJson = (version: string) => {
12+
const appJsonPath = join(workingDir, 'app.json');
13+
14+
writeFileSync(
15+
appJsonPath,
16+
JSON.stringify({ expo: { name: 'Example', version } }, null, 2) + '\n'
17+
);
18+
19+
return appJsonPath;
20+
};
21+
22+
// stderr is captured rather than inherited so that the messages from the
23+
// expected failures do not land in the test output.
24+
const run = (appJsonPath: string) =>
25+
execFileSync('node', [script, appJsonPath], {
26+
encoding: 'utf8',
27+
stdio: ['ignore', 'pipe', 'pipe'],
28+
});
29+
30+
const readVersion = (appJsonPath: string) =>
31+
JSON.parse(readFileSync(appJsonPath, 'utf8')).expo.version;
32+
33+
beforeEach(() => {
34+
workingDir = mkdtempSync(join(tmpdir(), 'bump-example-version-'));
35+
});
36+
37+
afterEach(() => {
38+
rmSync(workingDir, { recursive: true, force: true });
39+
});
40+
41+
it('bumps the minor version', () => {
42+
const appJsonPath = writeAppJson('3.16.0');
43+
44+
run(appJsonPath);
45+
46+
expect(readVersion(appJsonPath)).toBe('3.17.0');
47+
});
48+
49+
it('resets the patch version when bumping the minor version', () => {
50+
const appJsonPath = writeAppJson('3.16.5');
51+
52+
run(appJsonPath);
53+
54+
expect(readVersion(appJsonPath)).toBe('3.17.0');
55+
});
56+
57+
it('prints the version it bumped to', () => {
58+
const appJsonPath = writeAppJson('3.16.0');
59+
60+
expect(run(appJsonPath)).toContain('3.17.0');
61+
});
62+
63+
it('leaves the rest of the app config untouched', () => {
64+
const appJsonPath = writeAppJson('3.16.0');
65+
66+
run(appJsonPath);
67+
68+
expect(JSON.parse(readFileSync(appJsonPath, 'utf8')).expo.name).toBe(
69+
'Example'
70+
);
71+
});
72+
73+
it('increments the minor version past a single digit', () => {
74+
const appJsonPath = writeAppJson('3.9.0');
75+
76+
run(appJsonPath);
77+
78+
expect(readVersion(appJsonPath)).toBe('3.10.0');
79+
});
80+
81+
it('fails when no app config path is given', () => {
82+
expect(() =>
83+
execFileSync('node', [script], { stdio: ['ignore', 'pipe', 'pipe'] })
84+
).toThrow(/Usage/);
85+
});
86+
87+
it('fails when the app config has no expo section', () => {
88+
const appJsonPath = join(workingDir, 'app.json');
89+
writeFileSync(appJsonPath, JSON.stringify({}) + '\n');
90+
91+
expect(() => run(appJsonPath)).toThrow(/got undefined/);
92+
});
93+
94+
it('fails when the current version is not a valid version', () => {
95+
const appJsonPath = writeAppJson('not-a-version');
96+
97+
expect(() => run(appJsonPath)).toThrow(/got "not-a-version"/);
98+
});

‎scripts/bump-example-version.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { readFileSync, writeFileSync } from 'node:fs';
2+
3+
type AppConfig = { expo?: { version?: string } };
4+
5+
const appConfigPath = process.argv[2];
6+
7+
if (!appConfigPath) {
8+
console.error(
9+
'Usage: node scripts/bump-example-version.ts <path to the example app config>'
10+
);
11+
process.exit(1);
12+
}
13+
14+
const appConfig: AppConfig = JSON.parse(readFileSync(appConfigPath, 'utf8'));
15+
const expo = appConfig.expo;
16+
const currentVersion = expo?.version;
17+
const parsed = /^(\d+)\.(\d+)\.(\d+)$/.exec(currentVersion ?? '');
18+
19+
if (!expo || !parsed) {
20+
console.error(
21+
`Expected a "major.minor.patch" version at expo.version in ${appConfigPath}, got ${JSON.stringify(currentVersion)}.`
22+
);
23+
process.exit(1);
24+
}
25+
26+
const [, major, minor] = parsed;
27+
const nextVersion = `${major}.${Number(minor) + 1}.0`;
28+
29+
expo.version = nextVersion;
30+
31+
writeFileSync(appConfigPath, JSON.stringify(appConfig, null, 2) + '\n');
32+
33+
console.log(`Bumped example app version to ${nextVersion}`);

0 commit comments

Comments
 (0)