fix: tighten OTA release asset metadata handling
This commit is contained in:
parent
5cc272422f
commit
eba7be999f
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue