fix: tighten OTA release asset metadata handling

This commit is contained in:
DIYgod 2026-04-10 21:44:58 +08:00
parent 5cc272422f
commit eba7be999f
2 changed files with 282 additions and 43 deletions

View File

@ -6,7 +6,7 @@ import { existsSync } from "node:fs"
import { readFile, writeFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { dirname, join, resolve } from "pathe"
import { dirname, extname, join, resolve } from "pathe"
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..")
@ -41,6 +41,13 @@ const CONTENT_TYPES = new Map([
[".xml", "application/xml"],
])
/**
* @typedef {{
* path: string
* ext?: string
* }} ExportAssetMetadata
*/
/**
* @typedef {{
* path: string
@ -78,7 +85,7 @@ const CONTENT_TYPES = new Map([
* message: string | null
* }
* metadata: ExportMetadata
* resolveAsset: (assetPath: string) => Promise<OtaAsset>
* resolveAsset: (asset: ExportAssetMetadata) => Promise<OtaAsset>
* }} BuildOtaMetadataInput
*/
@ -88,27 +95,19 @@ const CONTENT_TYPES = new Map([
export async function buildOtaMetadata(input) {
/** @type {Record<string, { launchAsset: OtaAsset, assets: OtaAsset[] }>} */
const platforms = {}
const fileMetadata = resolveExportFileMetadata(input.metadata)
for (const platform of OTA_PLATFORMS) {
const platformMetadata = input.metadata.fileMetadata?.[platform]
if (!platformMetadata?.bundle) {
continue
}
const launchAssetPath = normalizeAssetPath(platformMetadata.bundle)
const assetPaths = collectAssetPaths(platformMetadata.assets)
const platformMetadata = resolvePlatformMetadata(fileMetadata, platform)
const launchAsset = { path: normalizeAssetPath(platformMetadata.bundle) }
const assets = collectAssets(platformMetadata.assets, platform)
platforms[platform] = {
launchAsset: await input.resolveAsset(launchAssetPath),
assets: await Promise.all(assetPaths.map((assetPath) => input.resolveAsset(assetPath))),
launchAsset: await input.resolveAsset(launchAsset),
assets: await Promise.all(assets.map((asset) => input.resolveAsset(asset))),
}
}
if (Object.keys(platforms).length === 0) {
throw new Error("Expo export metadata does not include any platform bundles")
}
return {
schemaVersion: 1,
product: input.product,
@ -163,8 +162,8 @@ export async function buildReleaseAssets(options = {}) {
message: parseNullableEnv("OTA_POLICY_MESSAGE"),
},
metadata,
resolveAsset: async (assetPath) => {
const assetFilePath = join(distDir, assetPath)
resolveAsset: async (asset) => {
const assetFilePath = join(distDir, asset.path)
let assetBuffer
try {
@ -172,14 +171,14 @@ export async function buildReleaseAssets(options = {}) {
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to read exported asset "${assetPath}" at ${assetFilePath}: ${reason}`,
`Failed to read exported asset "${asset.path}" at ${assetFilePath}: ${reason}`,
)
}
return {
path: assetPath,
path: asset.path,
sha256: createHash("sha256").update(assetBuffer).digest("hex"),
contentType: resolveContentType(assetPath),
contentType: resolveContentType(asset),
}
},
})
@ -215,43 +214,104 @@ async function main() {
/**
* @param {unknown} value
*/
function collectAssetPaths(value) {
if (!Array.isArray(value)) {
function resolveExportFileMetadata(metadata) {
if (!metadata || typeof metadata !== "object") {
throw new Error("Expo export metadata is missing fileMetadata")
}
if (!metadata.fileMetadata || typeof metadata.fileMetadata !== "object") {
throw new Error("Expo export metadata is missing fileMetadata")
}
return metadata.fileMetadata
}
function resolvePlatformMetadata(fileMetadata, platform) {
const platformMetadata = fileMetadata[platform]
if (!platformMetadata || typeof platformMetadata !== "object") {
throw new Error(`Expo export metadata is missing ${platform} platform metadata`)
}
if (typeof platformMetadata.bundle !== "string" || platformMetadata.bundle.length === 0) {
throw new Error(`Expo export metadata is missing ${platform} bundle metadata`)
}
return platformMetadata
}
function collectAssets(value, platform) {
if (value == null) {
return []
}
const dedupedPaths = new Set()
for (const asset of value) {
const assetPath = resolveAssetPath(asset)
dedupedPaths.add(normalizeAssetPath(assetPath))
if (!Array.isArray(value)) {
throw new TypeError(`Expo export metadata has invalid ${platform} assets metadata`)
}
return [...dedupedPaths]
/** @type {Map<string, ExportAssetMetadata>} */
const dedupedAssets = new Map()
for (const asset of value) {
const normalizedAsset = normalizeExportAsset(asset)
const existingAsset = dedupedAssets.get(normalizedAsset.path)
if (!existingAsset || (!existingAsset.ext && normalizedAsset.ext)) {
dedupedAssets.set(normalizedAsset.path, normalizedAsset)
continue
}
if (existingAsset.ext && normalizedAsset.ext && existingAsset.ext !== normalizedAsset.ext) {
throw new Error(
`Expo export metadata has conflicting ext values for asset "${normalizedAsset.path}"`,
)
}
}
return [...dedupedAssets.values()]
}
/**
* @param {unknown} asset
*/
function resolveAssetPath(asset) {
function normalizeExportAsset(asset) {
if (typeof asset === "string") {
return asset
return { path: normalizeAssetPath(asset) }
}
if (!asset || typeof asset !== "object") {
return null
throw new Error(`Unsupported Expo asset metadata entry: ${JSON.stringify(asset)}`)
}
if ("path" in asset && typeof asset.path === "string") {
return asset.path
const assetPath =
"path" in asset && typeof asset.path === "string"
? asset.path
: "file" in asset && typeof asset.file === "string"
? asset.file
: null
if (!assetPath) {
throw new Error(`Unsupported Expo asset metadata entry: ${JSON.stringify(asset)}`)
}
if ("file" in asset && typeof asset.file === "string") {
return asset.file
return {
path: normalizeAssetPath(assetPath),
...("ext" in asset && asset.ext != null ? { ext: normalizeAssetExtension(asset.ext) } : {}),
}
}
function normalizeAssetExtension(ext) {
if (typeof ext !== "string") {
throw new TypeError(`Unsupported Expo asset ext metadata: ${JSON.stringify(ext)}`)
}
throw new Error(`Unsupported Expo asset metadata entry: ${JSON.stringify(asset)}`)
const normalizedExt = ext.trim().replace(/^\.+/, "").toLowerCase()
if (!normalizedExt) {
throw new Error(`Invalid exported asset ext "${ext}"`)
}
return normalizedExt
}
function resolveMobileProjectDir(projectDir) {
@ -286,10 +346,12 @@ function normalizeAssetPath(assetPath) {
return normalized
}
function resolveContentType(assetPath) {
const extension = assetPath.includes(".") ? assetPath.slice(assetPath.lastIndexOf(".")) : ""
function resolveContentType(asset) {
const pathExtension = extname(asset.path).toLowerCase()
const metadataExtension = asset.ext ? `.${asset.ext}` : ""
const extension = pathExtension || metadataExtension
return CONTENT_TYPES.get(extension.toLowerCase()) ?? "application/octet-stream"
return CONTENT_TYPES.get(extension) ?? "application/octet-stream"
}
async function createTarZstArchive({ distDir, archivePath }) {
@ -380,7 +442,12 @@ function execGit(args, cwd) {
}
async function readJson(path) {
return JSON.parse(await readFile(path, "utf8"))
try {
return JSON.parse(await readFile(path, "utf8"))
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to read JSON at ${path}: ${reason}`)
}
}
function parseBooleanEnv(name, defaultValue) {

View File

@ -1,4 +1,18 @@
import { describe, expect, it } from "vitest"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "pathe"
import { afterEach, describe, expect, it } from "vitest"
type InputAsset = string | { path: string; ext?: string }
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
)
})
describe("buildOtaMetadata", () => {
it("preserves releaseVersion, runtimeVersion, and scoped git tag", async () => {
@ -41,4 +55,162 @@ describe("buildOtaMetadata", () => {
expect(result.runtimeVersion).toBe("0.4.1")
expect(result.git.tag).toBe("mobile/v0.4.2")
})
it("uses asset ext metadata when exported asset paths have no suffix", async () => {
const { buildOtaMetadata } = await import("./build-ota-release.mjs")
const result = await buildOtaMetadata({
product: "mobile",
channel: "production",
releaseVersion: "0.4.2",
releaseKind: "ota",
runtimeVersion: "0.4.1",
gitTag: "mobile/v0.4.2",
gitCommit: "abcdef1234567890",
publishedAt: "2026-04-10T12:00:00.000Z",
policy: {
storeRequired: false,
minSupportedBinaryVersion: "0.4.1",
message: null,
},
metadata: {
fileMetadata: {
ios: {
bundle: "_expo/static/js/ios/main.hbc",
assets: [{ path: "assets/splash", ext: "png" }],
},
android: {
bundle: "_expo/static/js/android/main.hbc",
assets: [],
},
},
},
resolveAsset: async (asset: InputAsset) => {
const assetPath = typeof asset === "string" ? asset : asset.path
const ext = typeof asset === "string" ? undefined : asset.ext
return {
path: assetPath,
sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64),
contentType: ext === "png" ? "image/png" : "application/octet-stream",
}
},
})
expect(result.platforms.ios.assets).toEqual([
expect.objectContaining({
path: "assets/splash",
contentType: "image/png",
}),
])
})
it("fails when a platform bundle is missing from export metadata", async () => {
const { buildOtaMetadata } = await import("./build-ota-release.mjs")
await expect(
buildOtaMetadata({
product: "mobile",
channel: "production",
releaseVersion: "0.4.2",
releaseKind: "ota",
runtimeVersion: "0.4.1",
gitTag: "mobile/v0.4.2",
gitCommit: "abcdef1234567890",
publishedAt: "2026-04-10T12:00:00.000Z",
policy: {
storeRequired: false,
minSupportedBinaryVersion: "0.4.1",
message: null,
},
metadata: {
fileMetadata: {
ios: {
bundle: "_expo/static/js/ios/main.hbc",
assets: [],
},
},
},
resolveAsset: async (assetPath: string) => ({
path: assetPath,
sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64),
contentType: "application/octet-stream",
}),
}),
).rejects.toThrow(/android/i)
})
it("deduplicates repeated asset entries after path normalization", async () => {
const { buildOtaMetadata } = await import("./build-ota-release.mjs")
const resolvedAssets: InputAsset[] = []
const result = await buildOtaMetadata({
product: "mobile",
channel: "production",
releaseVersion: "0.4.2",
releaseKind: "ota",
runtimeVersion: "0.4.1",
gitTag: "mobile/v0.4.2",
gitCommit: "abcdef1234567890",
publishedAt: "2026-04-10T12:00:00.000Z",
policy: {
storeRequired: false,
minSupportedBinaryVersion: "0.4.1",
message: null,
},
metadata: {
fileMetadata: {
ios: {
bundle: "_expo/static/js/ios/main.hbc",
assets: ["./assets/icon.png", { path: "assets/icon.png" }],
},
android: {
bundle: "_expo/static/js/android/main.hbc",
assets: [],
},
},
},
resolveAsset: async (asset: InputAsset) => {
resolvedAssets.push(asset)
const assetPath = typeof asset === "string" ? asset : asset.path
return {
path: assetPath,
sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64),
contentType: "image/png",
}
},
})
expect(result.platforms.ios.assets).toHaveLength(1)
expect(
resolvedAssets.filter((asset) => {
const assetPath = typeof asset === "string" ? asset : asset.path
return assetPath === "assets/icon.png"
}),
).toHaveLength(1)
})
})
describe("buildReleaseAssets", () => {
it("includes the JSON file path when export metadata cannot be parsed", async () => {
const { buildReleaseAssets } = await import("./build-ota-release.mjs")
const projectDir = await mkdtemp(join(tmpdir(), "build-ota-release-test-"))
const distDir = join(projectDir, "dist")
const metadataPath = join(distDir, "metadata.json")
tempDirs.push(projectDir)
await mkdir(distDir, { recursive: true })
await writeFile(
join(projectDir, "package.json"),
JSON.stringify({ name: "@follow/mobile-test", version: "0.4.2" }),
"utf8",
)
await writeFile(metadataPath, "{invalid", "utf8")
await expect(buildReleaseAssets({ projectDir })).rejects.toThrow(metadataPath)
})
})