feat(ota): generate release assets from mobile export

This commit is contained in:
DIYgod 2026-04-10 21:25:34 +08:00
parent d6258b94e2
commit bda7c3a0a8
4 changed files with 496 additions and 32 deletions

429
.github/scripts/build-ota-release.mjs vendored Normal file
View File

@ -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()
}

View File

@ -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")
})
})

View File

@ -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": {

53
apps/mobile/scripts/expo-update.ts Executable file → Normal file
View File

@ -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()