diff --git a/.github/scripts/build-ota-release.mjs b/.github/scripts/build-ota-release.mjs index 2fc160252..9fb8de01c 100644 --- a/.github/scripts/build-ota-release.mjs +++ b/.github/scripts/build-ota-release.mjs @@ -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 + * resolveAsset: (asset: ExportAssetMetadata) => Promise * }} BuildOtaMetadataInput */ @@ -88,27 +95,19 @@ const CONTENT_TYPES = new Map([ export async function buildOtaMetadata(input) { /** @type {Record} */ 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} */ + 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) { diff --git a/.github/scripts/build-ota-release.test.ts b/.github/scripts/build-ota-release.test.ts index 40bec3d0d..6ee7f1839 100644 --- a/.github/scripts/build-ota-release.test.ts +++ b/.github/scripts/build-ota-release.test.ts @@ -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) + }) })