| name | rn-harness-build-local |
| description | Phase 8 (EAS-free alternative) โ Build & deploy without EAS. For users whose EAS free-plan project slots are full. Local Gradle (Android AAB) + Xcode/fastlane (iOS IPA), self-hosted OTA, then submit via Google Play API + fastlane/Transporter. |
| allowed-tools | ["Read","Write","Edit","Bash","Glob","Grep","AskUserQuestion"] |
rn-harness-build-local โ Phase 8 (alt): EAS-free Build & Deploy
Build and ship without EAS. Use this instead of rn-harness-build + the EAS parts of rn-harness-submit when the user cannot use EAS โ most commonly because the EAS free plan is full (3-project limit reached) or they simply don't want the dependency.
When to use this instead of EAS
The orchestrator (or the user) picks the build method at the start of Phase 8:
| Situation | Path |
|---|
| EAS account has a free project slot, wants managed credentials/OTA | rn-harness-build (default) |
| EAS free plan full (3 projects) / declined EAS / wants full local control | this skill |
EAS free plan allows a limited number of projects per account. Once full, eas init on a new app fails. This path needs no EAS project at all.
Trigger
Called by the orchestrator as Phase 8 when build method = local.
Input
- Project code (AdMob integration complete)
docs/harness/config.md
Prerequisites
- Android: JDK 17, Android SDK (
ANDROID_HOME set), Gradle (bundled via wrapper)
- iOS: macOS + Xcode + CocoaPods, an Apple Developer account
- fastlane (
brew install fastlane or gem install fastlane) โ used for iOS signing + upload
- No EAS account required
Process
Step 1: Prebuild (generate native projects)
Expo managed projects have no android/ / ios/ folders until prebuilt. Generate them:
npx expo prebuild --clean
This creates android/ and ios/ with the correct bundle id, icons, splash, and plugin config. Re-run after any app.config.ts change.
OTA note: expo-updates in a non-EAS setup needs a self-hosted update endpoint (see Step 6). If OTA is not needed, remove expo-updates from plugins before prebuild to avoid a build-time updates.url requirement.
Step 2: Android Signing Keystore (ASK FIRST)
Same rule as the EAS path โ never silently generate a keystore. A local Gradle release build requires a keystore; there is no cloud fallback.
AskUserQuestion โ "์๋๋ก์ด๋ ์๋ช
keystore๋ฅผ ์ด๋ป๊ฒ ํ ๊น์?":
| ์ ํ์ง | ๋์ |
|---|
| ๊ธฐ์กด keystore ์ฌ์ฉ | ๋ณด์ ํ .jks ์ฌ์ฉ. ์ด๋ฏธ Play์ ์ถ์๋ ์ฑ์ด๋ฉด ํ์. |
| ์๋ก ์์ฑ (์ ๊ท ์ฑ) | keytool๋ก ์ keystore ์์ฑ (์๋). ์ฒ์ ์ถ์ํ๋ ์ฑ๋ง. |
| ์ ๋ชจ๋ฅด๊ฒ ์ | Play์ ์ฑ์ด ์ด๋ฏธ ์์ผ๋ฉด ๊ธฐ์กด ์ฌ์ฉ, ์์ ์ ๊ท๋ฉด ์๋ก ์์ฑ. |
์๋ก ์์ฑ ์:
keytool -genkeypair -v \
-keystore credentials/keystore.jks \
-alias upload -keyalg RSA -keysize 2048 -validity 10000
์์ฑ ์งํ ์์ ํ ๊ณณ์ ๋ฐฑ์
(์ด ํค๋ฅผ ์์ผ๋ฉด ์ฑ ์
๋ฐ์ดํธ ์๊ตฌ ๋ถ๊ฐ).
๊ณตํต โ Gradle์ keystore ์ฐ๊ฒฐ: create android/gradle.properties entries (or better, ~/.gradle/gradle.properties so secrets stay out of the repo):
RN_UPLOAD_STORE_FILE=keystore.jks
RN_UPLOAD_KEY_ALIAS=upload
RN_UPLOAD_STORE_PASSWORD=********
RN_UPLOAD_KEY_PASSWORD=********
Place keystore.jks at android/app/keystore.jks and wire android/app/build.gradle signingConfigs.release to read those properties. Verify credentials.json, *.jks, *.keystore are gitignored.
HARD GATE: app already on Play + new keystore = FAIL (Play rejects). Confirm before building.
Step 3: Android Release Build (Gradle โ AAB)
cd android
./gradlew bundleRelease
APK for sideload testing (optional):
./gradlew assembleRelease
Common failures & fixes:
| Error | Fix |
|---|
OutOfMemoryError / Metaspace | org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g in android/gradle.properties |
SDK location not found | set ANDROID_HOME, or create android/local.properties with sdk.dir=... |
Keystore file not found | keystore path in build.gradle is relative to android/app/ |
Duplicate class | ./gradlew clean then rebuild |
Step 4: iOS Release Build (fastlane gym โ IPA)
Requires macOS + Xcode + an Apple Developer account. Use fastlane for signing so we don't hand-manage provisioning profiles.
cd ios && pod install && cd ..
Create ios/fastlane/Fastfile (or use fastlane match for team signing):
default_platform(:ios)
platform :ios do
lane :beta do
build_app(
scheme: "<APP_SCHEME>",
export_method: "app-store",
workspace: "ios/<APP>.xcworkspace"
)
upload_to_testflight(
api_key_path: "credentials/asc-api-key.json"
)
end
end
cd ios && fastlane beta
Signing options:
- Automatic (recommended):
fastlane + ASC API key handles certs/profiles.
fastlane match: for teams sharing certs via a private git repo.
- Manual:
xcodebuild -workspace ... -scheme ... archive then -exportArchive with an exportOptions.plist. Only if fastlane is unavailable.
If no macOS is available, iOS cannot be built locally โ note this to the user and either use a Mac/CI runner or fall back to EAS for iOS only.
Step 5: Verify Build Artifacts
ls -lh android/app/build/outputs/bundle/release/app-release.aab
ls -lh ios/build/*.ipa 2>/dev/null || echo "iOS built via fastlane โ uploaded to TestFlight"
- AAB present โ Android ready
- IPA present / uploaded to TestFlight โ iOS ready
- Record paths in the build result (Output below)
Step 6: OTA Without EAS Update (optional)
EAS Update is a hosted service. Without it, expo-updates needs a self-hosted update server. Options, easiest first:
- Skip OTA โ remove
expo-updates; ship JS changes via normal store updates. Simplest; fine for low-frequency updates.
- Self-host the Expo Updates protocol โ
npx expo export produces the update bundle in dist/; serve it from any static host (S3/CloudFront, Cloudflare, a small Express server implementing the expo-updates protocol). Set updates.url in app.config.ts to that endpoint. Most control, most setup.
- Third-party OTA (e.g. self-hosted alternatives) โ drop-in servers that speak the expo-updates protocol.
npx expo export --platform all
Record which OTA option was chosen. If "skip", state clearly that JS fixes now require a store re-submission.
Step 7: Git Commit
git add android/ ios/ app.config.ts .gitignore
git commit -m "chore: local (EAS-free) build config for Android + iOS"
(Native folders android//ios/ are committed here because this is the bare-workflow build source. Never commit the keystore or credentials.json.)
Submit (EAS-free)
The rn-harness-submit skill already submits Android via the Google Play Developer API (scripts/publish.js) โ that path is already EAS-free, it just needs the local app-release.aab. Only the iOS binary upload differs.
Android โ reuse Google Play API path
Follow rn-harness-submit Part B unchanged. In the manual AAB-upload step, upload the local android/app/build/outputs/bundle/release/app-release.aab instead of an EAS build URL. scripts/publish.js handles metadata, images, and review submission via the API.
iOS โ upload without eas submit
Pick one (all use the ASC API key from rn-harness-submit A-1):
| Tool | Command | Notes |
|---|
| fastlane (recommended) | fastlane pilot upload (TestFlight) / fastlane deliver (App Store) | Same key as build step; scriptable |
| Transporter app | GUI drag-drop the .ipa | Manual, no CLI |
| altool | xcrun altool --upload-app -f app.ipa --apiKey <KEY_ID> --apiIssuer <ISSUER> | Built into Xcode CLI |
Then metadata + review submission via the App Store Connect API exactly as in rn-harness-submit A-3 (that part never used EAS).
Output
docs/harness/handoff/build-result.md:
# Build Result (EAS-free / local)
## Build Method
- EAS: NOT USED (reason: [free plan full / user choice])
## Android
- Method: local Gradle (`./gradlew bundleRelease`)
- Status: [SUCCESS/FAILED]
- Artifact: android/app/build/outputs/bundle/release/app-release.aab
- Keystore: [existing / newly generated โ BACKED UP?]
## iOS
- Method: fastlane gym (Xcode)
- Status: [SUCCESS/FAILED/SKIPPED โ no macOS]
- Artifact: [.ipa path / uploaded to TestFlight]
## OTA
- Strategy: [skipped / self-hosted expo-updates / third-party]
- updates.url: [endpoint or N/A]
## Build Config
- bundleIdentifier / package: com.xxx.xxx
- version: 1.0.0
- buildNumber / versionCode: 1
State Update
current_phase: screenshot
next_role: rn-harness-screenshot
HARD GATES
- Android keystore choice confirmed with user BEFORE build (never silently generate for an app already on Play)
- Keystore backup confirmed if newly generated (loss = permanent inability to update)
credentials.json, *.jks, *.keystore gitignored โ secrets never committed
- Android AAB must exist and be signed with the correct (upload) key
- iOS: if no macOS, explicitly report iOS cannot be built locally (don't silently skip)
- OTA strategy recorded; if "skip", user told JS fixes need store re-submission
- Build failure blocks next Phase