Shipping a React Native App to Both Stores Without EAS Build or GitHub-Hosted Runners
The actual CI setup behind a shipping RN app: Blacksmith runners, xcodebuild and xcrun altool for iOS, eas build --local plus r0adkll/upload-google-play for Android, Doppler in front of GitHub Secrets, no Fastlane, no keychain dance, and roughly $200 a month off the bill.
Two line items dominate the CI bill for a small React Native team: macOS build minutes on GitHub-hosted runners, and the monthly EAS Build subscription that papers over how unpleasant it is to assemble an iOS pipeline by hand. Both are easier to walk away from than the documentation makes them sound.
What follows is the actual setup behind a shipping React Native app I work on: release builds for both Android and iOS, on Blacksmith runners, without Fastlane, without EAS Build cloud capacity, and without anything that looks like a keychain dance. The snippets below are pulled from a real production workflow, with the app name and bundle identifier swapped for generic placeholders.
The cost shape, in real numbers
GitHub-hosted macOS minutes are the painful line item. As of the January 2026 pricing rework, the standard 3 or 4-core macOS runner is $0.062/min, the 12-core larger runner is $0.077/min, and the M2 Pro 5-core arm64 larger runner is $0.102/min. Linux 4-core is $0.012/min. For comparison, Blacksmith publishes $0.004/min base for Ubuntu x64 (so $0.008/min at 4 vCPU) and $0.08/min for the 6 vCPU Apple Silicon M4 macOS runners. Blacksmith macOS is not radically cheaper per minute, but the silicon is one generation newer and most RN iOS builds finish in roughly half the wall-clock time, which is where the savings come from.
On top of that, Expo's Production tier is $199/month for two concurrent builds and $225 of build credit. A real team running CI on every PR burns through that credit faster than the marketing page implies, and the seat cost is fixed whether you use it or not. For a three-person team building maybe 100 release candidates a month across both platforms, EAS Build plus GitHub macOS minutes used to land somewhere between $300 and $450 a month. The setup below ran ~$70 last month at similar volume. Call the delta a conservative $200 a month, or about $2,400 a year.
Blacksmith as a one-line drop-in
The migration from runs-on: ubuntu-latest to Blacksmith is exactly the change the docs advertise. From the production Android job in this repo:
jobs:
android-production:
name: Android Production (AAB)
runs-on: blacksmith-4vcpu-ubuntu-2404
environment: productionAnd the iOS job:
ios-production:
name: iOS Production (App Store)
runs-on: blacksmith-6vcpu-macos-26A couple of small things are worth flagging. The cache backend is co-located with the runner, so actions/cache@v4 is materially faster (the docs claim roughly 4x throughput, which is consistent with what I saw moving Gradle and Pods caches off GitHub-hosted). The free tier is 3,000 x64 2-vCPU-equivalent minutes per month, but be careful: a 6 vCPU macOS minute consumes the equivalent of 20 x64 2-vCPU minutes against that allowance, so the free tier evaporates after about 20 minutes of cumulative macOS time. And if you set runs-on: blacksmith-* in a repo that the Blacksmith GitHub App is not installed on, queued jobs from that repo can adopt runners you provisioned for a different repo and you will spend an afternoon wondering why your release pipeline is stuck.
Replacing EAS Build, while still using EAS where it's free
Here is the part that took me longest to get comfortable with: you can keep the EAS CLI without paying for EAS Build. Running eas build --localexecutes the build on the current runner instead of submitting it to Expo's cloud workers, which means no build credit is consumed and no cloud queue time. You get the Expo plugin pipeline (autolinking, config plugins, prebuild) for free, because that work happens in the CLI, not in the cloud.
That is exactly what this Android job does. The relevant package.json scripts:
{
"scripts": {
"build:android:production": "eas build --platform android --profile=production --local",
"build:android:preview": "eas build --platform android --profile=preview --local"
}
}The eas.json profile that drives it is unremarkable. What matters is --local:
{
"build": {
"base": {
"node": "24.11.1",
"yarn": "4.5.0",
"corepack": true
},
"production": {
"extends": "base",
"environment": "production",
"distribution": "store"
}
}
}For iOS, there is no eas build --local wrapper in this repo at all. Just xcodebuild:
{
"scripts": {
"build:ios:production": "cd ios && mkdir -p archive && xcodebuild archive -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -archivePath ./archive -destination 'generic/platform=iOS'",
"export:ios:production": "xcodebuild -exportArchive -archivePath ios/archive.xcarchive -exportOptionsPlist export-options/ExportOptions-appstore.plist -exportPath ./ios/ipa"
}
}The export options plist is also boring on purpose:
<!-- export-options/ExportOptions-appstore.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key> <string>export</string>
<key>method</key> <string>app-store-connect</string>
<key>signingStyle</key> <string>automatic</string>
<key>stripSwiftSymbols</key><true/>
<key>teamID</key> <string>ABCDE12345</string>
<key>uploadSymbols</key> <true/>
</dict>
</plist>Code signing without match and without a keychain dance
This is the part I expected to be ugly and it turned out to be the cleanest piece of the whole pipeline. There is no security create-keychain, no security import of a .p12, no fastlane match repo. Instead, the workflow writes one file to disk: an App Store Connect API key.
# .github/actions/setup-ios-signing/action.yml
name: 'Setup iOS Signing'
description: 'Decode ASC_API_KEY_CONTENT_BASE64 and write AuthKey.p8'
outputs:
auth_key_path:
description: 'Path to the written AuthKey*.p8 file'
value: ${{ steps.authkey.outputs.auth_key_path }}
runs:
using: composite
steps:
- name: Write App Store Connect API key
id: authkey
shell: bash
run: |
AUTH_KEY_PATH=$RUNNER_TEMP/AuthKey_${ASC_KEY_ID}.p8
echo "$ASC_API_KEY_CONTENT_BASE64" | base64 --decode > "$AUTH_KEY_PATH"
chmod 600 "$AUTH_KEY_PATH"
echo "AUTH_KEY_PATH=$AUTH_KEY_PATH" >> $GITHUB_ENV
echo "auth_key_path=$AUTH_KEY_PATH" >> $GITHUB_OUTPUTThen the workflow runs xcodebuild with -allowProvisioningUpdates and points it at the API key:
- name: Archive (xcodebuild, unsigned)
run: |
yarn build:ios:production \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
CODE_SIGN_ENTITLEMENTS=""
- name: Export IPA (App Store)
run: |
yarn export:ios:production \
-allowProvisioningUpdates \
-authenticationKeyPath "$AUTH_KEY_PATH" \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID"The trick is doing the archive unsigned, then signing at export time. -allowProvisioningUpdates tells Xcode to use the ASC API key to fetch (or create) the right certificate and provisioning profile on the fly. There is nothing to pre-install into a keychain, nothing to rotate when a profile expires, no shared match git repo. The API key itself is the only secret you have to manage, and it lives in App Store Connect under Users and Access. Give it the App Manager role; Developer is not enough to create new profiles.
Upload to TestFlight uses the same key:
- name: Upload IPA to App Store Connect
run: |
mkdir -p ~/.appstoreconnect/private_keys
cp "$AUTH_KEY_PATH" ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8
xcrun altool --upload-app \
--type ios \
--file ".expo/build-cache/ios-${{ steps.fingerprint.outputs.hash }}-Release.ipa" \
--apiKey "$ASC_KEY_ID" \
--apiIssuer "$ASC_ISSUER_ID"altool requires the key to be in a specific path (~/.appstoreconnect/private_keys/AuthKey_KEYID.p8) which is why the step copies it. xcrun notarytool uses the same convention if you need notarization for a Mac target.
Android signing and upload
The Android side is even shorter. A composite action decodes the keystore and writes the matching keystore.properties:
# .github/actions/setup-android-signing/action.yml
runs:
using: composite
steps:
- name: Decode keystore
shell: bash
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/release.keystore
- name: Write keystore.properties
shell: bash
run: |
cat > android/keystore.properties <<EOF
storeFile=release.keystore
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOFThen eas build --local builds the AAB, and r0adkll/upload-google-play@v1 pushes it to the internal track:
- name: Build AAB
run: yarn build:android:production
env:
EXPO_TOKEN: ${{ env.EXPO_TOKEN }}
- name: Write Play Store service account JSON
run: echo "$PLAY_SERVICE_ACCOUNT_BASE64" | base64 --decode > /tmp/play-service-account.json
- name: Upload AAB to Google Play (internal track)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: /tmp/play-service-account.json
packageName: com.example.myapp
releaseFiles: .expo/build-cache/android-${{ steps.fingerprint.outputs.hash }}-release.aab
track: internal
status: completedPure ./gradlew bundleRelease would also work and is slightly faster on a cold runner, but using eas build --local here gives you the Expo prebuild pipeline (config plugins, autolinking) without paying for an EAS Build worker. The build runs on the Blacksmith Linux runner, with Gradle and Expo caches mounted across runs via actions/cache.
The secrets you actually need
For a setup like this you need a small, well-scoped pile of secrets, and that is it. Names mirror the repo:
# iOS
ASC_KEY_ID # 10-char key ID from App Store Connect
ASC_ISSUER_ID # UUID issuer ID
ASC_API_KEY_CONTENT_BASE64 # base64 of the AuthKey_*.p8 file
# Android
ANDROID_KEYSTORE_BASE64 # base64 of the release keystore (.jks)
ANDROID_KEYSTORE_PASSWORD
ANDROID_KEY_ALIAS
ANDROID_KEY_PASSWORD
PLAY_SERVICE_ACCOUNT_BASE64 # base64 of the Play API service account JSON
# Firebase config files (optional, app-specific)
GOOGLE_SERVICE_INFO_PLIST_BASE64
GOOGLE_SERVICES_JSON_BASE64
# EAS CLI auth (free, but needed for eas build --local + env pull)
EXPO_TOKENYou can stop here. GitHub Secrets (or environment-scoped secrets) holds all of the above, your composite actions read them from the environment, and you are done. But there is a tidier shape worth knowing about.
One API token instead of fifteen secrets
The list above has the property that it grows. Add Sentry, add an analytics key, add a feature-flag service, and you are managing twenty named secrets in GitHub, twenty more in your staging environment, and the rotation calendar is now somebody's unofficial second job. The escape hatch is to put a secret manager in front of GitHub Secrets, and have GitHub hold exactly one credential: the API token for the manager.
This repo uses Doppler. The full integration is a single composite action that installs the CLI, pulls the secrets for the current project + config, masks every value before it can hit logs, and writes them into $GITHUB_ENV:
# .github/actions/load-doppler-secrets/action.yml
name: 'Load Doppler Secrets'
description: 'Install Doppler CLI, mask all secret values, then export to GITHUB_ENV'
runs:
using: composite
steps:
- name: Install Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Export Doppler secrets to environment (masked)
shell: bash
run: |
doppler secrets download --no-file --format env-no-quotes > /tmp/doppler_secrets.env
while IFS= read -r line; do
[[ -z "$line" || "$line" == \#* ]] && continue
value="${line#*=}"
echo "::add-mask::$value"
done < /tmp/doppler_secrets.env
cat /tmp/doppler_secrets.env >> $GITHUB_ENV
rm /tmp/doppler_secrets.envGitHub Secrets then holds two things, one per environment:
DOPPLER_TOKEN_PRODUCTION
DOPPLER_TOKEN_STAGINGAnd the workflow wires the right one in per job:
jobs:
android-production:
runs-on: blacksmith-4vcpu-ubuntu-2404
environment: production
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN_PRODUCTION }}
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/load-doppler-secrets
# everything below this line can read ASC_KEY_ID, PLAY_SERVICE_ACCOUNT_BASE64,
# GOOGLE_SERVICE_INFO_PLIST_BASE64, etc. straight from the environmentThe payoff is not the build pipeline; it is the rest of the lifecycle. Rotating the Play Console service account becomes a one-place change. Adding a new environment-specific value (say, a Sentry DSN) is editing one Doppler config rather than a sprawl of GitHub Secrets pages across three environments. Local dev pulls the same values via doppler run -- yarn start, so the things CI uses are literally the things you used yesterday on your laptop, which is the bug class that this pattern eliminates entirely.
Doppler is not the only option here, and the integration is shaped the same regardless. 1Password Secrets Automation, Infisical, HashiCorp Vault, AWS Secrets Manager, and Google Secret Manager all support the same pattern: GitHub holds one short-lived (or scoped) token, a composite action pulls and masks values at job start, and the rest of the workflow reads from $GITHUB_ENV as if you had set everything in Secrets directly.
One thing not to skip: the ::add-mask:: step. Every value that lands in $GITHUB_ENV needs to be explicitly masked before the line writing it is logged, or a stray set -x in a downstream step (or any tool that helpfully prints its environment) will print the secret in clear. The composite action above is doing that mask in a loop on purpose, in front of the cat >> $GITHUB_ENV line, not after it. The order matters.
The cache key that makes incremental builds work
One last piece worth lifting verbatim. RN release builds are slow mostly because of pod install, Gradle, and the JS bundle, all of which are deterministic functions of dependency state. EAS already knows how to compute a content hash of the native side of an RN project; you can hijack it as a cache key without using EAS Build:
- name: Calculate EAS fingerprint (iOS)
id: fingerprint
run: |
HASH=$(yarn eas fingerprint:generate --platform ios --json --non-interactive | jq -r '.hash')
echo "hash=$HASH" >> $GITHUB_OUTPUT
- name: Restore Expo build cache
id: build-cache
uses: actions/cache/restore@v4
with:
path: .expo/build-cache
key: expo-build-ios-production-${{ steps.fingerprint.outputs.hash }}Every expensive step in the job is gated on steps.build-cache.outputs.cache-hit != 'true', so if the native fingerprint did not change (a JS-only PR, say), the IPA from the last successful build is reused and the job collapses to roughly the time it takes to upload to TestFlight. On macOS at $0.08/min, that matters.
Gotchas worth saying out loud
A few things to know before you copy this layout into a repo. First, -allowProvisioningUpdates is doing a lot of heavy lifting. If the ASC API key role is too low, Xcode will silently fall back to looking for local certificates and the build will fail with an unhelpful signing error. App Manager is the minimum. Second, the App Store Connect API key can expire if your Apple Developer team rotates it, but it does not have a built-in expiry the way a provisioning profile does, so if signing breaks at 2 a.m. that is the second thing to check.
Third, you can keep EAS Submit even after walking away from EAS Build. Submit is free up to a point and is genuinely useful for handling metadata, screenshots, and the App Store Connect submit flow. The repo above uses neither (it goes straight to TestFlight with altool) but it is a reasonable middle ground if you want to drop EAS Build but keep a managed submit step.
Fourth, Apple's review is still the bottleneck. Cutting 10 minutes off your build pipeline does not change the fact that the first response from App Review is 24 to 48 hours away. The value of this stack is not faster shipping; it is fewer dollars and fewer moving parts.
Fifth, network egress on Blacksmith is free for the standard actions cache backend, but pulling large artifacts from third parties (CocoaPods specs, big NPM packages) still happens over the public internet. Cache aggressively. The Pods cache keyed on ios/Podfile.lock alone saves five minutes a build.
What I would reach for now
If I were starting this pipeline today: Blacksmith Linux 4 vCPU for Android, Blacksmith macOS 6 vCPU M4 for iOS, eas build --local for Android because it preserves the Expo plugin pipeline at zero cloud cost, raw xcodebuild for iOS because there is no equivalent value-add, ASC API key authentication with -allowProvisioningUpdates for signing, r0adkll/upload-google-play for Android upload and xcrun altool for TestFlight. Doppler or GitHub environment-scoped secrets for the credentials, EAS fingerprints as cache keys, no Fastlane anywhere.
The whole thing fits in two YAML files and four composite actions, costs under $100 a month at the volume I run, and the only piece of cloud build infrastructure I depend on is GitHub itself. That feels like the right place for the dependency to live.