feat(update): gate plugins on runtime baseline changes
这个提交包含在:
父节点
6fcd50598a
当前提交
144a3bcb2d
@ -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
自动生成的
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
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户