MobiWeb Maintainer Help

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

apps/<client>/<app>/dev

Signed APK to Firebase App Distribution

TestFlight

apps/<client>/<app>/prod

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 only Writerside/** or *.md files are ignored.

  • Manual run (workflow_dispatch): open Actions → Mobile Release → Run workflow, pick an app branch (dev or prod), then pick the platforms: both, android or ios. 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 show both.

  • Job names carry the store IDs and destination, e.g. Android ph.ity.unitcare_dev · dev → Firebase and iOS 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

ASC_KEY_ID

App Store Connect API key ID (team key)

ASC_ISSUER_ID

App Store Connect issuer ID, shown above the Team Keys table

ASC_KEY_P8

Contents of the .p8 file, pasted as-is

IOS_DIST_CERT_P12_BASE64

Apple Distribution certificate and private key as .p12, base64-encoded

IOS_DIST_CERT_PASSWORD

Password of that .p12

ANDROID_KEYSTORE_BASE64

The shared JKS keystore holding every app's upload key, base64-encoded

ANDROID_KEYSTORE_PASSWORD

Store password of that keystore

FIREBASE_SERVICE_ACCOUNT_JSON

Shared service account for Firebase App Distribution (dev uploads)

PLAY_SERVICE_ACCOUNT_JSON

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

ANDROID_KEY_ALIAS

secret

Android

This app's alias in the shared keystore, e.g. ity-maridien

ANDROID_KEY_PASSWORD

secret

Android

This app's key password

FIREBASE_TESTER_GROUPS

variable

dev

Comma-separated tester group aliases, e.g. qa,internal. If empty, the build uploads but invites nobody

PLAY_RELEASE_STATUS

variable

prod

completed (default) or draft. Use draft while the Play app has never been published

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 App and NotificationService.

  • Distribution certificate: the .p12 signs 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 two IOS_DIST_CERT_* secrets.

Creating the .p12 on Windows with OpenSSL, in a folder outside the repo:

openssl genrsa -out dist.key 2048 openssl req -new -key dist.key -out dist.csr -subj "/CN=Mobiweb CI Distribution/C=PH" # Upload dist.csr at developer.apple.com → Certificates → + → Apple Distribution; download distribution.cer Invoke-WebRequest https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer -OutFile wwdr.cer openssl x509 -inform DER -in distribution.cer -out distribution.pem openssl x509 -inform DER -in wwdr.cer -out wwdr.pem openssl pkcs12 -export -inkey dist.key -in distribution.pem -certfile wwdr.pem -name "Apple Distribution" ` -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 -out dist.p12 [Convert]::ToBase64String([IO.File]::ReadAllBytes("$PWD\dist.p12")) | gh secret set IOS_DIST_CERT_P12_BASE64 gh secret set IOS_DIST_CERT_PASSWORD

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 JKS or -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_JSON as an environment secret.

Repository variables (optional)

Variable

Default

What

IOS_RUNNER

macos-26

macOS runner image for the iOS job

XCODE_VERSION

latest-stable

Xcode version selected with setup-xcode

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.

  1. Apple: register the App IDs <bundle> and <bundle>.NotificationService. The main ID needs the capabilities from App.entitlements: Push Notifications, Associated Domains, Communication Notifications and Time Sensitive Notifications. Create the App Store Connect app record, and optionally an internal TestFlight group.

  2. Environment: gh api -X PUT repos/Dwigoric/mobiweb/environments/<client>.<app>.

  3. Android key: import or generate the key in the shared keystore and re-upload ANDROID_KEYSTORE_BASE64. Then set ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD with gh secret set … -e <client>.<app>.

  4. 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 set FIREBASE_TESTER_GROUPS.

  5. 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.

  6. Branch: merge main into 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

packageManager … yarn@4 … Corepack must be enabled

package.json declares Yarn 4, but .yarnrc.yml pins Yarn classic. The workflow sets SKIP_YARN_COREPACK_CHECK=1; keep it.

./gradlew exits with code 126

android/gradlew is tracked without its executable bit. The workflow runs chmod +x first.

Warning: cap sync ios failed … re-running pod install

Expected. Capacitor's final xcodebuild clean trips over the nanopb SPM checkout's build/ folder, so the workflow re-runs pod install. A real problem still fails the archive step.

Missing required env: …

A secret is missing at both environment and repository level. Check with gh secret list and gh secret list -e <client>.<app>.

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 .p12.

Running locally

bundle install # Android: export the same env vars as the workflow, then bundle exec fastlane android release channel:dev # iOS (macOS only): export ASC_* / IOS_DIST_CERT_* (+ KEYCHAIN_PASSWORD for the login keychain), then bundle exec fastlane ios beta

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.

15 November 2026