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`。
|
- Android SDK Jenkins `#152` 已发布 `com.xuqm:sdk-update:2.0.0-SNAPSHOT`。
|
||||||
- 新增 `native-test` 最小 Gradle 工程;Jenkins 发布 update 前必须真实编译 Java Bridge,不允许只做 TypeScript/打包校验。
|
- 新增 `native-test` 最小 Gradle 工程;Jenkins 发布 update 前必须真实编译 Java Bridge,不允许只做 TypeScript/打包校验。
|
||||||
- 首次门禁发现独立工程未启用 AndroidX,补充 `native-test/gradle.properties` 后编译通过;该失败不得通过关闭检查规避。
|
- 首次门禁发现独立工程未启用 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": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "2.2.0",
|
"@noble/ciphers": "2.2.0",
|
||||||
"@noble/hashes": "2.2.0",
|
"@noble/hashes": "2.2.0",
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"react-native-blob-util": "0.24.10",
|
"react-native-blob-util": "0.24.10",
|
||||||
"semver": "7.8.5",
|
"semver": "7.8.5",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
@ -43,7 +44,6 @@
|
|||||||
"axios": ">=1.0.0"
|
"axios": ">=1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/semver": "7.7.1",
|
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||||
|
|||||||
@ -12,23 +12,54 @@ const NATIVE_EXTENSIONS = new Set([
|
|||||||
'.java',
|
'.java',
|
||||||
'.kts',
|
'.kts',
|
||||||
'.kt',
|
'.kt',
|
||||||
|
'.m',
|
||||||
|
'.mm',
|
||||||
|
'.pbxproj',
|
||||||
|
'.podspec',
|
||||||
'.properties',
|
'.properties',
|
||||||
|
'.swift',
|
||||||
'.toml',
|
'.toml',
|
||||||
|
'.xcconfig',
|
||||||
'.xml',
|
'.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'])
|
const IGNORED_FILES = new Set(['local.properties'])
|
||||||
|
|
||||||
function walkNativeFiles(directory, root = directory) {
|
function walkFiles(directory, root, extensions, exactNames = new Set()) {
|
||||||
if (!existsSync(directory)) return []
|
if (!existsSync(directory)) return []
|
||||||
const files = []
|
const files = []
|
||||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue
|
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue
|
||||||
const absolute = path.join(directory, entry.name)
|
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 (
|
else if (
|
||||||
!IGNORED_FILES.has(entry.name) &&
|
!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('/') })
|
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
|
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function nativeDependencyVersions(root, hostPackage) {
|
function runtimeDependencyVersions(root, hostPackage) {
|
||||||
const dependencies = { ...hostPackage.dependencies, ...hostPackage.devDependencies }
|
const dependencies = hostPackage.dependencies ?? {}
|
||||||
const native = []
|
const runtime = []
|
||||||
for (const name of Object.keys(dependencies).sort()) {
|
for (const name of Object.keys(dependencies).sort()) {
|
||||||
const directory = path.join(root, 'node_modules', ...name.split('/'))
|
const directory = path.join(root, 'node_modules', ...name.split('/'))
|
||||||
const manifest = packageJson(directory)
|
const manifest = packageJson(directory)
|
||||||
if (!manifest) continue
|
runtime.push(`${name}@${manifest?.version ?? dependencies[name]}`)
|
||||||
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]}`)
|
|
||||||
}
|
}
|
||||||
}
|
return runtime
|
||||||
return native
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic identity of code and dependencies that can only change through a full APK. */
|
/** 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)
|
const hostPackage = packageJson(root)
|
||||||
if (!hostPackage) throw new Error('package.json is missing')
|
if (!hostPackage) throw new Error('package.json is missing')
|
||||||
const hash = createHash('sha256')
|
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`)
|
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),
|
a.relative.localeCompare(b.relative),
|
||||||
)) {
|
)) {
|
||||||
hash.update(`file\0${file.relative}\0`)
|
hash.update(`file\0${file.relative}\0`)
|
||||||
hash.update(readFileSync(file.absolute))
|
hash.update(readFileSync(file.absolute))
|
||||||
hash.update('\0')
|
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() {
|
function fixture() {
|
||||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-native-baseline-'))
|
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-native-baseline-'))
|
||||||
mkdirSync(path.join(root, 'android', 'app', 'src', 'main', 'java'), { recursive: true })
|
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 })
|
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, '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')
|
writeFileSync(path.join(root, 'src', 'business.ts'), 'export const value = 1')
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
@ -27,3 +37,38 @@ test('native source changes the baseline while ordinary JS does not', () => {
|
|||||||
rmSync(root, { recursive: true, force: true })
|
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 })
|
rmSync(target, { force: true, recursive: true })
|
||||||
mkdirSync(target, { recursive: true })
|
mkdirSync(target, { recursive: true })
|
||||||
const owners = new Map()
|
const owners = new Map()
|
||||||
const nativeBaselineId = computeNativeBaseline(root)
|
const nativeBaselineId = computeNativeBaseline(root, platform)
|
||||||
for (const module of versioned.modules) {
|
for (const module of versioned.modules) {
|
||||||
const sourceRoot = bundleOutputDirectory(root, config, platform, module.id)
|
const sourceRoot = bundleOutputDirectory(root, config, platform, module.id)
|
||||||
const bundleName = `${module.id}.${platform}.bundle`
|
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 config = readConfig(root)
|
||||||
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
||||||
const modules = selectModules({ ...config, modules: versioned.modules }, moduleIds)
|
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 serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl
|
||||||
const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey
|
const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey
|
||||||
const apiToken = process.env.XUQM_API_TOKEN ?? config.release?.apiToken
|
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':
|
'@noble/hashes':
|
||||||
specifier: 2.2.0
|
specifier: 2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
|
'@types/semver':
|
||||||
|
specifier: 7.7.1
|
||||||
|
version: 7.7.1
|
||||||
react-native-blob-util:
|
react-native-blob-util:
|
||||||
specifier: 0.24.10
|
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)
|
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':
|
'@types/react':
|
||||||
specifier: ^19.2.14
|
specifier: ^19.2.14
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
'@types/semver':
|
|
||||||
specifier: 7.7.1
|
|
||||||
version: 7.7.1
|
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.18.0
|
specifier: ^1.18.0
|
||||||
version: 1.18.1
|
version: 1.18.1
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户