mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-16 20:51:43 +08:00
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:
@@ -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,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,
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user