Native Signing¶
Shared, reproducible signing so any machine (or CI) produces the same signed
binary. iOS uses fastlane match (certs in a private GCS bucket); Android uses
a shared upload keystore with Play App Signing. Every secret lives in GCP
Secret Manager and is fetched at build time by frontend/scripts/build-with-secrets.sh.
Nothing signing-related is committed, and no secret is written to a tracked path.
Secret inventory (GCP Secret Manager)¶
Project development-485000 (override with GCP_PROJECT). task native:doctor
reports which are present.
| Secret | Contents | Used by |
|---|---|---|
tomoda-ios-match-passphrase |
the match encryption passphrase (plain string) | match → MATCH_PASSWORD |
tomoda-asc-api-key |
App Store Connect API key JSON (key_id, issuer_id, key) |
pilot/deliver uploads |
tomoda-android-upload-keystore |
the upload keystore, base64 | Gradle release signing |
tomoda-android-keystore-passwords |
JSON { store_password, key_alias, key_password } |
Gradle release signing |
tomoda-play-service-account |
Play Developer API service-account JSON | supply uploads |
tomoda-sentry-dsn, tomoda-sentry-auth-token |
Sentry (existing) | source-map upload |
Only GCP auth reaches CI (GCP_WIF_PROVIDER + GCP_SERVICE_ACCOUNT); the secrets
above never become GitHub secrets.
iOS — fastlane match on GCS¶
match keeps the distribution cert + App Store provisioning profile encrypted
in a private GCS bucket (tomoda-ios-certs, override MATCH_GCS_BUCKET). No git
repo. Access uses Application Default Credentials; the passphrase decrypts. Config
is in frontend/fastlane/Matchfile — readonly(true), so machines and CI only
ever read; provisioning is a deliberate one-time admin action.
Builds sign manually with the match identity, never the local Xcode account
— that's what makes iOS signing reproducible and shareable. The app ships a
Share Extension (com.tomoda.app.ShareExtension), a separate target with its
own App ID and profile, so match and the gym export cover both bundle ids
(APP_IDS in the Fastfile).
First-time setup (once, by an admin with Apple access)¶
- Create the GCS bucket
tomoda-ios-certs(private, public-access-prevented):gcloud storage buckets create gs://tomoda-ios-certs --project=development-485000 --location=us-central1 --uniform-bucket-level-access --public-access-prevention. The one-time write below uses your own ADC; CI reads via workload identity. - Choose a strong passphrase → store it:
printf '%s' '<passphrase>' | gcloud secrets create tomoda-ios-match-passphrase --project=development-485000 --data-file=-. - Create an App Store Connect API key (Users and Access → Integrations →
App Store Connect API, role: App Manager). Download the
.p8(one-time), note the key id + issuer id, and store astomoda-asc-api-keyJSON ({key_id, issuer_id, key}). Build the JSON with a temp file (avoids shell-quoting the PEM), then remove it:python3 -c 'import json,sys; print(json.dumps({"key_id":sys.argv[1],"issuer_id":sys.argv[2],"key":open(sys.argv[3]).read()}))' \ "$KEY_ID" "$ISSUER_ID" ~/Downloads/AuthKey_${KEY_ID}.p8 > /tmp/asc.json gcloud secrets create tomoda-asc-api-key --project=development-485000 --data-file=/tmp/asc.json && rm -f /tmp/asc.json - In the Apple Developer portal → Identifiers, before provisioning:
- App Groups: ensure
group.com.tomoda.app.shareexists. - On both App IDs (
com.tomoda.appandcom.tomoda.app.ShareExtension— register the extension id if missing) enable App Groups and assign that group. Keychain Sharing needs no portal toggle (entitlement-only). - Populate the cert store (read-write, one Apple login via the API key). API-key
auth needs
SPACESHIP_CONNECT_API_IN_HOUSE=false(standard App Store team, not Enterprise);--forceregenerates the profiles so they carry App Groups:match imports the distribution cert into your login keychain (it prompts for your macOS login-keychain password once). After this, every machine/CI resolves signing withcd frontend export MATCH_PASSWORD=$(gcloud secrets versions access latest --secret=tomoda-ios-match-passphrase --project=development-485000) export SPACESHIP_CONNECT_API_IN_HOUSE=false TMP=$(mktemp -d); gcloud secrets versions access latest --secret=tomoda-asc-api-key --project=development-485000 > "$TMP/asc.json" bundle exec fastlane match appstore --api_key_path "$TMP/asc.json" --readonly false --force rm -rf "$TMP"readonly, andtask native:beta:iosbuilds + uploads to TestFlight.
Android — shared upload keystore + Play App Signing¶
Play App Signing is on: Google holds the real app signing key; you ship an upload key. Rotating the upload key is safe and doesn't change the app's identity to users.
The generated android/app/build.gradle gets a release signingConfig from the
config plugin frontend/plugins/with-android-signing.js. It reads Gradle project
properties TOMODA_UPLOAD_*, which build-with-secrets.sh exports as
ORG_GRADLE_PROJECT_* from the secrets above at build time. With the properties
absent (a plain expo run:android), it falls back to the debug keystore so dev
builds still run.
First-time setup (once)¶
All secrets go in project development-485000, encoded so shell quoting can't
corrupt them.
-
Generate the upload keystore (alias
tomoda-upload— the config plugin and the passwords secret both reference it), and back it up offline:keytool -genkeypair -v -keystore upload.keystore -alias tomoda-upload \ -keyalg RSA -keysize 2048 -validity 9125 -
Store the keystore + passwords. Read the password interactively and JSON-encode it (works in bash and zsh):
base64 -i upload.keystore | gcloud secrets create tomoda-android-upload-keystore \ --project=development-485000 --data-file=- printf 'keystore password: '; read -rs PW; echo PW="$PW" python3 -c 'import json,os; print(json.dumps({"store_password":os.environ["PW"],"key_alias":"tomoda-upload","key_password":os.environ["PW"]}))' \ | gcloud secrets create tomoda-android-keystore-passwords --project=development-485000 --data-file=- -
Create the Play service account + key. SA-key creation is blocked org-wide by
iam.managed.disableServiceAccountKeyCreation; an org-policy admin exempts the build project once (the exemption takes ~5–10 min to reach the IAM enforcement layer), then the key is created:(Workload Identity is the keyless alternative, butgcloud iam service-accounts create play-publisher --project=development-485000 \ --display-name="Google Play Publisher" gcloud organizations add-iam-policy-binding <ORG_ID> \ --member="user:you@tomoda.life" --role="roles/orgpolicy.policyAdmin" gcloud org-policies set-policy /dev/stdin <<'EOF' name: projects/development-485000/policies/iam.managed.disableServiceAccountKeyCreation spec: rules: - enforce: false EOF gcloud iam service-accounts keys create play-sa.json \ --iam-account=play-publisher@development-485000.iam.gserviceaccount.com gcloud secrets create tomoda-play-service-account --project=development-485000 --data-file=play-sa.json rm play-sa.jsonfastlane supplyexpects a key — see Play Store.) -
Grant + enable. Grant the SA through Play Console → Users and permissions → Invite new users (type the SA email
play-publisher@development-485000.iam.gserviceaccount.com). On its Account permissions tab check Release apps to testing tracks — that single scope is all the beta lane needs — then Save changes (the grant isn't live until saved). Add Release to production later, when you ship prod. Enable the API:Inviting the SA under Users and permissions is the whole grant — the Setup → API access page (which links a Cloud project) is owner-only and not required for this flow. Play App Signing registers your upload key automatically on the firstgcloud services enable androidpublisher.googleapis.com --project=development-485000.aabupload — no manual toggle.
Grants take a few minutes to propagate. Confirm the SA can reach the app before building (200 = authorized, 403 = not yet / wrong scope):
P=development-485000; TMP=$(mktemp -d); export CLOUDSDK_CONFIG="$TMP/cfg"
gcloud secrets versions access latest --secret=tomoda-play-service-account --project=$P > "$TMP/k.json"
gcloud auth activate-service-account --key-file="$TMP/k.json" >/dev/null 2>&1
TOKEN=$(gcloud auth print-access-token --scopes=https://www.googleapis.com/auth/androidpublisher)
curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Length: 0" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.tomoda.app/edits"
rm -rf "$TMP"
- Verify the stored password opens the stored keystore before building:
P=development-485000; TMP=$(mktemp -d) gcloud secrets versions access latest --secret=tomoda-android-upload-keystore --project=$P | base64 -d > "$TMP/ks" PW=$(gcloud secrets versions access latest --secret=tomoda-android-keystore-passwords --project=$P \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["store_password"])') keytool -list -keystore "$TMP/ks" -storepass "$PW" -alias tomoda-upload && echo "pair OK"; rm -rf "$TMP"
Rotation¶
- iOS cert expiry: re-run the read-write
fastlane match appstore(ormatch nuke+ regenerate) once; readonly consumers pick it up automatically. - Android upload key: generate a new keystore, request an upload-key reset in Play Console, replace the two Android secrets. Play App Signing means users are unaffected.
- ASC API key / Play service account: rotate in the respective portal, then overwrite the secret with a new version.
See also¶
- Native Release — how the lanes use all of this.
- App Store / Play Store — portal specifics.