Appearance
Sign and Release the Mobile App
What has to exist, once, before a build of services/mobile can go to the Play Store or TestFlight / the App Store — and how the two manual workflows (mobile-android.yml, mobile-ios.yml) use it. Nothing here is committed: keys and certificates live in GitHub secrets (and, for local builds, in git-ignored files).
Android (Google Play)
1. Create the upload keystore
Once, on any machine with a JDK:
bash
keytool -genkeypair -v \
-keystore tim-release.jks -alias tim \
-keyalg RSA -keysize 2048 -validity 10000Keep tim-release.jks and both passwords somewhere durable (a password manager). Losing the upload key means a support request to Google to reset it; losing it before enrolling in Play App Signing means never updating the app again.
2. Enrol in Play App Signing
In the Play Console, create the app (package name ovh.tim.app) and accept Play App Signing when uploading the first bundle. Google then holds the app signing key; the keystore above is only the upload key, which is what the builds are signed with.
3. Secrets for the workflow
| Secret | Value |
|---|---|
ANDROID_KEYSTORE_BASE64 | base64 -w0 tim-release.jks |
ANDROID_KEYSTORE_PASSWORD | the keystore password |
ANDROID_KEY_ALIAS | tim |
ANDROID_KEY_PASSWORD | the key password |
ANDROID_GOOGLE_SERVICES_JSON | base64 -w0 google-services.json — optional, only for push (below) |
Run Mobile app (Android, manual) with release ticked. It attaches app-release.aab (upload this to the Play Console) and a release APK.
Local release build
bash
cp services/mobile/android/keystore.properties.example services/mobile/android/keystore.properties
# fill in the passwords, put tim-release.jks next to it
pnpm --filter @tt/mobile build:web && pnpm --filter @tt/mobile sync
cd services/mobile/android && ./gradlew bundleReleaseandroid/app/build.gradle reads keystore.properties when it exists and falls back to the debug key otherwise, so a checkout without it still builds.
4. Version numbers
Bump versionCode (must increase on every upload) and versionName in android/app/build.gradle.
iOS (App Store / TestFlight)
1. Apple Developer account
An Apple Developer Program membership (the paid one) for the team that will own the app. Note the Team ID (Membership details).
2. App ID and app record
- Certificates, Identifiers & Profiles → Identifiers: register
ovh.tim.appwith the Push Notifications capability. - App Store Connect → Apps: create the app with that bundle ID.
3. Distribution certificate
On a Mac with Xcode: Xcode → Settings → Accounts → the team → Manage Certificates → + → Apple Distribution. Then in Keychain Access export that certificate (with its private key) as a .p12 with a password.
4. App Store Connect API key
App Store Connect → Users and Access → Integrations → App Store Connect API → +. Role App Manager is enough. Download the AuthKey_<KEY_ID>.p8 (only offered once) and note the Key ID and Issuer ID.
With this key, xcodebuild -allowProvisioningUpdates creates and fetches the App Store provisioning profile itself, and -exportArchive can upload straight to TestFlight — no manual profiles, no altool.
5. Secrets for the workflow
| Secret | Value |
|---|---|
IOS_TEAM_ID | the Team ID |
IOS_DIST_CERT_P12_BASE64 | base64 -i cert.p12 |
IOS_DIST_CERT_PASSWORD | the .p12 password |
ASC_API_KEY_ID | the API key's Key ID |
ASC_API_ISSUER_ID | the Issuer ID |
ASC_API_KEY_P8_BASE64 | base64 -i AuthKey_<KEY_ID>.p8 |
Run Mobile app (iOS, manual). Without upload it attaches the signed .ipa; with it, the export step uploads to App Store Connect and the build appears in TestFlight after processing. It runs on a macOS runner (10× minutes), so treat each run as deliberate.
Local build
Open the project (pnpm --filter @tt/mobile open:ios), select the team under Signing & Capabilities and let Xcode manage signing. Product → Archive → Distribute App.
6. Version numbers
MARKETING_VERSION and CURRENT_PROJECT_VERSION in the Xcode project (the workflow's export options let App Store Connect bump the build number itself).
Push notifications — one-time setup
iOS (APNs)
- Certificates, Identifiers & Profiles → Keys → + → enable Apple Push Notifications service (APNs). Download
AuthKey_<KEY_ID>.p8and note the Key ID. One key serves every app of the team, sandbox and production alike. - In Xcode, add the Push Notifications capability to the App target (this adds
App.entitlementswithaps-environment). Commit that change. - Leave the two
didRegisterForRemoteNotificationsWithDeviceToken/didFailToRegisterForRemoteNotificationsWithErrormethods inios/App/App/AppDelegate.swiftin place (they are already committed). They forward the APNs token to@capacitor/push-notifications; without them the plugin never reports a registration, and the app gives up after 10 seconds with no subscription. Keep them if the native project is ever regenerated from the Capacitor template. - On the API:
APNS_KEY_ID,APNS_TEAM_ID,APNS_KEY_P8(base64 -i AuthKey_<KEY_ID>.p8), andAPNS_ENVIRONMENT=sandboxfor an API that serves Xcode debug builds,productionfor TestFlight and the App Store.APNS_BUNDLE_IDdefaults toovh.tim.app.
Android (FCM)
- In the Firebase console, create a project (or reuse one) and add an Android app with package
ovh.tim.app. Downloadgoogle-services.jsonintoservices/mobile/android/app/(git-ignored; CI gets it fromANDROID_GOOGLE_SERVICES_JSON). A build without it still works, but has no push: the app detects this (see Push notifications in the app) and the profile page shows push as unavailable. - Project settings → Service accounts → Generate new private key. On the API:
FCM_SERVICE_ACCOUNT=base64 -w0 service-account.json.
The API's push service reads these at start-up and logs which channels are on. Nothing else changes: a notification created for a member fans out to their browsers, iPhones and Android phones alike.
Google sign-in — one-time setup
The app signs in with a Google ID token obtained natively, verified by the API. Google Cloud console → APIs & Services → Credentials, in the same project as the existing web client:
Android OAuth client: package
ovh.tim.appplus the SHA-1 of every signing key that will run the app — the debug key (keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android), the upload key above, and the app signing key Google shows in the Play Console (Setup → App signing). Nothing from this client goes into the app; it just has to exist.iOS OAuth client: bundle ID
ovh.tim.app. Its client ID (123456-abcdef.apps.googleusercontent.com) goes on the API asGOOGLE_IOS_CLIENT_ID— and, unlike every other setting here, into the iOS build as well. The Google SDK checks that the client's reversed ID (com.googleusercontent.apps.123456-abcdef) is one of the app's URL schemes, and when it is not it raises an exception that kills the app.ios/App/App/Info.plistdeclares that scheme as$(GOOGLE_IOS_URL_SCHEME), so a build needs two inputs from the same value:Where What Web bundle ( pnpm --filter @tt/client build:native)TIM_GOOGLE_IOS_CLIENT_ID=123456-abcdef.apps.googleusercontent.comin the environmentXcode build setting GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.123456-abcdef— on thexcodebuildcommand line, or for local debug builds a line inios/debug.xcconfigThe Mobile app (iOS, manual) workflow does both from the
GOOGLE_IOS_CLIENT_IDrepository variable (Settings → Secrets and variables → Actions → Variables; not a secret, the API publishes it on/api/config). Without these the project's default is a placeholder scheme that matches no client, the bundle carries no iOS client ID, and the app shows no Google button on iOS — it never starts a sign-in the build cannot survive. The button appears only when the API'sGOOGLE_IOS_CLIENT_IDis the one the build was made for (lib/nativeGoogle.ts), so rotating the iOS client means a new app build.
The API publishes both client IDs on /api/config; the app only shows the Google button once it has what its platform needs. As on the web, Google sign-in only matches existing accounts — it never creates one.
App Store guideline 4.8
An iOS app that offers Google sign-in must also offer Sign in with Apple. That is not implemented yet; until it is, either keep GOOGLE_IOS_CLIENT_ID unset (no Google button on iOS, e-mail sign-in only) or add Apple sign-in first.