Skip to content

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) matchMATCH_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/Matchfilereadonly(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)

  1. 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.
  2. Choose a strong passphrase → store it: printf '%s' '<passphrase>' | gcloud secrets create tomoda-ios-match-passphrase --project=development-485000 --data-file=-.
  3. 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 as tomoda-asc-api-key JSON ({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
    
  4. In the Apple Developer portal → Identifiers, before provisioning:
  5. App Groups: ensure group.com.tomoda.app.share exists.
  6. On both App IDs (com.tomoda.app and com.tomoda.app.ShareExtension — register the extension id if missing) enable App Groups and assign that group. Keychain Sharing needs no portal toggle (entitlement-only).
  7. 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); --force regenerates the profiles so they carry App Groups:
    cd 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"
    
    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 with readonly, and task native:beta:ios builds + 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.

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

  2. 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=-
    

  3. 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:

    gcloud 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.json
    
    (Workload Identity is the keyless alternative, but fastlane supply expects a key — see Play Store.)

  4. 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:

    gcloud services enable androidpublisher.googleapis.com --project=development-485000
    
    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 first .aab upload — 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"

  1. 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 (or match 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