Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
225 changes: 225 additions & 0 deletions .github/workflows/publish-example-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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

defaults:
run:
shell: bash

concurrency:
group: publish-example-app

jobs:
check:
name: Check release is on main
runs-on: ubuntu-latest
outputs:
on-main: ${{ steps.check.outputs.on-main }}
sha: ${{ steps.check.outputs.sha }}
Comment on lines +31 to +36

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 have github.event.release.tag_name || inputs.tag in three places. We could define it once in the check job and pass it on as an output:

Suggested change
runs-on: ubuntu-latest
outputs:
on-main: ${{ steps.check.outputs.on-main }}
sha: ${{ steps.check.outputs.sha }}
runs-on: ubuntu-latest
env:
TAG: ${{ github.event.release.tag_name || inputs.tag }}
outputs:
on-main: ${{ steps.check.outputs.on-main }}
sha: ${{ steps.check.outputs.sha }}
tag: ${{ env.TAG }}

Then we can drop TAG from the step's env below, and read it from the output in the other two jobs:

TAG: ${{ needs.check.outputs.tag }}

steps:
- id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
status=$(gh api "repos/$GH_REPO/compare/main...$TAG" --jq .status)

if [ "$status" = "identical" ] || [ "$status" = "behind" ]; then
echo "on-main=true" >> "$GITHUB_OUTPUT"
echo "sha=$(gh api "repos/$GH_REPO/commits/$TAG" --jq .sha)" >> "$GITHUB_OUTPUT"
else
echo "on-main=false" >> "$GITHUB_OUTPUT"
echo "$TAG is not on main ($status), nothing to deploy." >> "$GITHUB_STEP_SUMMARY"
fi

publish:
name: Build and submit example app
needs: check
if: needs.check.outputs.on-main == 'true'
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.

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.check.outputs.sha }}

- 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: Set example app version from the release tag
working-directory: ./example
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
version="${TAG#v}"
version="${version%%-*}"

if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Could not read a major.minor.patch version from $TAG."
exit 1
fi

jq --arg version "$version" '.expo.version = $version' app.json > app.json.tmp
mv app.json.tmp app.json

- 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

if [ -z "$platforms" ]; then
echo "::error::No platforms to check."
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.

Same here, platforms can't be empty since platform already defaults to all in the resolve step.

Suggested change
if [ -z "$platforms" ]; then
echo "::error::No platforms to check."
exit 1
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 'if type == "array" then length else "notanarray" end')

case "$existing" in
''|*[!0-9]*)
echo "::error::Could not read builds for $platform."
exit 1
;;
esac

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

- 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 }}
PLATFORM: ${{ steps.release.outputs.platform }}
TAG: ${{ steps.release.outputs.tag }}
run: |
eas update \
--channel "$PROFILE" \
--platform "$PLATFORM" \
--message "$TAG" \
--non-interactive

- 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: [check, publish]
if: ${{ always() && (needs.check.result == 'failure' || (needs.publish.result != 'success' && needs.publish.result != 'skipped')) }}
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.

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

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 @@
# built by pod install, would make the fingerprint differ from the one EAS computes
**/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"
}
}
}
}
7 changes: 7 additions & 0 deletions example/fingerprint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @type {import('expo/fingerprint').Config} */
module.exports = {
sourceSkips: [
'ExpoConfigVersions',
'PackageJsonAndroidAndIosScriptsIfNotContainRun',
],
};
Loading