Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions .github/workflows/publish-example-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
name: Publish example app

on:
release:
types: [published]
Comment on lines +4 to +5

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensure we only deploy on releases on main. we may need to fetch tags from main and compare to skip releases from other branches such as 5.x

workflow_dispatch:
inputs:
tag:
description: Release tag to publish the example app for, e.g. v6.0.0
required: true
platform:
description: Platform to build. Defaults to all.
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
timeout-minutes: 120

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure about this timeout. since we're on free plan, it could take longer than 2 hours.

probably simpler to just use --no-wait and exit the workflow early, and check status on EAS.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entirely your call so just LMK which option you prefer, but I'd suggest to just remove the line and use default 6 hours timeout. Should be more than enough for the builds to pass, we can see errors without going into EAS, and since waiting is free for opensource repos the only downside is the 6-hour release lock if the build gets stuck which doesn't seem to be a problem with the current release cadence.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, 6 hours sounds fine. The repo is public, so the waiting runner doesn't cost us anything, and 6 hours is also the limit for GitHub-hosted runners anyway. That's enough room for the free-tier queue plus both builds and submissions. If a run ever goes past that, the build keeps going on EAS, so the worst case is a failed run while the build still lands. Let's remove the line.

permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why check main? shouldn't it check the tag that was released? it could result in later commits being published instead of the released tag


- 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check never runs. inputs.tag is required, and with an empty tag the compare call in the check job already fails, so we never get here.

Suggested change
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
profile=production
fi

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pin eas-version to a specific version instead of latest? This step has access to EXPO_TOKEN, which can also reach the store credentials, so a broken or compromised CLI release would run with that token. Pinning also keeps flags like --fingerprint-hash and eas update --channel behaving the same on every run.

token: ${{ secrets.EXPO_TOKEN }}

- name: Check Expo authentication
working-directory: ./example
run: |
if ! eas whoami; then
echo "::error::Could not authenticate with Expo, check EXPO_TOKEN."
exit 1
fi

- 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')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensure fingerprint doesn't change because of version bumps. otherwise it won't match next run and it'll trigger rebuild again.

may need a fingerprint config (example/fingerprint.config.js) with something like:

