For a Flutter car-wash booking app built on every merge, how does CI get the Android upload keystore and the iOS distribution certificate without committing them?
answer
- secrets in, files only at build time
- base64 keystore decoded on the runner
- key.properties generated, never committed
- temporary keychain on the macOS runner
- App Store Connect API key for profiles
basics
~20 sStore the keystore and certificate as encrypted CI secrets, materialise them only during the job: decode the base64 keystore and write key.properties for Gradle; import the .p12 and profiles into a temporary keychain on the macOS runner, then sign.
solid answer
~40 sNothing signing-related lives in the repository. For **Android**, the upload keystore is base64-encoded into an encrypted CI variable and decoded to a file during the job, and its passwords come from further secrets; the job writes `android/key.properties` (kept out of git) or exposes environment variables that the release `signingConfig` reads, then runs `flutter build appbundle`. For **iOS**, the job runs on macOS, creates a **temporary keychain**, and either imports a `.p12` certificate and provisioning profiles from secrets or fetches them with an App Store Connect API key; Codemagic's CLI tools do this with `keychain initialize`, `app-store-connect fetch-signing-files`, `keychain add-certificates` and `xcode-project use-profiles`, which also writes an export options plist for `flutter build ipa --export-options-plist`. Afterwards the job restores the default keychain and discards the files. Pull requests from forks never receive these secrets.
code
bash · 18 lines# Android job
echo "$UPLOAD_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/upload-keystore.jks"
cat > android/key.properties <<EOF
storePassword=$UPLOAD_STORE_PASSWORD
keyPassword=$UPLOAD_KEY_PASSWORD
keyAlias=upload
storeFile=$RUNNER_TEMP/upload-keystore.jks
EOF
flutter build appbundle
# iOS job (macOS)
keychain initialize
app-store-connect fetch-signing-files "$(xcode-project detect-bundle-id)" \
--platform IOS --type IOS_APP_STORE --certificate-key=@file:cert_key --create
keychain add-certificates
xcode-project use-profiles
flutter build ipa --export-options-plist=$HOME/export_options.plist
keychain use-logingo deeper
Recall that keystores and certificates are CI secrets, never committed, and that iOS signing runs on a macOS runner.
Explain the mechanics: base64-decoded keystore plus key.properties or environment variables for Gradle, and a temporary keychain with certificate and profiles for Xcode.
Harden it: no secrets on fork builds, no secrets in --dart-define, cleanup on self-hosted runners, and a recovery plan for a leaked upload key or revoked certificate.
Decide who owns signing identities, how access is granted and audited, and whether signing moves to a dedicated release environment separate from ordinary CI.
## The goal The car-wash booking app ships a signed Android App Bundle and a signed iOS IPA on every merge to `main`. The two signing identities are the most sensitive files the team owns: whoever holds the Android **upload key** or the iOS **distribution certificate's private key** can publish builds under the app's name. The rule is simple: **they never enter the repository**, and they exist on a build machine only for the duration of one job. ## Android: keystore from secrets 1. Encode the upload keystore once: `base64 -i upload-keystore.jks`, and store the output as an encrypted CI variable, with the store password, key alias and key password as further secrets. 2. In the job, decode it to a file, as the Flutter continuous-delivery docs show: `echo "$PLAY_STORE_UPLOAD_KEY" | base64 --decode > upload-keystore.jks`. 3. Give Gradle the values. Either write `android/key.properties` from the secrets, the file the Flutter signing setup reads, or have the release `signingConfig` read environment variables. Codemagic follows the second pattern: it exports `CI=true` plus `CM_KEYSTORE_PATH`, `CM_KEYSTORE_PASSWORD`, `CM_KEY_ALIAS` and `CM_KEY_PASSWORD` for an uploaded keystore. 4. Run `flutter build appbundle`. With Play App Signing, the key in CI is the **upload key**; Google holds the app signing key, so a leaked upload key can be reset without losing the app. ## iOS: a temporary keychain on macOS `flutter build ipa` needs macOS and Xcode, and Xcode signs with identities from a **keychain**. The Flutter iOS deployment guide walks through Codemagic's open-source CLI tools for exactly this: 1. Create an App Store Connect API key and pass its issuer ID, key ID and private key as secrets. 2. `keychain initialize` creates a temporary keychain isolated from the login keychain. 3. `app-store-connect fetch-signing-files $(xcode-project detect-bundle-id) --platform IOS --type IOS_APP_STORE --certificate-key=@file:cert_key --create` fetches, or creates, the distribution certificate and App Store profile. 4. `keychain add-certificates` imports the certificate; `xcode-project use-profiles` points the Xcode project at the profiles and writes an export options plist. 5. `flutter build ipa --export-options-plist=$HOME/export_options.plist` builds and exports. 6. `keychain use-login` restores the login keychain as the default. The alternative without those tools is the same shape: store the `.p12` and its password plus the `.mobileprovision` files as secrets, import them into a temporary keychain, and select manual signing. Syncing one shared certificate across a team is a separate tool's job. ## Comparing the two platforms | | Android | iOS | |---|---|---| | Secret material | upload keystore + passwords | `.p12` certificate + profiles, or an API key | | Where it lands | a file plus `key.properties` or env vars | a temporary keychain + installed profiles | | Runner | Linux or macOS | macOS only for the archive | | Recovery if leaked | reset the upload key with Play App Signing | revoke the certificate, issue a new one | ## Hygiene that interviewers probe - **Pull requests from forks do not receive secrets**, so signing jobs run only on trusted branches. - **Keep signing jobs separate** from jobs that run untrusted code, such as dependency updates, so a compromised build script cannot read the keys. - **Do not pass signing secrets through `--dart-define`**; anything compiled into the app is readable by anyone with the binary. - **Delete materialised files** at the end of self-hosted jobs, since those machines are not wiped. ## Rotating and recovering Signing material expires or leaks, and the pipeline should make replacing it routine: - **Apple distribution certificates expire** after a fixed period. With the API-key flow, `fetch-signing-files --create` can issue a new one from the stored private key; with a stored `.p12`, someone must export a new one and update the secret. - **Provisioning profiles** are regenerated whenever the certificate or a capability changes; a fetched profile keeps CI in step automatically, a stored one does not. - **A leaked Android upload key** is reset through Play App Signing: generate a new upload key, register it, and replace the CI secret. The app signing key held by Google is unaffected. - **Record where each secret lives** and who can read it, so rotation is a checklist rather than an investigation. ## Common mistakes - Committing `key.properties` or the `.jks` to a private repository and assuming privacy is protection. - Importing certificates into the login keychain of a shared build Mac, which then prompts or leaks across jobs. - Signing iOS on CI with a development certificate and wondering why the App Store export fails.
- Why does the iOS job restore the login keychain at the end?`keychain initialize` makes the temporary keychain the default. On a developer Mac or a reused runner, leaving it as default causes authentication problems for other apps and jobs, so the guide says to run `keychain use-login` afterwards.
- Can a pull request from a fork build a signed release of the car-wash app?It should not, and CI systems withhold secrets from fork pull requests for this reason. Run unsigned checks on pull requests and sign only on trusted branches after merge.
saying these in an interview costs you the question
- Committing the keystore is fine if the repository is private.
- Signing secrets can be passed to the app with --dart-define.
- The iOS certificate should be imported into the runner's login keychain.
- A leaked Android upload key means the app can never be updated again.
- Both platforms can be signed and built on a Linux runner.