diff --git a/.agents/skills/mobile-release/SKILL.md b/.agents/skills/mobile-release/SKILL.md index 39667635e..df93f9bfc 100644 --- a/.agents/skills/mobile-release/SKILL.md +++ b/.agents/skills/mobile-release/SKILL.md @@ -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. diff --git a/.agents/skills/mobile-release/references/verify-android-build.md b/.agents/skills/mobile-release/references/verify-android-build.md index cdaecac8c..f35ecf757 100644 --- a/.agents/skills/mobile-release/references/verify-android-build.md +++ b/.agents/skills/mobile-release/references/verify-android-build.md @@ -14,10 +14,11 @@ BT=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1) gh run download -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 ` 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"`. diff --git a/.github/scripts/release-workflow-guards.test.ts b/.github/scripts/release-workflow-guards.test.ts index c015410d8..f0fb0a83b 100644 --- a/.github/scripts/release-workflow-guards.test.ts +++ b/.github/scripts/release-workflow-guards.test.ts @@ -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 } + + 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") diff --git a/apps/mobile/app.config.base.ts b/apps/mobile/app.config.base.ts index 0c20f334c..6643720cf 100644 --- a/apps/mobile/app.config.base.ts +++ b/apps/mobile/app.config.base.ts @@ -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 +// 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, }) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c9c1b2c6f..51c44d6fe 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/scripts/app-config.base.test.ts b/apps/mobile/scripts/app-config.base.test.ts index 85b047fb4..8704c58d6 100644 --- a/apps/mobile/scripts/app-config.base.test.ts +++ b/apps/mobile/scripts/app-config.base.test.ts @@ -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 +} + +const easJson = JSON.parse(readFileSync(new URL("../eas.json", import.meta.url), "utf8")) as { + build: Record +} + +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[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[0]) + expect(config.runtimeVersion).toBe("0.5.11") + }) +}) describe("resolveRuntimeVersion", () => { it("keeps the development runtime version stable", () => { diff --git a/apps/mobile/scripts/verify-native-runtime-version.impl.ts b/apps/mobile/scripts/verify-native-runtime-version.impl.ts new file mode 100644 index 000000000..f67fc0cb8 --- /dev/null +++ b/apps/mobile/scripts/verify-native-runtime-version.impl.ts @@ -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>/, + }, + ios: { + path: "ios/Folo/Supporting/Expo.plist", + pattern: /EXUpdatesRuntimeVersion<\/key>\s*([^<]*)<\/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 +} diff --git a/apps/mobile/scripts/verify-native-runtime-version.test.ts b/apps/mobile/scripts/verify-native-runtime-version.test.ts new file mode 100644 index 000000000..8e0c4ce5d --- /dev/null +++ b/apps/mobile/scripts/verify-native-runtime-version.test.ts @@ -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) => { + 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) => ` + Folo + ${runtimeVersion} + +` + +const expoPlist = (runtimeVersion: string) => ` + + + + EXUpdatesRuntimeVersion + ${runtimeVersion} + + +` + +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]: "" }) + + 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/) + }) +}) diff --git a/apps/mobile/scripts/verify-native-runtime-version.ts b/apps/mobile/scripts/verify-native-runtime-version.ts new file mode 100644 index 000000000..14d04b046 --- /dev/null +++ b/apps/mobile/scripts/verify-native-runtime-version.ts @@ -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()