/** @type {import('expo/fingerprint').Config} */
module.exports = {
  sourceSkips: [
    'ExpoConfigVersions',
    'PackageJsonAndroidAndIosScriptsIfNotContainRun',
  ],
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great catch, thank you


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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When platform is all and only one platform's fingerprint changed, build ends up true for both, so we build and submit both apps. That's an extra build in the free-tier queue and an extra store review for an app with no native changes.

We could keep a list of the platforms that need a build, build only those, and publish an update for the rest. Not a blocker though, fine as a follow-up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did it in this PR since it was small. Decide now splits platforms into a build list and an update list, so only the one with native changes gets built and the other gets an OTA update. The update step also runs if the other platform's build fails.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you check if this fails the workflow on an error instead of continuing? from claude (please verify):

a failed command sends the run down the OTA path.
- The default run shell is bash -e without pipefail. If eas build:list or the fingerprint command fails, jq 'length' gets empty input and prints nothing, so existing is empty.
- [ "" -eq 0 ] then errors inside a && list, which doesn't trigger -e. build stays false, and the run publishes an OTA update and reports success.
- Fix: add shell: bash (it runs with -eo pipefail) and check that existing is a number.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confirmed & fixed


- name: Bump example app version
if: steps.decide.outputs.build == 'true'
run: node scripts/bump-example-version.ts example/app.json

- 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
eas update \
--branch "$PROFILE" \
--message "$TAG" \
--non-interactive
eas update \
--branch "$PROFILE" \
--platform "$PLATFORM" \
--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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not have separate PR/commit for version bumps. Does it need to be stored in the repo? Can't it be derived from release tag automatically?

body: Version bump for ${{ steps.release.outputs.tag }}.

- name: Summarise
if: ${{ !cancelled() && (steps.submit.outcome == 'success' || steps.update.outcome == 'success') }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can drop this if. Without it we get the default success(), which is already true when one of the build and update steps ran and the other was skipped.

Or we could write the summary at the end of the build and update steps and remove this step entirely.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the second option, the build and update steps write their own summary and the Summarise step is gone.

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

report-failure:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We start a separate runner here just to print one annotation. The run already shows up as failed, and GitHub already sends an email about it to whoever published the release.

If we want to keep the message, a last step in publish with if: failure() || cancelled() would be enough. The only case it doesn't cover is a failing check job, and that one already shows up as a failed run too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it into a last step in publish with if: failure() || cancelled(). I also added a line saying to resubmit with eas submit --id if only the store upload failed, because re-running the workflow would see the finished build and publish an OTA update instead.

name: Report failure
needs: publish
if: ${{ always() && needs.publish.result != 'success' }}
runs-on: ubuntu-latest
steps:
- name: Report failure
env:
TAG: ${{ github.event.release.tag_name || inputs.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"
27 changes: 0 additions & 27 deletions .github/workflows/updates.yml

This file was deleted.

2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make this more user focused. it should say what to do. something like:

Suggested change
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

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.
Expand Down
2 changes: 2 additions & 0 deletions example/.fingerprintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# prevents build failures due to version mismatch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please clarify this comment. what kind of version mismatch and build failure?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this good enough explaination?
# built by pod install, would make the fingerprint differ from the one EAS computes

previously it had full context but I rewrote it as minimalistic as possible following suggestion on removing AI prose:

# 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/
7 changes: 4 additions & 3 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with appVersionSource: "remote" and no local versionCode, ensure we have run eas build:version:set so EAS has a correct version to start from

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one is done, I already seeded the values (iOS 26.0.5, Android 38) and they auto-incremented as expected to 26.0.7 and 39 from my test builds

"permissions": [
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE"
Expand All @@ -37,7 +38,7 @@
}
},
"runtimeVersion": {
"policy": "sdkVersion"
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/ba8f5139-58fe-48f0-b2f2-2d675b6eb2d4"
Expand Down
35 changes: 35 additions & 0 deletions example/eas.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"cli": {
"appVersionSource": "remote"
},
"build": {
"production": {
"node": "24.13.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the profile doesn't set node, the Node version is read from .nvmrc by EAS CLI, so we don't need to repeat it here:

Suggested change
"node": "24.13.0",

One catch is that .nvmrc is looked up in the project directory (example/), not the repo root. We can symlink it so there's still only one version to update:

ln -s ../.nvmrc example/.nvmrc

"autoIncrement": true,
"android": {
"buildType": "app-bundle"
},
"channel": "production"
},
"preview": {
"extends": "production",
"channel": "preview"
}
},
"submit": {
"production": {
"android": {
"track": "production"
},
"ios": {
"ascAppId": "1548934513"
}
},
"preview": {
"extends": "production",
"android": {
"track": "internal"
}
}
}
}
50 changes: 50 additions & 0 deletions scripts/__tests__/bump-example-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, expect, it } from '@jest/globals';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't really need tests for basic scripts

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"/
);
});
33 changes: 33 additions & 0 deletions scripts/bump-example-version.ts
Original file line number Diff line number Diff line change
@@ -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 <path to the example app config>'
);
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}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this script? Can't the workflow just do something like:

- name: Set example app version from the release tag
  if: steps.decide.outputs.build == 'true'
  working-directory: ./example
  env:
    TAG: ${{ steps.release.outputs.tag }}
  run: |
    version="${TAG#v}"
    version="${version%%-*}"
    jq --arg version "$version" '.expo.version = $version' app.json > app.json.tmp
    mv app.json.tmp app.json

so instead of reading version number from app.json, it'd use the release tag

also ensure the step fails if the tag doesn't match major.minor.patch (e.g. something went wrong)

Loading