feat(ota): generate release assets from mobile export
This commit is contained in:
parent
d6258b94e2
commit
bda7c3a0a8
|
|
@ -0,0 +1,429 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync } from "node:fs"
|
||||
import { readFile, writeFile } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { dirname, join, resolve } from "pathe"
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..")
|
||||
const DEFAULT_MOBILE_PROJECT_DIR = join(REPO_ROOT, "apps", "mobile")
|
||||
const OTA_PLATFORMS = ["ios", "android"]
|
||||
const CONTENT_TYPES = new Map([
|
||||
[".aac", "audio/aac"],
|
||||
[".bmp", "image/bmp"],
|
||||
[".bundle", "application/javascript"],
|
||||
[".css", "text/css"],
|
||||
[".gif", "image/gif"],
|
||||
[".hbc", "application/javascript"],
|
||||
[".html", "text/html"],
|
||||
[".jpeg", "image/jpeg"],
|
||||
[".jpg", "image/jpeg"],
|
||||
[".js", "application/javascript"],
|
||||
[".json", "application/json"],
|
||||
[".m4a", "audio/mp4"],
|
||||
[".mp3", "audio/mpeg"],
|
||||
[".mp4", "video/mp4"],
|
||||
[".otf", "font/otf"],
|
||||
[".pdf", "application/pdf"],
|
||||
[".png", "image/png"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".ttf", "font/ttf"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
[".wav", "audio/wav"],
|
||||
[".webm", "video/webm"],
|
||||
[".webp", "image/webp"],
|
||||
[".woff", "font/woff"],
|
||||
[".woff2", "font/woff2"],
|
||||
[".xml", "application/xml"],
|
||||
])
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* path: string
|
||||
* sha256: string
|
||||
* contentType: string
|
||||
* }} OtaAsset
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* bundle: string
|
||||
* assets?: unknown[]
|
||||
* }} ExportPlatformMetadata
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* fileMetadata?: Partial<Record<"ios" | "android", ExportPlatformMetadata>>
|
||||
* }} ExportMetadata
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* product: string
|
||||
* channel: string
|
||||
* releaseVersion: string
|
||||
* releaseKind: string
|
||||
* runtimeVersion: string
|
||||
* gitTag: string
|
||||
* gitCommit: string
|
||||
* publishedAt: string
|
||||
* policy: {
|
||||
* storeRequired: boolean
|
||||
* minSupportedBinaryVersion: string
|
||||
* message: string | null
|
||||
* }
|
||||
* metadata: ExportMetadata
|
||||
* resolveAsset: (assetPath: string) => Promise<OtaAsset>
|
||||
* }} BuildOtaMetadataInput
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {BuildOtaMetadataInput} input
|
||||
*/
|
||||
export async function buildOtaMetadata(input) {
|
||||
/** @type {Record<string, { launchAsset: OtaAsset, assets: OtaAsset[] }>} */
|
||||
const platforms = {}
|
||||
|
||||
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)
|
||||
|
||||
platforms[platform] = {
|
||||
launchAsset: await input.resolveAsset(launchAssetPath),
|
||||
assets: await Promise.all(assetPaths.map((assetPath) => input.resolveAsset(assetPath))),
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(platforms).length === 0) {
|
||||
throw new Error("Expo export metadata does not include any platform bundles")
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
product: input.product,
|
||||
channel: input.channel,
|
||||
releaseVersion: input.releaseVersion,
|
||||
releaseKind: input.releaseKind,
|
||||
runtimeVersion: input.runtimeVersion,
|
||||
publishedAt: input.publishedAt,
|
||||
git: {
|
||||
tag: input.gitTag,
|
||||
commit: input.gitCommit,
|
||||
},
|
||||
policy: input.policy,
|
||||
platforms,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildReleaseAssets(options = {}) {
|
||||
const projectDir = resolveMobileProjectDir(options.projectDir)
|
||||
const distDir = join(projectDir, "dist")
|
||||
const metadataPath = join(distDir, "metadata.json")
|
||||
|
||||
if (!existsSync(metadataPath)) {
|
||||
throw new Error(
|
||||
`Missing Expo export metadata at ${metadataPath}. Run "pnpm --dir apps/mobile run update:export" first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const packageJson = await readJson(join(projectDir, "package.json"))
|
||||
const product = process.env.OTA_PRODUCT ?? "mobile"
|
||||
const channel = process.env.OTA_CHANNEL ?? "production"
|
||||
const releaseVersion = process.env.OTA_RELEASE_VERSION ?? packageJson.version
|
||||
const releaseKind = process.env.OTA_RELEASE_KIND ?? "ota"
|
||||
const runtimeVersion = process.env.OTA_RUNTIME_VERSION ?? packageJson.version
|
||||
const gitTag = process.env.OTA_GIT_TAG ?? `${product}/v${releaseVersion}`
|
||||
const gitCommit = process.env.OTA_GIT_COMMIT ?? execGit(["rev-parse", "HEAD"], REPO_ROOT)
|
||||
const publishedAt = process.env.OTA_PUBLISHED_AT ?? new Date().toISOString()
|
||||
const metadata = await readJson(metadataPath)
|
||||
|
||||
const otaMetadata = await buildOtaMetadata({
|
||||
product,
|
||||
channel,
|
||||
releaseVersion,
|
||||
releaseKind,
|
||||
runtimeVersion,
|
||||
gitTag,
|
||||
gitCommit,
|
||||
publishedAt,
|
||||
policy: {
|
||||
storeRequired: parseBooleanEnv("OTA_STORE_REQUIRED", false),
|
||||
minSupportedBinaryVersion: process.env.OTA_MIN_SUPPORTED_BINARY_VERSION ?? runtimeVersion,
|
||||
message: parseNullableEnv("OTA_POLICY_MESSAGE"),
|
||||
},
|
||||
metadata,
|
||||
resolveAsset: async (assetPath) => {
|
||||
const assetFilePath = join(distDir, assetPath)
|
||||
let assetBuffer
|
||||
|
||||
try {
|
||||
assetBuffer = await readFile(assetFilePath)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to read exported asset "${assetPath}" at ${assetFilePath}: ${reason}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
path: assetPath,
|
||||
sha256: createHash("sha256").update(assetBuffer).digest("hex"),
|
||||
contentType: resolveContentType(assetPath),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const outputPath = join(distDir, "ota-release.json")
|
||||
const archivePath = join(projectDir, "dist.tar.zst")
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(otaMetadata, null, 2)}\n`, "utf8")
|
||||
await createTarZstArchive({ distDir, archivePath })
|
||||
|
||||
return {
|
||||
projectDir,
|
||||
distDir,
|
||||
metadataPath,
|
||||
outputPath,
|
||||
archivePath,
|
||||
otaMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const result = await buildReleaseAssets()
|
||||
|
||||
console.info(`Wrote OTA metadata: ${result.outputPath}`)
|
||||
console.info(`Wrote OTA archive: ${result.archivePath}`)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function collectAssetPaths(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const dedupedPaths = new Set()
|
||||
|
||||
for (const asset of value) {
|
||||
const assetPath = resolveAssetPath(asset)
|
||||
|
||||
dedupedPaths.add(normalizeAssetPath(assetPath))
|
||||
}
|
||||
|
||||
return [...dedupedPaths]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} asset
|
||||
*/
|
||||
function resolveAssetPath(asset) {
|
||||
if (typeof asset === "string") {
|
||||
return asset
|
||||
}
|
||||
|
||||
if (!asset || typeof asset !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
if ("path" in asset && typeof asset.path === "string") {
|
||||
return asset.path
|
||||
}
|
||||
|
||||
if ("file" in asset && typeof asset.file === "string") {
|
||||
return asset.file
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Expo asset metadata entry: ${JSON.stringify(asset)}`)
|
||||
}
|
||||
|
||||
function resolveMobileProjectDir(projectDir) {
|
||||
if (projectDir) {
|
||||
return resolve(projectDir)
|
||||
}
|
||||
|
||||
const cwd = resolve(process.cwd())
|
||||
|
||||
if (existsSync(join(cwd, "package.json")) && existsSync(join(cwd, "scripts", "expo-update.ts"))) {
|
||||
return cwd
|
||||
}
|
||||
|
||||
if (existsSync(join(cwd, "apps", "mobile", "package.json"))) {
|
||||
return join(cwd, "apps", "mobile")
|
||||
}
|
||||
|
||||
return DEFAULT_MOBILE_PROJECT_DIR
|
||||
}
|
||||
|
||||
function normalizeAssetPath(assetPath) {
|
||||
const normalized = assetPath
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/^(\.\/)+/, "")
|
||||
.replaceAll(/\/{2,}/g, "/")
|
||||
|
||||
if (!normalized || normalized.split("/").includes("..")) {
|
||||
throw new Error(`Invalid exported asset path "${assetPath}"`)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function resolveContentType(assetPath) {
|
||||
const extension = assetPath.includes(".") ? assetPath.slice(assetPath.lastIndexOf(".")) : ""
|
||||
|
||||
return CONTENT_TYPES.get(extension.toLowerCase()) ?? "application/octet-stream"
|
||||
}
|
||||
|
||||
async function createTarZstArchive({ distDir, archivePath }) {
|
||||
const zstdBinary = resolveZstdBinary()
|
||||
|
||||
await new Promise((resolvePromise, rejectPromise) => {
|
||||
let tarExited = false
|
||||
let zstdExited = false
|
||||
|
||||
const tarProcess = spawn("tar", ["-cf", "-", "-C", distDir, "."], {
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
})
|
||||
const zstdProcess = spawn(zstdBinary, ["-q", "-f", "-o", archivePath], {
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
})
|
||||
|
||||
const rejectOnce = once((error) => rejectPromise(error))
|
||||
const resolveIfComplete = () => {
|
||||
if (tarExited && zstdExited) {
|
||||
resolvePromise(void 0)
|
||||
}
|
||||
}
|
||||
|
||||
tarProcess.on("error", (error) => {
|
||||
rejectOnce(new Error(`Failed to run tar: ${error.message}`))
|
||||
})
|
||||
zstdProcess.on("error", (error) => {
|
||||
rejectOnce(new Error(`Failed to run zstd: ${error.message}`))
|
||||
})
|
||||
|
||||
tarProcess.stdout.on("error", (error) => {
|
||||
rejectOnce(new Error(`Failed to stream tar output: ${error.message}`))
|
||||
})
|
||||
zstdProcess.stdin.on("error", (error) => {
|
||||
rejectOnce(new Error(`Failed to stream zstd input: ${error.message}`))
|
||||
})
|
||||
|
||||
tarProcess.stdout.pipe(zstdProcess.stdin)
|
||||
|
||||
tarProcess.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
rejectOnce(new Error(`tar exited with code ${code}`))
|
||||
return
|
||||
}
|
||||
|
||||
tarExited = true
|
||||
resolveIfComplete()
|
||||
})
|
||||
|
||||
zstdProcess.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
rejectOnce(new Error(`zstd exited with code ${code}`))
|
||||
return
|
||||
}
|
||||
|
||||
zstdExited = true
|
||||
resolveIfComplete()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolveZstdBinary() {
|
||||
const candidates = [process.env.ZSTD_BIN, "zstd", "/opt/homebrew/bin/zstd"].filter(Boolean)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const args = candidate === "/opt/homebrew/bin/zstd" ? ["--version"] : ["--version"]
|
||||
const result = spawnSync(candidate, args, { stdio: "ignore" })
|
||||
|
||||
if (result.status === 0) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to find "zstd". Set ZSTD_BIN or install zstd in PATH.')
|
||||
}
|
||||
|
||||
function execGit(args, cwd) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr?.trim() || `git ${args.join(" ")} failed`)
|
||||
}
|
||||
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, "utf8"))
|
||||
}
|
||||
|
||||
function parseBooleanEnv(name, defaultValue) {
|
||||
const value = process.env[name]
|
||||
|
||||
if (value == null) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if (value === "1" || value.toLowerCase() === "true") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (value === "0" || value.toLowerCase() === "false") {
|
||||
return false
|
||||
}
|
||||
|
||||
throw new Error(`${name} must be "true", "false", "1", or "0"`)
|
||||
}
|
||||
|
||||
function parseNullableEnv(name) {
|
||||
const value = process.env[name]
|
||||
|
||||
if (value == null || value.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function once(callback) {
|
||||
let called = false
|
||||
|
||||
return (...args) => {
|
||||
if (called) {
|
||||
return
|
||||
}
|
||||
|
||||
called = true
|
||||
callback(...args)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
await main()
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("buildOtaMetadata", () => {
|
||||
it("preserves releaseVersion, runtimeVersion, and scoped git tag", 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: [],
|
||||
},
|
||||
android: {
|
||||
bundle: "_expo/static/js/android/main.hbc",
|
||||
assets: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
resolveAsset: async (assetPath: string) => ({
|
||||
path: assetPath,
|
||||
sha256: `${assetPath}-sha256`.padEnd(64, "0").slice(0, 64),
|
||||
contentType: "application/octet-stream",
|
||||
}),
|
||||
})
|
||||
|
||||
expect(result.releaseVersion).toBe("0.4.2")
|
||||
expect(result.runtimeVersion).toBe("0.4.1")
|
||||
expect(result.git.tag).toBe("mobile/v0.4.2")
|
||||
})
|
||||
})
|
||||
|
|
@ -21,6 +21,8 @@
|
|||
"start:clean": "expo start --dev-client --clear",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"update": "tsx scripts/expo-update.ts",
|
||||
"update:bundle": "node ../../.github/scripts/build-ota-release.mjs",
|
||||
"update:export": "expo export -p ios -p android --output-dir dist",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,52 +1,41 @@
|
|||
import { spawn } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import fsp from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { getConfig } from "@expo/config"
|
||||
import { dirname, join } from "pathe"
|
||||
|
||||
import { version } from "../package.json"
|
||||
async function spawnAsync(command: string, args: string[], cwd: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
})
|
||||
|
||||
async function spawnAsync(command: string, args: string[]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: "inherit", shell: true })
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(void 0)
|
||||
} else {
|
||||
reject(new Error(`Command failed with exit code ${code}`))
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
reject(new Error(`Command failed with exit code ${code}: ${command} ${args.join(" ")}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await spawnAsync("expo", ["export", "-p", "ios", "-p", "android"])
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectDir = join(__dirname, "..")
|
||||
const bundleScriptPath = join(
|
||||
projectDir,
|
||||
"..",
|
||||
"..",
|
||||
".github",
|
||||
"scripts",
|
||||
"build-ota-release.mjs",
|
||||
)
|
||||
|
||||
const expoUpdatesServerRoot = join(projectDir, "../", "../", "../", "expo-updates-server")
|
||||
const timestamp = Date.now()
|
||||
const updatesFolder = join(expoUpdatesServerRoot, "updates", version, `${timestamp}`)
|
||||
|
||||
if (!fs.existsSync(updatesFolder)) {
|
||||
fs.mkdirSync(updatesFolder, { recursive: true })
|
||||
}
|
||||
|
||||
// cp dist to expo-updates-server
|
||||
const distFolder = join(projectDir, "dist")
|
||||
await fsp.cp(distFolder, updatesFolder, { recursive: true })
|
||||
|
||||
const { exp } = getConfig(projectDir, {
|
||||
skipSDKVersionRequirement: true,
|
||||
isPublicConfig: true,
|
||||
})
|
||||
|
||||
const expoConfig = JSON.stringify(exp, null, 2)
|
||||
const expoConfigPath = join(updatesFolder, "expoConfig.json")
|
||||
fs.writeFileSync(expoConfigPath, expoConfig)
|
||||
await spawnAsync("pnpm", ["run", "update:export"], projectDir)
|
||||
await spawnAsync(process.execPath, [bundleScriptPath], projectDir)
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
|
|||
Loading…
Reference in New Issue