feat(update): gate plugins on runtime baseline changes

这个提交包含在:
XuqmGroup 2026-07-17 14:43:05 +08:00
父节点 6fcd50598a
当前提交 144a3bcb2d
共有 7 个文件被更改,包括 112 次插入27 次删除

查看文件

@ -152,3 +152,6 @@ pnpm --dir packages/update test
- Android SDK Jenkins `#152` 已发布 `com.xuqm:sdk-update:2.0.0-SNAPSHOT`
- 新增 `native-test` 最小 Gradle 工程;Jenkins 发布 update 前必须真实编译 Java Bridge,不允许只做 TypeScript/打包校验。
- 首次门禁发现独立工程未启用 AndroidX,补充 `native-test/gradle.properties` 后编译通过;该失败不得通过关闭检查规避。
- Jenkins Windows 节点直接访问外部 Android 仓库会出现无输出的长时间依赖等待;`native-test` 的 plugin management 已与 AndroidSDK 对齐,统一优先使用 Nexus `android` 聚合仓库,再回退官方仓库。
- Windows CLI 测试路径统一使用 `fileURLToPath`,禁止把 `file:` URL 的 pathname 直接作为本机路径。
- native baseline 现在按 Android/iOS 分别计算,并覆盖全部运行时依赖的实际安装版本、平台原生源码/资源、`src`/`assets` 内会随安装包打入的图片、字体和媒体文件;这些内容变化后必须先发布完整 App,不能只发布 JS 插件。

查看文件

