diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index b568fd9..876efc7 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -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 插件。 diff --git a/packages/common/package.json b/packages/common/package.json index 895bbbb..4c41f4f 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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", diff --git a/packages/update/scripts/native-baseline.mjs b/packages/update/scripts/native-baseline.mjs index f34fc9a..514c51e 100644 --- a/packages/update/scripts/native-baseline.mjs +++ b/packages/update/scripts/native-baseline.mjs @@ -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')}` } diff --git a/packages/update/scripts/native-baseline.test.mjs b/packages/update/scripts/native-baseline.test.mjs index e112087..144ca79 100644 --- a/packages/update/scripts/native-baseline.test.mjs +++ b/packages/update/scripts/native-baseline.test.mjs @@ -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 }) + } +}) diff --git a/packages/update/scripts/xuqm-rn.mjs b/packages/update/scripts/xuqm-rn.mjs index ff1f33a..1c81321 100755 --- a/packages/update/scripts/xuqm-rn.mjs +++ b/packages/update/scripts/xuqm-rn.mjs @@ -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` diff --git a/packages/update/scripts/xuqm_release.mjs b/packages/update/scripts/xuqm_release.mjs index 47fa97b..f2125f9 100644 --- a/packages/update/scripts/xuqm_release.mjs +++ b/packages/update/scripts/xuqm_release.mjs @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9676ab..29d2380 100644 --- a/pnpm-lock.yaml +++ b/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