CI Release (GitHub Actions)
The Mobile Release workflow (.github/workflows/mobile-release.yml) builds and ships an app branch. It uses fastlane lanes in fastlane/Fastfile, with gems from the root Gemfile and fastlane/Pluginfile.
Branch | Android | iOS |
|---|---|---|
| Signed APK to Firebase App Distribution | TestFlight |
| Signed AAB to the Google Play internal track | TestFlight |
Every Android run also keeps the signed APK and AAB as workflow artifacts for 30 days. TestFlight builds reach internal testers once Apple finishes processing, usually 5–15 minutes.
Triggers
Push to
apps/*/*/prod: builds both platforms. Pushes that change onlyWriterside/**or*.mdfiles are ignored.Manual run (
workflow_dispatch): open Actions → Mobile Release → Run workflow, pick an app branch (dev or prod), then pick the platforms:both,androidorios. You can also start one from the CLI:gh workflow run mobile-release.yml --ref apps/<client>/<app>/dev -f platforms=ios.Push to prod without building: put
[skip ci]in the head commit message. GitHub then starts no run for that push.
Pushes to dev branches never start a run. Use a manual run for those.
A running build on a branch is never cancelled. While it runs, only the newest waiting run for that branch is kept; older waiting runs are cancelled.
The workflow refuses to run on any branch that does not match apps/<client>/<app>/<dev|prod>. iOS is skipped automatically while the iOS bundle ID is still the placeholder com.yourapp.mobiweb.
The tests (yarn test:offline-sw, yarn test:ios-sounds and ./gradlew testDebugUnitTest) must pass before anything uploads.
Run and job names
Run title:
Mobile Release · <branch> · <platforms>, e.g.Mobile Release · apps/ity/maridien/dev · ios. Prod pushes showboth.Job names carry the store IDs and destination, e.g.
Android ph.ity.unitcare_dev · dev → FirebaseandiOS ph.ity.maridien · dev → TestFlight.
GitHub only evaluates a job's name when the job runs. A skipped platform job therefore shows its raw ${{ … }} template. That's expected.
Build numbers
Build numbers are computed by CI and never committed:
Android
versionCode:max(build.gradle value, Play tracks internal/alpha/beta/production, latest Firebase release) + 1. A store is only checked when its credentials are present.iOS
CURRENT_PROJECT_VERSION:max(pbxproj value, latest TestFlight build) + 1.
versionName and MARKETING_VERSION still come from git. Bump them by hand for a new user-facing version.
Secrets
GitHub resolves each secrets.X from the job's environment first, then falls back to the repository. So every repository-level secret below can be overridden for a single app, for example a client with its own Apple team or Play account.
Repository-level (once, shared by all apps)
Secret | What |
|---|---|
| App Store Connect API key ID (team key) |
| App Store Connect issuer ID, shown above the Team Keys table |
| Contents of the |
| Apple Distribution certificate and private key as |
| Password of that |
| The shared JKS keystore holding every app's upload key, base64-encoded |
| Store password of that keystore |
| Shared service account for Firebase App Distribution (dev uploads) |
| Shared service account for Google Play (prod uploads) |
Per-app environment
Create one GitHub environment per app, named <client>.<app>, for example ity.maridien or ity.ity-unitcare-dev. The dev and prod branches of an app share the environment because they share the applicationId and signing key.
Name | Kind | Needed for | What |
|---|---|---|---|
| secret | Android | This app's alias in the shared keystore, e.g. |
| secret | Android | This app's key password |
| variable | dev | Comma-separated tester group aliases, e.g. |
| variable | prod |
|
If both service accounts are reachable, versionCode is checked against Play and Firebase on either branch type.
Apple: API key and distribution certificate
All apps use Apple team D33A49X2M4.
API key: create a Team key under App Store Connect → Users and Access → Integrations → App Store Connect API. Give it the Admin role, or App Manager with Access to Certificates, Identifiers & Profiles enabled. On every run it creates or renews the App Store provisioning profiles for
AppandNotificationService.Distribution certificate: the
.p12signs the build. It's required because signing with only the API key would make every fresh runner create a new Apple Development certificate, until Apple's certificate cap blocks builds. The certificate expires yearly. Before it does, create a new one (steps below) and update the twoIOS_DIST_CERT_*secrets.
Creating the .p12 on Windows with OpenSSL, in a folder outside the repo:
The legacy -keypbe, -certpbe and -macalg flags matter. The macOS keychain on the runner can fail to import OpenSSL 3's default .p12 format.
Android: shared keystore
All apps' upload keys live in one JKS keystore. Each app has its own alias and key password.
The keystore must be JKS, because PKCS12 forces each key password to equal the store password. Always pass
-storetype JKSor-deststoretype JKS.To bring in an existing app, copy its key in. The signature is preserved:
keytool -importkeystore -srckeystore <app>.jks -srcalias <old> -destkeystore mobiweb-apps.jks -deststoretype JKS -destalias <client>-<app>. With Play App Signing, this must be the app's upload key. Compare SHA-1s with Play Console → App integrity.For a new app, generate a key:
keytool -genkeypair -keystore mobiweb-apps.jks -storetype JKS -alias <client>-<app> -keyalg RSA -keysize 2048 -validity 10000.After any change, re-upload the whole file to
ANDROID_KEYSTORE_BASE64. Keep the master copy and its passwords backed up. Losing it means no app can ship an update signed with the same key.
Gradle reads the signing values and the versionCode through android/app/ci.gradle. Local builds without these env vars are unaffected.
Firebase: shared service account
The service account github-actions-app-distribution lives in a single home Google Cloud project.
Enable the Firebase App Distribution API in that home project, because API calls are billed and quota'd there. Otherwise uploads fail with "API has not been used in project …".
For each app, grant the service account Firebase App Distribution Admin in that app's Firebase project (IAM → Grant access). Then click App Distribution → Get started once.
Google Play: shared service account
The service account github-actions-play lives in the same home Google Cloud project.
Enable the Google Play Android Developer API in the home project.
In Play Console → Users and permissions, invite the service account email. Under App permissions, add each app with Release apps to testing tracks. Leave account-level permissions off.
A client in a different Play developer account needs its own
PLAY_SERVICE_ACCOUNT_JSONas an environment secret.
Repository variables (optional)
Variable | Default | What |
|---|---|---|
|
| macOS runner image for the iOS job |
|
| Xcode version selected with |
Like secrets, these can be overridden for one app with an environment variable of the same name.
App Store Connect only accepts builds made with a recent SDK. If uploads get rejected for the SDK version, bump these.
Onboarding an app
The CI part of a new app. For the full setup (config, icons, Firebase, store listings) see Setting Up a New App.
Apple: register the App IDs
<bundle>and<bundle>.NotificationService. The main ID needs the capabilities fromApp.entitlements: Push Notifications, Associated Domains, Communication Notifications and Time Sensitive Notifications. Create the App Store Connect app record, and optionally an internal TestFlight group.Environment:
gh api -X PUT repos/Dwigoric/mobiweb/environments/<client>.<app>.Android key: import or generate the key in the shared keystore and re-upload
ANDROID_KEYSTORE_BASE64. Then setANDROID_KEY_ALIASandANDROID_KEY_PASSWORDwithgh secret set … -e <client>.<app>.Firebase (dev): grant the shared service account access to the app's Firebase project (named in
android/app/google-services.json), click Get started, create a tester group, and setFIREBASE_TESTER_GROUPS.Play (prod): add the app to the Play service account's app permissions. The first AAB must be uploaded manually, because Google rejects API uploads before that. If the app was never published, set
PLAY_RELEASE_STATUS=draft.Branch: merge
maininto the app's dev branch, push it, and start a manual run. Push prod only when you're ready for a Play and TestFlight upload, or use[skip ci].
Troubleshooting
Symptom | Cause and fix |
|---|---|
|
|
|
|
Warning: | Expected. Capacitor's final |
| A secret is missing at both environment and repository level. Check with |
Play upload rejected / app not found | The first AAB was never uploaded manually, or the service account lacks permission for that app. |
iOS signing errors after about a year | The distribution certificate expired. Create a new |
Running locally
Cost note
The repository is private, so macOS runner minutes are billed at a multiplier. Prod pushes build iOS every time. Use a manual run with android when you don't need a TestFlight build.