@ -32,6 +32,7 @@
"dependencies": {
"@noble/ciphers": "2.2.0",
"@noble/hashes": "2.2.0",
"@types/semver": "7.7.1",
"react-native-blob-util": "0.24.10",
"semver": "7.8.5",
"zod": "4.4.3"
@ -43,7 +44,6 @@
"axios": ">=1.0.0"
},
"devDependencies": {
"@types/semver": "7.7.1",
"typescript": "catalog:",
"@types/react": "^19.2.14",
"@react-native-async-storage/async-storage": "^3.1.1",

查看文件

@ -12,23 +12,54 @@ const NATIVE_EXTENSIONS = new Set([
'.java',
'.kts',
'.kt',
'.m',
'.mm',
'.pbxproj',
'.podspec',
'.properties',
'.swift',
'.toml',
'.xcconfig',
'.xml',
])
const IGNORED_DIRECTORIES = new Set(['.cxx', '.gradle', 'build', 'generated'])
const PACKAGED_ASSET_EXTENSIONS = new Set([
'.bmp',
'.gif',
'.jpeg',
'.jpg',
'.mp3',
'.mp4',
'.otf',
'.png',
'.svg',
'.ttf',
'.webp',
'.wav',
])
const IGNORED_DIRECTORIES = new Set([
'.cxx',
'.git',
'.gradle',
'build',
'bundle',
'coverage',
'dist',
'generated',
'node_modules',
'rn-bundles',
])
const IGNORED_FILES = new Set(['local.properties'])
function walkNativeFiles(directory, root = directory) {
function walkFiles(directory, root, extensions, exactNames = new Set()) {
if (!existsSync(directory)) return []
const files = []
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue
const absolute = path.join(directory, entry.name)
if (entry.isDirectory()) files.push(...walkNativeFiles(absolute, root))
if (entry.isDirectory()) files.push(...walkFiles(absolute, root, extensions, exactNames))
else if (
!IGNORED_FILES.has(entry.name) &&
(NATIVE_EXTENSIONS.has(path.extname(entry.name)) || entry.name === 'gradlew')
(extensions.has(path.extname(entry.name).toLowerCase()) || exactNames.has(entry.name))
) {
files.push({ absolute, relative: path.relative(root, absolute).split(path.sep).join('/') })
}
@ -41,38 +72,44 @@ function packageJson(directory) {
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
}
function nativeDependencyVersions(root, hostPackage) {
const dependencies = { ...hostPackage.dependencies, ...hostPackage.devDependencies }
const native = []
function runtimeDependencyVersions(root, hostPackage) {
const dependencies = hostPackage.dependencies ?? {}
const runtime = []
for (const name of Object.keys(dependencies).sort()) {
const directory = path.join(root, 'node_modules', ...name.split('/'))
const manifest = packageJson(directory)
if (!manifest) continue
const hasNativeCode =
existsSync(path.join(directory, 'android')) ||
existsSync(path.join(directory, 'react-native.config.js')) ||
manifest.codegenConfig !== undefined
if (hasNativeCode || name === 'react-native') {
native.push(`${name}@${manifest.version ?? dependencies[name]}`)
runtime.push(`${name}@${manifest?.version ?? dependencies[name]}`)
}
}
return native
return runtime
}
/** Deterministic identity of code and dependencies that can only change through a full APK. */
export function computeNativeBaseline(root = process.cwd()) {
export function computeNativeBaseline(root = process.cwd(), platform = 'android') {
if (platform !== 'android' && platform !== 'ios') {
throw new Error('platform must be android or ios')
}
const hostPackage = packageJson(root)
if (!hostPackage) throw new Error('package.json is missing')
const hash = createHash('sha256')
for (const dependency of nativeDependencyVersions(root, hostPackage)) {
hash.update(`platform\0${platform}\0`)
for (const dependency of runtimeDependencyVersions(root, hostPackage)) {
hash.update(`dependency\0${dependency}\0`)
}
for (const file of walkNativeFiles(path.join(root, 'android')).sort((a, b) =>
const nativeFiles = walkFiles(
path.join(root, platform),
root,
new Set([...NATIVE_EXTENSIONS, ...PACKAGED_ASSET_EXTENSIONS]),
new Set(platform === 'android' ? ['gradlew'] : ['Podfile']),
)
const sourceAssets = [path.join(root, 'src'), path.join(root, 'assets')].flatMap(directory =>
walkFiles(directory, root, PACKAGED_ASSET_EXTENSIONS),
)
for (const file of [...nativeFiles, ...sourceAssets].sort((a, b) =>
a.relative.localeCompare(b.relative),
)) {
hash.update(`file\0${file.relative}\0`)
hash.update(readFileSync(file.absolute))
hash.update('\0')
}
return `android-sha256:${hash.digest('hex')}`
return `${platform}-sha256:${hash.digest('hex')}`
}

查看文件

@ -8,9 +8,19 @@ import { computeNativeBaseline } from './native-baseline.mjs'
function fixture() {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-native-baseline-'))
mkdirSync(path.join(root, 'android', 'app', 'src', 'main', 'java'), { recursive: true })
mkdirSync(path.join(root, 'ios'), { recursive: true })
mkdirSync(path.join(root, 'node_modules', 'runtime-library'), { recursive: true })
mkdirSync(path.join(root, 'src'), { recursive: true })
writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: {} }))
writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({ dependencies: { 'runtime-library': '^1.0.0' } }),
)
writeFileSync(
path.join(root, 'node_modules', 'runtime-library', 'package.json'),
JSON.stringify({ name: 'runtime-library', version: '1.0.0' }),
)
writeFileSync(path.join(root, 'android', 'settings.gradle'), 'rootProject.name = "fixture"')
writeFileSync(path.join(root, 'ios', 'App.swift'), 'struct App {}')
writeFileSync(path.join(root, 'src', 'business.ts'), 'export const value = 1')
return root
}
@ -27,3 +37,38 @@ test('native source changes the baseline while ordinary JS does not', () => {
rmSync(root, { recursive: true, force: true })
}
})
test('runtime dependencies and packaged assets require a new full-app baseline', () => {
const root = fixture()
try {
const initial = computeNativeBaseline(root, 'android')
writeFileSync(path.join(root, 'src', 'logo.png'), 'first-image')
const withAsset = computeNativeBaseline(root, 'android')
assert.notEqual(withAsset, initial)
writeFileSync(
path.join(root, 'node_modules', 'runtime-library', 'package.json'),
JSON.stringify({ name: 'runtime-library', version: '1.1.0' }),
)
assert.notEqual(computeNativeBaseline(root, 'android'), withAsset)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('native baselines are platform-specific', () => {
const root = fixture()
try {
const android = computeNativeBaseline(root, 'android')
const ios = computeNativeBaseline(root, 'ios')
assert.match(android, /^android-sha256:/)
assert.match(ios, /^ios-sha256:/)
assert.notEqual(android, ios)
writeFileSync(path.join(root, 'ios', 'App.swift'), 'struct App { let value = 1 }')
assert.equal(computeNativeBaseline(root, 'android'), android)
assert.notEqual(computeNativeBaseline(root, 'ios'), ios)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

查看文件

@ -302,7 +302,7 @@ function embed(platform, optionArgs = []) {
rmSync(target, { force: true, recursive: true })
mkdirSync(target, { recursive: true })
const owners = new Map()
const nativeBaselineId = computeNativeBaseline(root)
const nativeBaselineId = computeNativeBaseline(root, platform)
for (const module of versioned.modules) {
const sourceRoot = bundleOutputDirectory(root, config, platform, module.id)
const bundleName = `${module.id}.${platform}.bundle`

查看文件

@ -38,7 +38,7 @@ if (!SUPPORTED_PLATFORMS.has(platform)) throw new Error('platform must be androi
const config = readConfig(root)
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
const modules = selectModules({ ...config, modules: versioned.modules }, moduleIds)
const nativeBaselineId = computeNativeBaseline(root)
const nativeBaselineId = computeNativeBaseline(root, platform)
const serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl
const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey
const apiToken = process.env.XUQM_API_TOKEN ?? config.release?.apiToken

6
pnpm-lock.yaml 自动生成的
查看文件

@ -69,6 +69,9 @@ importers:
'@noble/hashes':
specifier: 2.2.0
version: 2.2.0
'@types/semver':
specifier: 7.7.1
version: 7.7.1
react-native-blob-util:
specifier: 0.24.10
version: 0.24.10(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
@ -85,9 +88,6 @@ importers:
'@types/react':
specifier: ^19.2.14
version: 19.2.17
'@types/semver':
specifier: 7.7.1
version: 7.7.1
axios:
specifier: ^1.18.0
version: 1.18.1