skip to content

Hosted CI Builds

Building both platforms in CI means macOS runners for iOS, cached Pods and Gradle, and signing secrets injected at build time. Interviewers ask how you keep builds reproducible and keys safe.

part ofReact Nativeoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a React Native CI pipeline, how do you get the Android upload keystore and iOS signing assets into the build without committing them?

level: middleimportance: must knowfreq 55%

answer

  1. binary files become base64 secrets
  2. decode to a temp file at job start
  3. MYAPP_UPLOAD_* as Gradle project properties
  4. template release signs with debug key
  5. delete the files on exit, trap

basics

~10 s

Store the keystore, certificate and profile as base64 CI secrets, decode them into temporary files at build time, pass the passwords as Gradle properties or environment variables, and delete everything when the job ends.

solid answer

~50 s

Signing files are binary, so they go into the CI secret store base64-encoded; the job decodes them into a temporary directory outside the repository. On Android, the release `signingConfig` reads `MYAPP_UPLOAD_STORE_FILE`, `MYAPP_UPLOAD_KEY_ALIAS`, `MYAPP_UPLOAD_STORE_PASSWORD` and `MYAPP_UPLOAD_KEY_PASSWORD` as Gradle project properties, and CI supplies them as `ORG_GRADLE_PROJECT_*` environment variables instead of writing `android/gradle.properties`. On iOS, the distribution certificate (`.p12` plus its password) and the provisioning profile are decoded, the certificate is imported into a temporary keychain and the profile installed before `xcodebuild` archives. Fail fast if a secret is missing: the React Native template signs `release` with the debug keystore, and the documented `hasProperty` guard skips the upload key silently, so a mis-wired job can still produce a binary the store will not accept. Clean up in an exit trap so a failed build does not leave keys behind.

code

bash · 14 lines
bash
set -euo pipefail
: "${ANDROID_KEYSTORE_B64:?missing keystore secret}"
: "${ANDROID_STORE_PASSWORD:?missing store password}"

KEY_DIR="$(mktemp -d)"
trap 'rm -rf "$KEY_DIR"' EXIT
echo "$ANDROID_KEYSTORE_B64" | base64 --decode > "$KEY_DIR/upload.keystore"

export ORG_GRADLE_PROJECT_MYAPP_UPLOAD_STORE_FILE="$KEY_DIR/upload.keystore"
export ORG_GRADLE_PROJECT_MYAPP_UPLOAD_KEY_ALIAS="$ANDROID_KEY_ALIAS"
export ORG_GRADLE_PROJECT_MYAPP_UPLOAD_STORE_PASSWORD="$ANDROID_STORE_PASSWORD"
export ORG_GRADLE_PROJECT_MYAPP_UPLOAD_KEY_PASSWORD="$ANDROID_KEY_PASSWORD"

cd android && ./gradlew bundleRelease

go deeper

for a junior

Recall that signing files are never committed: they live as encrypted CI secrets, are decoded during the job and removed afterwards.

for a middle

Explain the hand-over: base64 secrets decoded to temp files, MYAPP_UPLOAD_* supplied as Gradle project properties, and the certificate and profile installed before the iOS archive.

for a senior

Show the failure modes you guard against: the template's debug-signed release, the silent hasProperty guard, secrets exposed to untrusted builds, and keys left behind after a failed job.

for a principal

Decide who holds signing keys at all: raw secrets in CI, a certificate-sync repository, or a hosted builder's credential store, and what each means for rotation and leaks.

