Skip to content

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.

  1. GitHub Actions (recommended): the Brand build workflow. It runs on GitHub's machines, so you don't need a Mac.
  2. Your own machine, with the same commands the workflow runs.
  3. 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
Androidbundle 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 PlayIn 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.
AppleIn 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 ConnectCreate 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:

  1. Expo project. Run npx eas-cli login (your Expo account), then npx eas-cli init. Put the project ID it prints in brand.json as identity.easProjectId, and your account or organisation name as identity.easOwner. A store build refuses to start until this is set.
  2. Android push (Firebase Cloud Messaging).
    • Create a Firebase project and add an Android app with your package name (identity.androidPackage).
    • Download google-services.json into brands/<slug>/firebase/, and add the iOS app's GoogleService-Info.plist there too.
    • In brand.json, set "services": { "firebase": { "android": "firebase/google-services.json", "ios": "firebase/GoogleService-Info.plist", "analytics": false } }. Set "analytics": true to 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).
  3. iOS push. Run npx eas-cli credentials -p ios → Push Notifications. Let EAS create the push key, or upload a .p8 key with Apple Push Notifications enabled (developer portal → Keys).
  4. 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.
  5. 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:

SecretValue
ANDROID_KEYSTOREbase64 < signing/<slug>/upload.keystore
ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORDfrom signing/<slug>/upload.keystore.password
ANDROID_KEY_ALIASupload
PLAY_SERVICE_ACCOUNT_JSONoptional, for uploads: base64 < service-account.json
ASC_KEY_ID, ASC_ISSUER_IDfrom App Store Connect
ASC_KEY_P8base64 < AuthKey_XXXXXXXXXX.p8
APPLE_TEAM_IDyour team ID (Membership details in the developer account)
IOS_DIST_CERT_P12, IOS_DIST_CERT_PASSWORDfrom signing/<slug>/ (the bootstrap prints the commands)
SENTRY_AUTH_TOKENoptional, to upload crash-report source maps

Add these as environment variables:

  • EXPO_PUBLIC_API_BASE_URL, EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY, EXPO_PUBLIC_PUSHER_KEY and EXPO_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 .ipa and build-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 production channel.

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 same ASC_* 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.