Apps and clients
Building and publishing the apps
Your app is built from this repository with your brand applied (brands/<slug>/brand.json). You can
build it in three ways, and they all produce the same signed files: an .aab for Google Play and an
.ipa for the App Store.
- GitHub Actions (recommended): the
Brand buildworkflow. It runs on GitHub's machines, so you don't need a Mac. - Your own machine, with the same commands the workflow runs.
- EAS Build, on your own Expo account.
Before the first build
You need Node 24.12 or newer. For iOS you need a Mac with Xcode 26.4+, for Android JDK 17 and the Android SDK.
For the signing and upload steps you need Ruby 4.0 with Bundler (bundle install installs fastlane).
npm ci # also applies the default brand
npm run brand:apply -- <your-slug>
npm run brand:check # the app's ids are yours; nothing else is left in
Signing, once per app
| What to do | |
|---|---|
| Android | bundle exec fastlane android bootstrap_signing creates your upload keystore in signing/<slug>/ and prints the commands that store it as GitHub secrets. Keep a backup: you need it for every future update. |
| Google Play | In Play Console: create the app, turn on Play App Signing, and upload the first .aab by hand (Google doesn't allow a first upload through the API). For uploads from the workflow, create a Google Cloud service account and invite it under Users and permissions, on this app only, with View app information and Release apps to testing tracks. |
| Apple | In App Store Connect → Users and Access → Integrations, create a team key (Admin), and note its Key ID and Issuer ID. If the tab asks for it, the Account Holder must first click Request Access. Then on a Mac: ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_P8_PATH=AuthKey_….p8 bundle exec fastlane ios bootstrap_signing. It registers the app's identifier with push notifications and creates the distribution certificate in signing/<slug>/. |
| App Store Connect | Create the app (My Apps → +) with your bundle ID: Apple has no API for this step. |
Never commit signing/; it is in .gitignore.
Your Expo project and push notifications, once per app
Push notifications and over-the-air updates belong to your own Expo project:
- Expo project. Run
npx eas-cli login(your Expo account), thennpx eas-cli init. Put the project ID it prints inbrand.jsonasidentity.easProjectId, and your account or organisation name asidentity.easOwner. A store build refuses to start until this is set. - Android push (Firebase Cloud Messaging).
- Create a Firebase project and add an Android app with your package name (
identity.androidPackage). - Download
google-services.jsonintobrands/<slug>/firebase/, and add the iOS app'sGoogleService-Info.plistthere too. - In
brand.json, set"services": { "firebase": { "android": "firebase/google-services.json", "ios": "firebase/GoogleService-Info.plist", "analytics": false } }. Set"analytics": trueto also offer opt-in usage analytics. - In Firebase, open Project settings → Service accounts → Generate new private key. Upload that
JSON with
npx eas-cli credentials -p android→ Google Service Account → Push Notifications (FCM V1).
- Create a Firebase project and add an Android app with your package name (
- iOS push. Run
npx eas-cli credentials -p ios→ Push Notifications. Let EAS create the push key, or upload a.p8key with Apple Push Notifications enabled (developer portal → Keys). - The backend. If you turn on Enhanced Security for Push Notifications in your Expo project, create an access token there. Paste it in the admin under Settings → Integrations → Expo push access token.
- Check it. Install a build on an iPhone and an Android phone, sign in and allow notifications.
Then, on the server, run
php bin/console app:push:test you@example.com: each phone gets a test notification. If not, the command prints Expo's reason, for example missing FCM credentials.
1. GitHub Actions
In your repository's Settings → Environments, create an environment named exactly your brand's slug. Add these secrets to it:
| Secret | Value |
|---|---|
ANDROID_KEYSTORE | base64 < signing/<slug>/upload.keystore |
ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD | from signing/<slug>/upload.keystore.password |
ANDROID_KEY_ALIAS | upload |
PLAY_SERVICE_ACCOUNT_JSON | optional, for uploads: base64 < service-account.json |
ASC_KEY_ID, ASC_ISSUER_ID | from App Store Connect |
ASC_KEY_P8 | base64 < AuthKey_XXXXXXXXXX.p8 |
APPLE_TEAM_ID | your team ID (Membership details in the developer account) |
IOS_DIST_CERT_P12, IOS_DIST_CERT_PASSWORD | from signing/<slug>/ (the bootstrap prints the commands) |
SENTRY_AUTH_TOKEN | optional, to upload crash-report source maps |
Add these as environment variables:
EXPO_PUBLIC_API_BASE_URL,EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY,EXPO_PUBLIC_PUSHER_KEYandEXPO_PUBLIC_PUSHER_CLUSTER;- if your app is already in the stores,
BUILD_NUMBER_BASE: a number above its latest build number.
Then go to Actions → Brand build → Run workflow. Enter your slug, choose the platform, and tick upload to send the build to Play internal testing and TestFlight.
What a run does:
- It applies and checks the brand, then runs the type and lint checks.
- It checks every signing secret before building. A wrong password or a certificate from another team stops the run with the secret's name, never its value.
- It builds both platforms in parallel.
- The run's artifacts hold the
.aab, the.ipaandbuild-manifest.json(version, build number, commit, native fingerprints, SHA-256 of each file).
Timing and cost: an iOS build takes about 20 to 35 minutes on GitHub's macOS machines, and an Android build about 15 to 20 minutes. GitHub bills private repositories per minute, and macOS minutes cost about ten times Linux ones.
Updates without a store review
JavaScript-only fixes reach installed apps through Actions → Brand update. This needs one more
Environment secret: EXPO_TOKEN, an access token from your Expo account settings. The workflow:
- finds your latest store builds;
- refuses the update if native code changed since then, because such an update would reach nobody and needs a store build;
- otherwise publishes to the
productionchannel.
Installed apps pick it up on their next launch.
2. Your own machine
export APP_VARIANT=store BUILD_NUMBER=42 # a number higher than the store's latest build
Android:
npx expo prebuild -p android --clean
ANDROID_KEYSTORE_PATH=$PWD/signing/<slug>/upload.keystore \
ANDROID_KEYSTORE_PASSWORD=… ANDROID_KEY_ALIAS=upload ANDROID_KEY_PASSWORD=… \
./android/gradlew -p android :app:bundleRelease
# → android/app/build/outputs/bundle/release/app-release.aab
iOS (on a Mac):
npx expo prebuild -p ios --clean
ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_P8_PATH=AuthKey_….p8 APPLE_TEAM_ID=… \
IOS_DIST_CERT_PATH=signing/<slug>/distribution.p12 IOS_DIST_CERT_PASSWORD=… \
bundle exec fastlane ios build
# → build/ios/<slug>.ipa
To upload:
- Android:
AAB_PATH=… PLAY_SERVICE_ACCOUNT_JSON=$(base64 < service-account.json) bundle exec fastlane android upload. - iOS:
IPA_PATH=… bundle exec fastlane ios upload(with the sameASC_*variables).
For development, npx expo run:ios and npx expo run:android build a debug app that loads code
from your machine.
3. EAS Build
If you prefer Expo's build service, on your own Expo account:
npx eas-cli build --platform all --profile production
EAS creates and stores the signing credentials for you. eas build --local runs the same build on
your machine, but still needs you to be logged in to Expo. The store profiles in eas.json set
APP_VARIANT=store, like the workflow does.
Found a mistake? Tell us.