fix(mobile): embed the real OTA runtime version in Android builds

EAS generates android/ with `expo prebuild`, which sets NODE_ENV to
"development" when it is unset. The app config picked the 0.0.0-dev
runtime from NODE_ENV, so every Android binary since the React Native
0.79 upgrade embedded expo_runtime_version 0.0.0-dev, and ota.folo.is
rejects its update checks. iOS kept the real runtime because eas-cli
syncs its committed Expo.plist outside of prebuild.

Decide the development runtime from the EAS build profile and fall back
to NODE_ENV only without one (expo start, local prebuild). The
eas-build-post-install hook now fails any build whose native runtime
differs from the expected one, and a test evaluates every eas.json
profile the way prebuild does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-10-16 16:17:53 +08:00
parent cffb8f0113
commit 28ab52296e
9 changed files with 308 additions and 6 deletions
+1
View File
@@ -105,6 +105,7 @@ For `ota`, derive the runtime from the store binaries users have installed, not
2. Cross-check `apps/mobile/app.config.base.ts`: the runtime defaults to the binary's package version unless `OTA_RUNTIME_VERSION` is set during an OTA export.
3. Use the current App Store and Google Play binary version as `runtimeVersion`. If the stores show `0.5.0`, an OTA for `0.5.4` uses `"runtimeVersion": "0.5.0"`.
4. If iOS and Android differ, or the installed runtime is unclear, stop and ask. The plan supports one runtime; never guess or silently pick the newest.
5. Android binaries up to and including 0.6.0 embed the runtime `0.0.0-dev` and never take an OTA. While Google Play still serves one of them, an OTA reaches iOS only; say so in the plan.
Never choose the previous OTA release version just because it is the latest working manifest; a runtime mismatch publishes assets only newer binaries can see and leaves store users stuck.
@@ -14,10 +14,11 @@ BT=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)
gh run download <build-android run> -n app-android -D "$SCRATCH/pr/android"
APK="$SCRATCH/pr/android/build.apk"
"$BT/aapt2" dump badging "$APK" | grep -E "^package:|^application-label:"
"$BT/aapt2" dump resources "$APK" | grep -A1 "string/expo_runtime_version"
"$BT/apksigner" verify --print-certs "$APK" | grep -E "SHA-256"
```
Expected: package `is.follow`, `versionName` the new version, label "Folo", and certificate SHA-256 `4c2c3438d76d61ee5b18e8cd841776284e3d25574fa2ea68af809154aa496e08`. That is the EAS key; preview, `production-apk` and GitHub release APKs all carry it, so they update each other in place and keep app data. A Google Play install is re-signed by Google and cannot be updated with these APKs.
Expected: package `is.follow`, `versionName` the new version, label "Folo", OTA runtime `expo_runtime_version` equal to the new version (the `eas-build-post-install` hook fails builds that embed anything else; APKs up to 0.6.0 embed `0.0.0-dev`), and certificate SHA-256 `4c2c3438d76d61ee5b18e8cd841776284e3d25574fa2ea68af809154aa496e08`. That is the EAS key; preview, `production-apk` and GitHub release APKs all carry it, so they update each other in place and keep app data. A Google Play install is re-signed by Google and cannot be updated with these APKs.
The preview build talks to the production API, uses the `preview` OTA channel and shows the staging icon.
@@ -84,6 +85,8 @@ adb -s "$SERIAL" shell monkey -p is.follow -c android.intent.category.LAUNCHER 1
The app checks for an update in the background after launch and applies a downloaded update on the next launch. Wait about a minute, then `adb -s "$SERIAL" shell am force-stop is.follow`, launch again, and confirm Settings → About shows `OTA <new version>` and the changed screens behave. The release APK uses the `production` channel; for a `preview` OTA, use a preview APK instead.
Android binaries up to and including 0.6.0 embed the runtime `0.0.0-dev` (About shows `OTA 0.0.0-dev`), which ota.folo.is rejects, so they never take an OTA and this check cannot pass with them.
## 5. Clean up
- Temporary AVD: `adb -s "$SERIAL" emu kill`, then `rm -rf "$AVD/$NAME.avd" "$AVD/$NAME.ini"`.
@@ -17,6 +17,17 @@ describe("release build workflow guards", () => {
expect(iosWorkflow).toContain("release(desktop):")
})
// Android builds once shipped the 0.0.0-dev runtime unnoticed, so every EAS build checks it.
it("verifies the native OTA runtime version in every EAS mobile build", () => {
const mobilePackage = JSON.parse(
readFileSync(new URL("../../apps/mobile/package.json", import.meta.url), "utf8"),
) as { scripts: Record<string, string> }
expect(mobilePackage.scripts["eas-build-post-install"]).toContain(
"scripts/verify-native-runtime-version.ts",
)
})
it("skips direct desktop push builds for mobile release commits on mobile-main", () => {
const desktopWorkflow = workflow("build-desktop.yml")
+15 -2
View File
@@ -15,8 +15,8 @@ const iconPath = iconPathMap[process.env.PROFILE || "production"] || iconPathMap
const adaptiveIconPath = resolve(__dirname, "./assets/adaptive-icon.png")
const splashIconPath = resolve(__dirname, "./assets/splash-icon.png")
const isDev = process.env.NODE_ENV === "development"
const semverPattern = /^\d+\.\d+\.\d+$/
const developmentProfiles = new Set(["development", "ios-simulator"])
const channelNameMap = {
development: "development",
"ios-simulator": "development",
@@ -26,6 +26,16 @@ const channelNameMap = {
production: "production",
} as Record<string, string>
// Every EAS build profile sets PROFILE. NODE_ENV can't tell builds apart: `expo prebuild`, which
// generates `android/` in every EAS Android build, sets it to "development" when it is unset.
export const isDevelopmentRuntime = ({
profile,
nodeEnv,
}: {
profile: string | undefined
nodeEnv: string | undefined
}) => (profile ? developmentProfiles.has(profile) : nodeEnv === "development")
export const resolveRuntimeVersion = ({
isDevelopment,
packageVersion,
@@ -56,7 +66,10 @@ export default ({ config }: ConfigContext): ExpoConfig => {
const profile = process.env.PROFILE || "production"
const channelName = channelNameMap[profile] || channelNameMap.production
const runtimeVersion = resolveRuntimeVersion({
isDevelopment: isDev,
isDevelopment: isDevelopmentRuntime({
profile: process.env.PROFILE,
nodeEnv: process.env.NODE_ENV,
}),
packageVersion: PKG.version,
otaRuntimeVersionOverride: process.env.OTA_RUNTIME_VERSION ?? null,
})
+1 -1
View File
@@ -13,7 +13,7 @@
"e2e:doctor": "sh -c 'for f in e2e/flows/shared/*.yaml e2e/flows/android/*.yaml e2e/flows/ios/*.yaml; do maestro check-syntax $f; done'",
"e2e:ios": "sh ./e2e/run-maestro.sh ios",
"e2e:ios:bootstrap": "sh ./e2e/run-maestro.sh ios bootstrap-auth",
"eas-build-post-install": "rm -rf $TMPDIR/metro-cache",
"eas-build-post-install": "rm -rf $TMPDIR/metro-cache && tsx scripts/verify-native-runtime-version.ts",
"eas-build-pre-install": "command -v pod >/dev/null 2>&1 && pod repo update || echo 'CocoaPods not found, skipping pod repo update'",
"ios": "expo run:ios",
"ios:device": "expo run:ios --device",
+69 -2
View File
@@ -1,6 +1,73 @@
import { describe, expect, it } from "vitest"
import { readFileSync } from "node:fs"
import createExpoConfig, { resolveRuntimeVersion } from "../app.config.base"
import { afterEach, describe, expect, it, vi } from "vitest"
import createExpoConfig, { isDevelopmentRuntime, resolveRuntimeVersion } from "../app.config.base"
import PKG from "../package.json"
interface EasBuildProfile {
extends?: string
developmentClient?: boolean
env?: Record<string, string>
}
const easJson = JSON.parse(readFileSync(new URL("../eas.json", import.meta.url), "utf8")) as {
build: Record<string, EasBuildProfile>
}
const resolveEasBuildProfile = (name: string): EasBuildProfile => {
const profile = easJson.build[name]
if (!profile) {
throw new Error(`Unknown EAS build profile ${name}`)
}
const base = profile.extends ? resolveEasBuildProfile(profile.extends) : {}
return { ...base, ...profile, env: { ...base.env, ...profile.env } }
}
describe("isDevelopmentRuntime", () => {
it("follows the EAS build profile even when expo prebuild sets NODE_ENV", () => {
expect(isDevelopmentRuntime({ profile: "preview", nodeEnv: "development" })).toBe(false)
expect(isDevelopmentRuntime({ profile: "production", nodeEnv: "development" })).toBe(false)
expect(isDevelopmentRuntime({ profile: "development", nodeEnv: "production" })).toBe(true)
expect(isDevelopmentRuntime({ profile: "ios-simulator", nodeEnv: undefined })).toBe(true)
})
it("falls back to NODE_ENV without a build profile", () => {
expect(isDevelopmentRuntime({ profile: undefined, nodeEnv: "development" })).toBe(true)
expect(isDevelopmentRuntime({ profile: undefined, nodeEnv: "production" })).toBe(false)
})
})
describe("runtime version per EAS build profile", () => {
afterEach(() => {
vi.unstubAllEnvs()
})
// EAS generates `android/` with `expo prebuild`, which sets NODE_ENV=development.
it.each(Object.keys(easJson.build))("%s embeds the expected runtime version", (name) => {
const profile = resolveEasBuildProfile(name)
expect(profile.env?.PROFILE).toBeTruthy()
vi.stubEnv("NODE_ENV", "development")
vi.stubEnv("OTA_RUNTIME_VERSION", undefined)
for (const [key, value] of Object.entries(profile.env ?? {})) {
vi.stubEnv(key, value)
}
const config = createExpoConfig({ config: {} } as Parameters<typeof createExpoConfig>[0])
expect(config.runtimeVersion).toBe(profile.developmentClient ? "0.0.0-dev" : PKG.version)
})
it("keeps OTA_RUNTIME_VERSION for OTA exports", () => {
vi.stubEnv("NODE_ENV", "production")
vi.stubEnv("PROFILE", undefined)
vi.stubEnv("OTA_RUNTIME_VERSION", "0.5.11")
const config = createExpoConfig({ config: {} } as Parameters<typeof createExpoConfig>[0])
expect(config.runtimeVersion).toBe("0.5.11")
})
})
describe("resolveRuntimeVersion", () => {
it("keeps the development runtime version stable", () => {
@@ -0,0 +1,46 @@
import { readFile } from "node:fs/promises"
import { join } from "pathe"
import { isDevelopmentRuntime, resolveRuntimeVersion } from "../app.config.base"
const nativeRuntimeVersionSources = {
android: {
path: "android/app/src/main/res/values/strings.xml",
pattern: /<string name="expo_runtime_version">([^<]*)<\/string>/,
},
ios: {
path: "ios/Folo/Supporting/Expo.plist",
pattern: /<key>EXUpdatesRuntimeVersion<\/key>\s*<string>([^<]*)<\/string>/,
},
}
export async function verifyNativeRuntimeVersion(input: {
projectDir: string
platform: string | undefined
profile: string | undefined
nodeEnv: string | undefined
}) {
if (input.platform !== "android" && input.platform !== "ios") {
throw new Error(`Expected EAS_BUILD_PLATFORM to be android or ios, got ${input.platform}`)
}
const source = nativeRuntimeVersionSources[input.platform]
const nativeConfig = await readFile(join(input.projectDir, source.path), "utf8")
const nativeRuntimeVersion = nativeConfig.match(source.pattern)?.[1] ?? null
const packageJsonPath = join(input.projectDir, "package.json")
const { version } = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version: string }
// No OTA_RUNTIME_VERSION here: OTA releases take their runtime from the binary version.
const expectedRuntimeVersion = resolveRuntimeVersion({
isDevelopment: isDevelopmentRuntime({ profile: input.profile, nodeEnv: input.nodeEnv }),
packageVersion: version,
})
if (nativeRuntimeVersion !== expectedRuntimeVersion) {
throw new Error(
`${source.path} has runtime version ${nativeRuntimeVersion ?? "(missing)"}, expected ${expectedRuntimeVersion} for build profile ${input.profile ?? "(unset)"}. This binary would never match the OTA updates published for its version.`,
)
}
return nativeRuntimeVersion
}
@@ -0,0 +1,140 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { dirname, join } from "pathe"
import { afterEach, describe, expect, it } from "vitest"
import { verifyNativeRuntimeVersion } from "./verify-native-runtime-version.impl"
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
)
})
const createProject = async (files: Record<string, string>) => {
const projectDir = await mkdtemp(join(tmpdir(), "verify-native-runtime-version-test-"))
tempDirs.push(projectDir)
await writeFile(join(projectDir, "package.json"), JSON.stringify({ version: "0.6.1" }), "utf8")
for (const [path, content] of Object.entries(files)) {
await mkdir(dirname(join(projectDir, path)), { recursive: true })
await writeFile(join(projectDir, path), content, "utf8")
}
return projectDir
}
const androidStrings = (runtimeVersion: string) => `<resources>
<string name="app_name">Folo</string>
<string name="expo_runtime_version">${runtimeVersion}</string>
</resources>
`
const expoPlist = (runtimeVersion: string) => `<?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>EXUpdatesRuntimeVersion</key>
<string>${runtimeVersion}</string>
</dict>
</plist>
`
const androidStringsPath = "android/app/src/main/res/values/strings.xml"
const expoPlistPath = "ios/Folo/Supporting/Expo.plist"
describe("verifyNativeRuntimeVersion", () => {
it("accepts an Android release build that embeds the package version", async () => {
const projectDir = await createProject({ [androidStringsPath]: androidStrings("0.6.1") })
await expect(
verifyNativeRuntimeVersion({
projectDir,
platform: "android",
profile: "production",
nodeEnv: undefined,
}),
).resolves.toBe("0.6.1")
})
it("rejects an Android release build that embeds the development runtime", async () => {
const projectDir = await createProject({ [androidStringsPath]: androidStrings("0.0.0-dev") })
await expect(
verifyNativeRuntimeVersion({
projectDir,
platform: "android",
profile: "preview",
nodeEnv: undefined,
}),
).rejects.toThrow(/has runtime version 0\.0\.0-dev, expected 0\.6\.1/)
})
it("expects the development runtime for development client builds", async () => {
const projectDir = await createProject({ [androidStringsPath]: androidStrings("0.0.0-dev") })
await expect(
verifyNativeRuntimeVersion({
projectDir,
platform: "android",
profile: "development",
nodeEnv: undefined,
}),
).resolves.toBe("0.0.0-dev")
})
it("treats a build without a profile as a release build", async () => {
const projectDir = await createProject({ [androidStringsPath]: androidStrings("0.0.0-dev") })
await expect(
verifyNativeRuntimeVersion({
projectDir,
platform: "android",
profile: undefined,
nodeEnv: undefined,
}),
).rejects.toThrow(/expected 0\.6\.1/)
})
it("checks Expo.plist for iOS builds", async () => {
const projectDir = await createProject({ [expoPlistPath]: expoPlist("0.6.1") })
await expect(
verifyNativeRuntimeVersion({
projectDir,
platform: "ios",
profile: "preview",
nodeEnv: undefined,
}),
).resolves.toBe("0.6.1")
})
it("rejects a native project without a runtime version", async () => {
const projectDir = await createProject({ [expoPlistPath]: "<plist><dict></dict></plist>" })
await expect(
verifyNativeRuntimeVersion({
projectDir,
platform: "ios",
profile: "production",
nodeEnv: undefined,
}),
).rejects.toThrow(/has runtime version \(missing\)/)
})
it("rejects an unknown platform", async () => {
const projectDir = await createProject({})
await expect(
verifyNativeRuntimeVersion({
projectDir,
platform: undefined,
profile: "production",
nodeEnv: undefined,
}),
).rejects.toThrow(/EAS_BUILD_PLATFORM/)
})
})
@@ -0,0 +1,21 @@
import { fileURLToPath } from "node:url"
import { dirname } from "pathe"
import { verifyNativeRuntimeVersion } from "./verify-native-runtime-version.impl"
// Runs as the eas-build-post-install hook, after EAS generated or synced the native project.
const projectDir = dirname(dirname(fileURLToPath(import.meta.url)))
async function main() {
const runtimeVersion = await verifyNativeRuntimeVersion({
projectDir,
platform: process.env.EAS_BUILD_PLATFORM,
profile: process.env.PROFILE,
nodeEnv: process.env.NODE_ENV,
})
console.info(`Native runtime version: ${runtimeVersion}`)
}
void main()