Skip to content

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 10000

Keep 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 ​

SecretValue
ANDROID_KEYSTORE_BASE64base64 -w0 tim-release.jks
ANDROID_KEYSTORE_PASSWORDthe keystore password
ANDROID_KEY_ALIAStim
ANDROID_KEY_PASSWORDthe key password
ANDROID_GOOGLE_SERVICES_JSONbase64 -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 bundleRelease

android/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.app with 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 ​

SecretValue
IOS_TEAM_IDthe Team ID
IOS_DIST_CERT_P12_BASE64base64 -i cert.p12
IOS_DIST_CERT_PASSWORDthe .p12 password
ASC_API_KEY_IDthe API key's Key ID
ASC_API_ISSUER_IDthe Issuer ID
ASC_API_KEY_P8_BASE64base64 -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) ​

  1. Certificates, Identifiers & Profiles → Keys → + → enable Apple Push Notifications service (APNs). Download AuthKey_<KEY_ID>.p8 and note the Key ID. One key serves every app of the team, sandbox and production alike.
  2. In Xcode, add the Push Notifications capability to the App target (this adds App.entitlements with aps-environment). Commit that change.
  3. Leave the two didRegisterForRemoteNotificationsWithDeviceToken / didFailToRegisterForRemoteNotificationsWithError methods in ios/App/App/AppDelegate.swift in 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.
  4. On the API: APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_P8 (base64 -i AuthKey_<KEY_ID>.p8), and APNS_ENVIRONMENT=sandbox for an API that serves Xcode debug builds, production for TestFlight and the App Store. APNS_BUNDLE_ID defaults to ovh.tim.app.

Android (FCM) ​

  1. In the Firebase console, create a project (or reuse one) and add an Android app with package ovh.tim.app. Download google-services.json into services/mobile/android/app/ (git-ignored; CI gets it from ANDROID_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.
  2. 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.app plus 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 as GOOGLE_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.plist declares that scheme as $(GOOGLE_IOS_URL_SCHEME), so a build needs two inputs from the same value:

    WhereWhat
    Web bundle (pnpm --filter @tt/client build:native)TIM_GOOGLE_IOS_CLIENT_ID=123456-abcdef.apps.googleusercontent.com in the environment
    Xcode build settingGOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.123456-abcdef — on the xcodebuild command line, or for local debug builds a line in ios/debug.xcconfig

    The Mobile app (iOS, manual) workflow does both from the GOOGLE_IOS_CLIENT_ID repository 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's GOOGLE_IOS_CLIENT_ID is 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.

TT Time Tracker — Internal Documentation