I was one artifact away from a Google Play launch. A signed .aab, that was it. Then eas build -p android returned the line every free-tier Expo dev eventually meets:
You have used your free tier of builds. Your billing cycle resets on the 1st.
The 1st was eleven days away. I tried eas build --local and my laptop filled its disk halfway through the all-ABI native compile. I was not going to wait eleven days, and I was not going to pay $99/month for a single build.
So I built the AAB on a free GitHub Actions runner. Clean machine, isolated, zero cost, about 29 minutes. Here is the whole thing, including the one detail that wastes everyone's afternoon: the signing key.
Why GitHub Actions and not local
EAS is a convenience layer over gradlew. Nothing about a release bundle requires Expo's servers. With Continuous Native Generation, your android/ folder is disposable. You run expo prebuild to regenerate it, then gradlew bundleRelease, the same as any bare React Native app.
A GitHub free runner gives you a 2-core, 7GB Ubuntu box with the Android SDK already installed. That is enough for a release bundle if you trim two things: the ABIs you compile, and the junk preinstalled on the runner.
The build config that matters
Two deliberate choices keep the build light and safe.
First, ABIs. Compiling x86/x86_64 only feeds emulators. Drop them.
# android/gradle.properties
reactNativeArchitectures=arm64-v8a,armeabi-v7a
That covers every real phone and roughly halves native-compile time and disk use.
Second, a judgment call: I keep minification OFF for the first production bundle.
android.enableMinifyInReleaseBuilds=false
android.enableShrinkResourcesInReleaseBuilds=false
My reason is parity, not fear of R8. The bundle I upload should behave exactly like the APK I already device-verified, so I am not chasing a minification difference during a launch. R8 strips code it believes is dead, and reflection-heavy code without proper -keep rules can lose classes you actually need at runtime. A blank paywall is the classic symptom, and I hit a version of it once already in a separate launch saga. Most paid SDKs ship their own consumer keep-rules, so a default minified build is usually fine. I still prefer to turn R8 on in a later release, deliberately, with the rules written and tested, rather than on the build I am shipping under launch pressure.
Free the disk before you build
The runner ships with .NET, Haskell, and a pile of Docker images you do not need. Reclaim that space first, or the bundle task dies at 95%.
- name: Free up runner disk space
uses: jlumbroso/free-disk-space@main
with:
android: false # we NEED the Android SDK
dotnet: true
haskell: true
large-packages: true
docker-images: true
The signing gotcha (read this part twice)
This is where most attempts brick. If your app already exists on a Play track, Google's Play App Signing has locked onto one specific upload key. Google re-signs your bundle with the real app key on their side, but it only accepts an upload signed by that exact upload certificate. Sign with a fresh throwaway keystore and Play rejects the upload with a fingerprint mismatch.
So you need the same upload key EAS used for your first build. EAS stores it for you. Pull it down once with eas credentials, then base64-encode it into a GitHub secret:
base64 -w0 upload-keystore.jks | gh secret set MY_UPLOAD_KEYSTORE_BASE64
# the three below prompt for the value interactively, paste each when asked
gh secret set MY_UPLOAD_KEYSTORE_PASSWORD
gh secret set MY_UPLOAD_KEY_ALIAS
gh secret set MY_UPLOAD_KEY_PASSWORD
Keep this keystore far away from any throwaway CI cert you use for sideload debug builds. They are not interchangeable. One is for Play, one is for adb install, and mixing them up is the second-most-common way this goes wrong.
In the workflow, decode it at runtime and hand it to Gradle through injected signing properties:
- name: Decode upload keystore
run: echo "${{ secrets.MY_UPLOAD_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/upload.keystore"
- name: Build signed release AAB
working-directory: android
env:
KS_PASS: ${{ secrets.MY_UPLOAD_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.MY_UPLOAD_KEY_ALIAS }}
KEY_PASS: ${{ secrets.MY_UPLOAD_KEY_PASSWORD }}
run: |
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file="$RUNNER_TEMP/upload.keystore" \
-Pandroid.injected.signing.store.password="$KS_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS" \
--no-daemon
--no-daemon matters on a single-shot runner. The Gradle daemon is built for long-lived dev machines, not a container that gets destroyed in 30 minutes.
The whole workflow, in order
Because android/ is generated and gitignored, you patch it inside the job, not in your repo. gradlew bundleRelease writes to android/app/build/outputs/bundle/release/, so the upload step has to point there (or copy out of it first). That path-matching is the line most likely to break a first run, so here is the complete, ordered file rather than fragments to stitch.
# .github/workflows/build-aab-play.yml
name: Build Play AAB (production)
on:
workflow_dispatch:
inputs:
ref: { description: 'Branch/ref to build', required: false, default: 'main' }
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Free up runner disk space
uses: jlumbroso/free-disk-space@main
with: { android: false, dotnet: true, haskell: true, large-packages: true, docker-images: true }
- uses: actions/checkout@v4
with: { ref: ${{ github.event.inputs.ref || 'main' }} }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- run: npm ci
- run: npx expo prebuild --platform android --no-install
- name: Patch the generated android/ (light, verified-parity)
run: |
set -euo pipefail
GP=android/gradle.properties
echo 'reactNativeArchitectures=arm64-v8a,armeabi-v7a' >> "$GP"
echo 'android.enableMinifyInReleaseBuilds=false' >> "$GP"
echo 'android.enableShrinkResourcesInReleaseBuilds=false' >> "$GP"
- name: Decode upload keystore
run: echo "${{ secrets.MY_UPLOAD_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/upload.keystore"
- name: Build signed release AAB
working-directory: android
env:
KS_PASS: ${{ secrets.MY_UPLOAD_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.MY_UPLOAD_KEY_ALIAS }}
KEY_PASS: ${{ secrets.MY_UPLOAD_KEY_PASSWORD }}
run: |
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file="$RUNNER_TEMP/upload.keystore" \
-Pandroid.injected.signing.store.password="$KS_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS" \
--no-daemon
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: android/app/build/outputs/bundle/release/*.aab
if-no-files-found: error
A couple of things earn their place here. set -euo pipefail makes the patch step fail instead of half-applying. if-no-files-found: error turns a build that produced no bundle into a red run instead of a green one with an empty artifact. And the path glob points straight at Gradle's real output, so there is nothing to reconcile by hand.
The one simplification worth naming: appending the three lines with >> assumes they are not already set in your gradle.properties. If they are, switch to a sed replace so you do not end up with two conflicting values. Check your file once and pick the right tool.
Trigger it and pull the bundle
gh workflow run build-aab-play.yml --ref main
# grab the run id once it finishes
gh run list --workflow build-aab-play.yml
gh run download <run-id>
You now have a signed .aab on your laptop, built on hardware that cost you nothing. Validate it before you upload. If you drive the Play Developer API or a CLI wrapper over it, run a bundle validation, push it to your internal track, then smoke-test the universal APK on a real device with bundletool before you promote to production.
The takeaway
Managed build services sell convenience, and the free tier is genuinely good until the month you ship twice. When the quota wall hits at the worst possible moment, remember that the build itself is just Gradle. A free CI runner can do it, and the only real trap is signing: use the upload key Play already locked onto, base64 it into a secret, and inject it at build time.
I keep this workflow permanent now. Every release after the first is one gh workflow run and one gh run download, with zero laptop impact and no quota to watch. If you are mid-launch and staring at a reset date, you do not have to wait.
I write up the unglamorous parts of shipping small apps solo, the bugs and the launch fights, as I hit them. Follow along if that is your kind of thing.
Top comments (0)