## What has to reach the runner A store build of a React Native app needs signing material for both platforms, and none of it belongs in git (the template's `.gitignore` already excludes `*.keystore` apart from `debug.keystore`). - **Android**: the **upload keystore** file, its store password, the key alias and the key password. - **iOS**: the **distribution certificate** exported as a `.p12` file with its password, and the **provisioning profile** that ties the app ID, the team and that certificate together. In a gym-booking app with two build jobs, each job should receive only its own platform's secrets. ## The general recipe 1. **Encode once, locally**: base64-encode each binary file so it fits in a text secret. 2. **Store** the encoded files and the passwords in the CI system's secret store, scoped to the branches or environments that produce releases. 3. **Decode at job start** into a directory created with `mktemp -d`, never inside the checked-out repository, so nothing can be committed or cached by accident. 4. **Hand the values to the build tool** through environment variables or build properties, not by editing tracked files. 5. **Clean up in an exit trap**, so the files are removed on failure as well as on success. ## Android: feeding the signing config The React Native signing guide wires `signingConfigs.release` to four Gradle properties, `MYAPP_UPLOAD_STORE_FILE`, `MYAPP_UPLOAD_KEY_ALIAS`, `MYAPP_UPLOAD_STORE_PASSWORD` and `MYAPP_UPLOAD_KEY_PASSWORD`, and suggests keeping them in `~/.gradle/gradle.properties` on a developer machine. On CI there is no need for a file at all: Gradle accepts project properties as `-P` arguments or as environment variables prefixed `ORG_GRADLE_PROJECT_`. The environment form keeps passwords out of the process command line. ```groovy signingConfigs { release { if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) { storeFile file(MYAPP_UPLOAD_STORE_FILE) storePassword MYAPP_UPLOAD_STORE_PASSWORD keyAlias MYAPP_UPLOAD_KEY_ALIAS keyPassword MYAPP_UPLOAD_KEY_PASSWORD } } } ``` Two details make this a CI trap rather than a formality: - The **template's `release` build type uses `signingConfigs.debug`**. If the project was never switched to `signingConfigs.release`, CI produces a debug-signed AAB. - The **`hasProperty` guard** means a missing property does not fail configuration; the release config simply gets no upload key. Check the secrets explicitly at the start of the job (`${VAR:?}` in bash) so a mis-wired pipeline fails loudly instead of producing an artifact the store refuses. ## iOS: certificate, profile, keychain The iOS job decodes the `.p12` and the `.mobileprovision`, imports the certificate into a **temporary keychain** that the job creates, unlocks and deletes, and installs the profile where Xcode looks for profiles. `xcodebuild` then archives the Release configuration with manual signing set to that profile. Teams with more than one app or developer usually let a certificate-sync tool such as fastlane match manage this step instead of storing raw files as secrets; the keychain mechanics belong to that tool. | | Android | iOS | |---|---|---| | File secrets | upload keystore | `.p12` certificate, provisioning profile | | Text secrets | store password, alias, key password | `.p12` password, keychain password | | Handed over as | Gradle project properties | imported keychain + installed profile | | Silent failure | debug-signed or unsigned release | archive fails at code signing | ## Hardening the secrets themselves - **Scope** release secrets to protected branches or a release environment so untrusted pull-request builds never see them. - **Never echo** a secret or run the shell with tracing enabled around the decode step. - **Keep the upload key replaceable**: with Play App Signing, a leaked upload key can be reset, while the app signing key stays with Google. - **Do not cache** directories that contain decoded keys or signed outputs. - **Rotate on exposure**: if a secret may have appeared in a log or a cached directory, treat it as leaked; reset the upload key through the store and revoke and reissue the iOS certificate. ## Checking the result before upload A pipeline that signs correctly today can regress silently when someone edits the Gradle file or the Xcode signing settings. Two cheap checks catch that before the store does: - On Android, verify the AAB's signing certificate fingerprint against the known upload-key fingerprint as a build step, and fail on a mismatch or a debug certificate. - On iOS, check that the exported archive's embedded provisioning profile is the distribution profile for the app ID, not a development profile. Both checks compare against values that are not secret (fingerprints and profile names), so they can live in the repository beside the pipeline.

  • Why pass the Android passwords as ORG_GRADLE_PROJECT_ environment variables rather than -P arguments?
    Both become Gradle project properties, but `-P` values sit on the process command line, where build logs and process listings can show them. Environment variables are not printed by default and most CI systems mask secret values in logs.
  • The CI-built AAB of a React Native app is refused because it is signed with a debug certificate; what went wrong?
    The release build type still points at `signingConfigs.debug`, as the template ships it, or the release config's `hasProperty` guard found no `MYAPP_UPLOAD_STORE_FILE` and skipped the upload key. Point `release` at `signingConfigs.release` and make the job fail when the keystore secrets are missing.
  • Why decode the keystore into a temporary directory rather than android/app?
    Anything inside the checkout can be swept into a cache, an uploaded artifact or an accidental commit. A `mktemp -d` directory outside the repository, removed by an exit trap, never reaches any of those.

saying these in an interview costs you the question

  • Commit the keystore; the repository is private anyway
  • The React Native template already signs release builds with a store-ready key
  • Write the passwords into android/gradle.properties during the CI job
  • A missing MYAPP_UPLOAD_STORE_FILE property makes the Gradle build fail immediately
  • Echo the decoded secret to check it arrived correctly
open as a page

Why does a React Native app's iOS release build need a macOS CI runner, and what must that runner have installed?

level: juniorimportance: should knowfreq 42%

basics

~20 s

The iOS app is compiled, linked, signed and archived by Xcode's tools, which run only on macOS. The runner needs Xcode (16.1 or newer for React Native 0.87), Ruby with CocoaPods, and Node for the JavaScript bundle step.

open as a page

In CI for a React Native app, what should you cache for node_modules, CocoaPods and Gradle, and what should each cache key be?

level: middleimportance: should knowfreq 40%

basics

~20 s

Cache dependency downloads keyed on their lockfiles: the JavaScript packages on the package lockfile, ios/Pods on Podfile.lock, Ruby gems on Gemfile.lock, and Gradle's caches and wrapper on the Gradle build files. Still run pod install, and never cache signing material.

open as a page

Two branches of a React Native app both uploaded build 57 and the store rejected one; how should CI assign Android versionCode and the iOS build number instead?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Let one release pipeline own a single increasing counter and inject it at build time: a Gradle property for versionCode and the CURRENT_PROJECT_VERSION build setting for the iOS build number, instead of numbers edited by hand in the repository.

open as a page

For a React Native app, when should CI hand the release build to Fastlane lanes or to EAS Build, and what stays in the CI job?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Hand off to Fastlane when you own the native projects and runners and want scripted signing and upload on them; hand off to EAS Build when Expo should run the builds and hold credentials. CI still tests, gates and triggers.

open as a page