mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
build(mobile): key Metro's transform cache on the shell build kind (#22244)
`babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into `mobileShellBuildKind` at transform time (babel-preset-expo/build/inline-env-vars.js:51), but nothing Metro hashes into the transform cache key carries that value: the key is `metro/src/DeltaBundler/getTransformCacheKey.js:21`, whose inputs are the Metro version, `cacheVersion`, the transformer path and `@expo/metro-config/build/transform-worker/metro-transform-worker.js:600`, none of which reads the environment. A release assembled after an opposite-kind build reuses the warm entries and bakes the wrong shell, and the absence of the variable's name in the bundle cannot tell the two apart. The newest published `@expo/metro-config` (58.0.4) keys it no differently. Folds the kind into `cacheVersion`, by the same `=== 'ota'` rule the app applies, keeping Metro's own version as the prefix. Proven with four `expo export --platform android` runs against an isolated Metro cache. Before: `ota` then `native` produced byte-identical bundles, both `return 'ota'`. After: the second run returns `'native'` under a different bundle hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -103,3 +103,91 @@ it('leaves the switch out of every other workflow, so only a release can set it'
|
||||
|
||||
expect(setters).toEqual(['mobile-android-release.yml', 'mobile-ios-release.yml'])
|
||||
})
|
||||
|
||||
/**
|
||||
* The other half of the switch: what a restored bundler cache would do to it.
|
||||
*
|
||||
* `babel-preset-expo` inlines the variable at transform time, but nothing Metro hashes into the
|
||||
* transform cache key carries its value, so a Metro cache restored from a run of the opposite kind
|
||||
* returns the opposite shell byte for byte. `mobile/metro.config.js` folds the kind into
|
||||
* `cacheVersion` and so survives one; a cache keyed by these workflows would have to name the shell
|
||||
* too, and today none of them restores one at all.
|
||||
*/
|
||||
const MOBILE_WORKFLOWS = ['mobile.yml', 'mobile-android-release.yml', 'mobile-ios-release.yml']
|
||||
/** Paths under which a Metro or Expo build cache lives, in the spellings a workflow would use. */
|
||||
const BUNDLER_CACHE_PATHS = ['metro-cache', '.expo', 'node_modules/.cache']
|
||||
/** The one restored path these workflows compute in a script, and so this test cannot read. */
|
||||
const REVIEWED_COMPUTED_PATH = '${{ steps.electron-package-cache.outputs.cache-root }}'
|
||||
|
||||
/** Every step a workflow runs, descending into the repository's own composite actions. */
|
||||
function stepsIncludingComposites(file) {
|
||||
const collect = (owner, steps, into) => {
|
||||
for (const step of steps ?? []) {
|
||||
into.push({ owner, step })
|
||||
if (typeof step.uses === 'string' && step.uses.startsWith('./')) {
|
||||
const action = parse(readFileSync(resolve(projectDir, step.uses, 'action.yml'), 'utf8'))
|
||||
collect(step.uses, action.runs?.steps, into)
|
||||
}
|
||||
}
|
||||
return into
|
||||
}
|
||||
return Object.entries(workflowOf(file).jobs).flatMap(([job, body]) =>
|
||||
collect(job, body.steps, [])
|
||||
)
|
||||
}
|
||||
|
||||
/** What those steps restore: `actions/cache`, and the setup actions that carry one of their own. */
|
||||
function cacheRestores(file) {
|
||||
return stepsIncludingComposites(file).flatMap(({ owner, step }) => {
|
||||
const uses = typeof step.uses === 'string' ? step.uses : ''
|
||||
const named = { name: `${owner}: ${step.name ?? uses}` }
|
||||
if (/^actions\/cache(\/restore)?@/.test(uses)) {
|
||||
return [{ ...named, paths: String(step.with?.path ?? ''), key: String(step.with?.key ?? '') }]
|
||||
}
|
||||
if (uses.startsWith('actions/setup-node@') && step.with?.cache) {
|
||||
return [{ ...named, paths: `${step.with.cache} store`, key: '' }]
|
||||
}
|
||||
if (uses.startsWith('ruby/setup-ruby@') && step.with?.['bundler-cache']) {
|
||||
return [{ ...named, paths: 'bundler vendor', key: '' }]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
const MOBILE_CACHE_RESTORES = MOBILE_WORKFLOWS.flatMap((file) => cacheRestores(file))
|
||||
|
||||
describe('what the mobile jobs restore from cache', () => {
|
||||
it('sees the caches these jobs already have, so the rule below cannot pass vacuously', () => {
|
||||
const names = MOBILE_CACHE_RESTORES.map(({ name }) => name)
|
||||
|
||||
// One from a composite action and one declared in a workflow: a walk that stopped at either
|
||||
// boundary would report an empty list and call it clean.
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'./.github/actions/install-node-dependencies: Cache Electron package archive',
|
||||
'./.github/actions/install-node-dependencies: Restore compiled native modules',
|
||||
'./.github/actions/install-node-dependencies: Setup Node.js',
|
||||
'ios-build: Setup Ruby and fastlane'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reads every restored path, rather than passing one it cannot evaluate', () => {
|
||||
const computed = MOBILE_CACHE_RESTORES.filter(({ paths }) => paths.includes('${{'))
|
||||
|
||||
expect(computed.map(({ paths }) => paths)).toEqual(computed.map(() => REVIEWED_COMPUTED_PATH))
|
||||
})
|
||||
|
||||
it('restores no Metro or Expo build cache, which would decide the shell before the env does', () => {
|
||||
const bundlerCaches = MOBILE_CACHE_RESTORES.filter(({ paths }) =>
|
||||
BUNDLER_CACHE_PATHS.some((needle) => paths.includes(needle))
|
||||
)
|
||||
|
||||
// A restored one is not fatal — it just has to name the shell, the way `cacheVersion` does.
|
||||
expect(
|
||||
bundlerCaches.filter(({ key }) => !key.includes(SWITCH) && !key.includes('inputs.shell')),
|
||||
bundlerCaches.map(({ name, paths }) => `${name}: ${paths}`).join('\n')
|
||||
).toEqual([])
|
||||
expect(bundlerCaches).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,4 +10,16 @@ const config = getDefaultConfig(projectRoot)
|
||||
// Metro only watches mobile/ by default, so make repo-root shared modules visible.
|
||||
config.watchFolders = Array.from(new Set([...(config.watchFolders ?? []), sharedRoot]))
|
||||
|
||||
/**
|
||||
* The shell kind the bundle is being built for, by the same rule `mobileShellBuildKind` applies in
|
||||
* `src/storage/preferences.ts`. This read runs in Node at config time, so it is not the second
|
||||
* inlined read that module's census forbids.
|
||||
*/
|
||||
const shellBuildKind = process.env.EXPO_PUBLIC_MOBILE_SHELL === 'ota' ? 'ota' : 'native'
|
||||
|
||||
// Why: babel-preset-expo inlines EXPO_PUBLIC_MOBILE_SHELL at transform time, but nothing Metro
|
||||
// hashes into the transform cache key carries that value, so a warm cache from the opposite kind
|
||||
// silently bakes the wrong shell into a release.
|
||||
config.cacheVersion = `${config.cacheVersion}-shell-${shellBuildKind}`
|
||||
|
||||
module.exports = config
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Metro's transform cache is what decides which shell a release bakes in.
|
||||
*
|
||||
* `babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into `mobileShellBuildKind` at transform
|
||||
* time, but nothing Metro hashes into the transform cache key carries that value, so a warm cache
|
||||
* seeded by the opposite kind returns the opposite shell byte for byte — and the absence of the
|
||||
* variable's name in the bundle cannot tell the two apart. `cacheVersion` is the one input to that
|
||||
* key a project owns.
|
||||
*/
|
||||
const MOBILE_ROOT = join(import.meta.dirname, '..')
|
||||
const CONFIG_PATH = join(MOBILE_ROOT, 'metro.config.js')
|
||||
const SWITCH = 'EXPO_PUBLIC_MOBILE_SHELL'
|
||||
/** The one expression both the config and the app answer the build kind with. */
|
||||
const BUILD_KIND_RULE = `process.env.${SWITCH} === 'ota' ? 'ota' : 'native'`
|
||||
const APP_DEFINITION = join(MOBILE_ROOT, 'src', 'storage', 'preferences.ts')
|
||||
const requireConfig = createRequire(import.meta.url)
|
||||
const originalSwitch = process.env[SWITCH]
|
||||
|
||||
function cacheVersionOf(loaded: unknown): string {
|
||||
if (typeof loaded !== 'object' || loaded === null || !('cacheVersion' in loaded)) {
|
||||
throw new Error('metro.config.js exported no cacheVersion')
|
||||
}
|
||||
const { cacheVersion } = loaded
|
||||
if (typeof cacheVersion !== 'string') {
|
||||
throw new Error(`metro.config.js exported a non-string cacheVersion: ${typeof cacheVersion}`)
|
||||
}
|
||||
return cacheVersion
|
||||
}
|
||||
|
||||
/** Re-evaluates the config, which is the only way the env read at its top level runs again. */
|
||||
function cacheVersionFor(shell: string | undefined): string {
|
||||
if (shell === undefined) {
|
||||
Reflect.deleteProperty(process.env, SWITCH)
|
||||
} else {
|
||||
process.env[SWITCH] = shell
|
||||
}
|
||||
Reflect.deleteProperty(requireConfig.cache, requireConfig.resolve(CONFIG_PATH))
|
||||
return cacheVersionOf(requireConfig(CONFIG_PATH))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSwitch === undefined) {
|
||||
Reflect.deleteProperty(process.env, SWITCH)
|
||||
} else {
|
||||
process.env[SWITCH] = originalSwitch
|
||||
}
|
||||
})
|
||||
|
||||
describe("metro's transform cache key", () => {
|
||||
it('separates the two shell kinds, so neither can be served a warm cache of the other', () => {
|
||||
expect(cacheVersionFor('ota')).not.toEqual(cacheVersionFor('native'))
|
||||
})
|
||||
|
||||
it('reads the kind by the same rule the app applies, so the key names the shell it bakes', () => {
|
||||
const base = cacheVersionFor(undefined).replace(/-shell-native$/, '')
|
||||
|
||||
expect(cacheVersionFor('ota')).toBe(`${base}-shell-ota`)
|
||||
expect(cacheVersionFor('native')).toBe(`${base}-shell-native`)
|
||||
// Every other value is a native build to the app, and so has to be one to the cache as well.
|
||||
expect(cacheVersionFor(undefined)).toBe(`${base}-shell-native`)
|
||||
expect(cacheVersionFor('')).toBe(`${base}-shell-native`)
|
||||
expect(cacheVersionFor('OTA')).toBe(`${base}-shell-native`)
|
||||
expect(cacheVersionFor('page')).toBe(`${base}-shell-native`)
|
||||
})
|
||||
|
||||
it('spells that rule the same way the app does, so the two cannot drift apart silently', () => {
|
||||
// A copy is unavoidable: the config runs in Node at bundle time and the app module is a React
|
||||
// Native one, so neither can import the other. Pinning the spelling is what keeps them equal.
|
||||
for (const path of [CONFIG_PATH, APP_DEFINITION]) {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
const line = text.split('\n').find((candidate) => candidate.includes(BUILD_KIND_RULE))
|
||||
|
||||
expect(line, `${path} does not spell: ${BUILD_KIND_RULE}`).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps metro's own cache version, which invalidates on a bundler upgrade", () => {
|
||||
// Replacing it rather than extending it would trade this bug for that one.
|
||||
const defaultConfig: unknown = requireConfig('expo/metro-config').getDefaultConfig(MOBILE_ROOT)
|
||||
|
||||
expect(cacheVersionFor('ota').startsWith(`${cacheVersionOf(defaultConfig)}-`)).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user