mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-17 05:08:13 +08:00
feat(release): drive mobile release mode from config files
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
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 { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { dirname, extname, join, resolve } from "pathe"
|
||||
@@ -95,16 +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 = resolvePlatformMetadata(fileMetadata, platform)
|
||||
const launchAsset = { path: normalizeAssetPath(platformMetadata.bundle) }
|
||||
const assets = collectAssets(platformMetadata.assets, platform)
|
||||
if (input.releaseKind === "ota") {
|
||||
const fileMetadata = resolveExportFileMetadata(input.metadata)
|
||||
|
||||
platforms[platform] = {
|
||||
launchAsset: await input.resolveAsset(launchAsset),
|
||||
assets: await Promise.all(assets.map((asset) => input.resolveAsset(asset))),
|
||||
for (const platform of OTA_PLATFORMS) {
|
||||
const platformMetadata = resolvePlatformMetadata(fileMetadata, platform)
|
||||
const launchAsset = { path: normalizeAssetPath(platformMetadata.bundle) }
|
||||
const assets = collectAssets(platformMetadata.assets, platform)
|
||||
|
||||
platforms[platform] = {
|
||||
launchAsset: await input.resolveAsset(launchAsset),
|
||||
assets: await Promise.all(assets.map((asset) => input.resolveAsset(asset))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,12 +152,7 @@ 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 archivePath = join(projectDir, "dist.tar.zst")
|
||||
|
||||
const packageJson = await readJson(join(projectDir, "package.json"))
|
||||
const product = process.env.OTA_PRODUCT ?? "mobile"
|
||||
@@ -165,7 +163,14 @@ export async function buildReleaseAssets(options = {}) {
|
||||
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)
|
||||
|
||||
if (releaseKind === "ota" && !existsSync(metadataPath)) {
|
||||
throw new Error(
|
||||
`Missing Expo export metadata at ${metadataPath}. Run "pnpm --dir apps/mobile run update:export" first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const metadata = releaseKind === "ota" ? await readJson(metadataPath) : { fileMetadata: {} }
|
||||
|
||||
const otaMetadata = await buildOtaMetadata({
|
||||
product,
|
||||
@@ -204,17 +209,20 @@ export async function buildReleaseAssets(options = {}) {
|
||||
})
|
||||
|
||||
const outputPath = join(distDir, "ota-release.json")
|
||||
const archivePath = join(projectDir, "dist.tar.zst")
|
||||
|
||||
await mkdir(distDir, { recursive: true })
|
||||
await writeFile(outputPath, `${JSON.stringify(otaMetadata, null, 2)}\n`, "utf8")
|
||||
await createTarZstArchive({ distDir, archivePath })
|
||||
|
||||
if (releaseKind === "ota") {
|
||||
await createTarZstArchive({ distDir, archivePath })
|
||||
}
|
||||
|
||||
return {
|
||||
projectDir,
|
||||
distDir,
|
||||
metadataPath,
|
||||
outputPath,
|
||||
archivePath,
|
||||
archivePath: releaseKind === "ota" ? archivePath : null,
|
||||
otaMetadata,
|
||||
}
|
||||
}
|
||||
@@ -224,7 +232,9 @@ async function main() {
|
||||
const result = await buildReleaseAssets()
|
||||
|
||||
console.info(`Wrote OTA metadata: ${result.outputPath}`)
|
||||
console.info(`Wrote OTA archive: ${result.archivePath}`)
|
||||
if (result.archivePath) {
|
||||
console.info(`Wrote OTA archive: ${result.archivePath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -12,6 +12,11 @@ afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
)
|
||||
delete process.env.OTA_RELEASE_KIND
|
||||
delete process.env.OTA_RELEASE_VERSION
|
||||
delete process.env.OTA_RUNTIME_VERSION
|
||||
delete process.env.OTA_CHANNEL
|
||||
delete process.env.OTA_STORE_REQUIRED
|
||||
})
|
||||
|
||||
describe("buildOtaMetadata", () => {
|
||||
@@ -236,6 +241,32 @@ describe("buildOtaMetadata", () => {
|
||||
})
|
||||
|
||||
describe("buildReleaseAssets", () => {
|
||||
it("builds metadata-only assets for store releases without Expo export output", async () => {
|
||||
const { buildReleaseAssets } = await import("./build-ota-release.mjs")
|
||||
|
||||
const projectDir = await mkdtemp(join(tmpdir(), "build-ota-release-store-test-"))
|
||||
tempDirs.push(projectDir)
|
||||
|
||||
await writeFile(
|
||||
join(projectDir, "package.json"),
|
||||
JSON.stringify({ name: "@follow/mobile-test", version: "0.4.3" }),
|
||||
"utf8",
|
||||
)
|
||||
|
||||
process.env.OTA_RELEASE_KIND = "store"
|
||||
process.env.OTA_RELEASE_VERSION = "0.4.3"
|
||||
process.env.OTA_RUNTIME_VERSION = "0.4.3"
|
||||
process.env.OTA_CHANNEL = "production"
|
||||
process.env.OTA_STORE_REQUIRED = "true"
|
||||
|
||||
const result = await buildReleaseAssets({ projectDir })
|
||||
|
||||
expect(result.otaMetadata.releaseKind).toBe("store")
|
||||
expect(result.otaMetadata.policy.storeRequired).toBe(true)
|
||||
expect(result.otaMetadata.platforms).toEqual({})
|
||||
expect(result.archivePath).toBeNull()
|
||||
})
|
||||
|
||||
it("includes the JSON file path when export metadata cannot be parsed", async () => {
|
||||
const { buildReleaseAssets } = await import("./build-ota-release.mjs")
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { appendFileSync } from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/
|
||||
const VALID_MODES = new Set(["store", "ota", "store-policy"])
|
||||
const VALID_CHANNELS = new Set(["production", "preview"])
|
||||
|
||||
export function resolveMobileReleaseConfig(input) {
|
||||
const normalizedReleaseVersion = input.releaseVersion.replace(/^v/, "")
|
||||
const config = input.releaseConfig
|
||||
|
||||
if (config.version !== normalizedReleaseVersion) {
|
||||
throw new Error(
|
||||
`apps/mobile/release.json version ${config.version} does not match release version ${normalizedReleaseVersion}.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!VALID_MODES.has(config.mode)) {
|
||||
throw new Error("apps/mobile/release.json mode must be store, ota, or store-policy.")
|
||||
}
|
||||
|
||||
if (config.mode === "store") {
|
||||
return {
|
||||
triggerStoreBuilds: true,
|
||||
triggerOtaPublish: false,
|
||||
releaseKind: null,
|
||||
runtimeVersion: null,
|
||||
channel: null,
|
||||
storeRequired: null,
|
||||
policyMessage: null,
|
||||
releaseVersion: normalizedReleaseVersion,
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.runtimeVersion || !SEMVER_PATTERN.test(config.runtimeVersion)) {
|
||||
throw new Error("apps/mobile/release.json runtimeVersion must be a plain x.y.z version.")
|
||||
}
|
||||
|
||||
if (!config.channel || !VALID_CHANNELS.has(config.channel)) {
|
||||
throw new Error("apps/mobile/release.json channel must be production or preview.")
|
||||
}
|
||||
|
||||
return {
|
||||
triggerStoreBuilds: false,
|
||||
triggerOtaPublish: true,
|
||||
releaseKind: config.mode === "ota" ? "ota" : "store",
|
||||
runtimeVersion: config.runtimeVersion,
|
||||
channel: config.channel,
|
||||
storeRequired: config.mode === "store-policy" ? String(config.storeRequired ?? false) : "false",
|
||||
policyMessage: config.mode === "store-policy" ? (config.message ?? "") : "",
|
||||
releaseVersion: normalizedReleaseVersion,
|
||||
}
|
||||
}
|
||||
|
||||
async function readReleaseConfig(path) {
|
||||
const raw = await readFile(path, "utf8")
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function setGitHubOutput(key, value) {
|
||||
if (!process.env.GITHUB_OUTPUT) {
|
||||
return
|
||||
}
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const configPath = process.env.RELEASE_CONFIG_PATH ?? "apps/mobile/release.json"
|
||||
const releaseConfig = await readReleaseConfig(configPath)
|
||||
const result = resolveMobileReleaseConfig({
|
||||
releaseVersion: process.env.RELEASE_VERSION ?? "",
|
||||
releaseConfig,
|
||||
})
|
||||
|
||||
setGitHubOutput("trigger_store_builds", String(result.triggerStoreBuilds))
|
||||
setGitHubOutput("trigger_ota_publish", String(result.triggerOtaPublish))
|
||||
setGitHubOutput("release_kind", result.releaseKind ?? "")
|
||||
setGitHubOutput("runtime_version", result.runtimeVersion ?? "")
|
||||
setGitHubOutput("channel", result.channel ?? "")
|
||||
setGitHubOutput("store_required", result.storeRequired ?? "")
|
||||
setGitHubOutput("policy_message", result.policyMessage ?? "")
|
||||
setGitHubOutput("release_version", result.releaseVersion)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("resolveMobileReleaseConfig", () => {
|
||||
it("defaults to store builds when release.json mode is store", async () => {
|
||||
const { resolveMobileReleaseConfig } = await import("./resolve-mobile-release-config.mjs")
|
||||
|
||||
expect(
|
||||
resolveMobileReleaseConfig({
|
||||
releaseVersion: "v0.4.2",
|
||||
releaseConfig: {
|
||||
version: "0.4.2",
|
||||
mode: "store",
|
||||
runtimeVersion: null,
|
||||
channel: null,
|
||||
storeRequired: false,
|
||||
message: null,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
triggerStoreBuilds: true,
|
||||
triggerOtaPublish: false,
|
||||
releaseKind: null,
|
||||
runtimeVersion: null,
|
||||
channel: null,
|
||||
storeRequired: null,
|
||||
policyMessage: null,
|
||||
releaseVersion: "0.4.2",
|
||||
})
|
||||
})
|
||||
|
||||
it("triggers OTA publish only for ota mode", async () => {
|
||||
const { resolveMobileReleaseConfig } = await import("./resolve-mobile-release-config.mjs")
|
||||
|
||||
expect(
|
||||
resolveMobileReleaseConfig({
|
||||
releaseVersion: "v0.4.2",
|
||||
releaseConfig: {
|
||||
version: "0.4.2",
|
||||
mode: "ota",
|
||||
runtimeVersion: "0.4.1",
|
||||
channel: "production",
|
||||
storeRequired: false,
|
||||
message: null,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
triggerStoreBuilds: false,
|
||||
triggerOtaPublish: true,
|
||||
releaseKind: "ota",
|
||||
runtimeVersion: "0.4.1",
|
||||
channel: "production",
|
||||
storeRequired: "false",
|
||||
policyMessage: "",
|
||||
releaseVersion: "0.4.2",
|
||||
})
|
||||
})
|
||||
|
||||
it("triggers metadata-only publish for store-policy mode", async () => {
|
||||
const { resolveMobileReleaseConfig } = await import("./resolve-mobile-release-config.mjs")
|
||||
|
||||
expect(
|
||||
resolveMobileReleaseConfig({
|
||||
releaseVersion: "v0.4.3",
|
||||
releaseConfig: {
|
||||
version: "0.4.3",
|
||||
mode: "store-policy",
|
||||
runtimeVersion: "0.4.3",
|
||||
channel: "production",
|
||||
storeRequired: true,
|
||||
message: "Install 0.4.3 from the store.",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
triggerStoreBuilds: false,
|
||||
triggerOtaPublish: true,
|
||||
releaseKind: "store",
|
||||
runtimeVersion: "0.4.3",
|
||||
channel: "production",
|
||||
storeRequired: "true",
|
||||
policyMessage: "Install 0.4.3 from the store.",
|
||||
releaseVersion: "0.4.3",
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects a release config that does not match the release version", async () => {
|
||||
const { resolveMobileReleaseConfig } = await import("./resolve-mobile-release-config.mjs")
|
||||
|
||||
expect(() =>
|
||||
resolveMobileReleaseConfig({
|
||||
releaseVersion: "v0.4.4",
|
||||
releaseConfig: {
|
||||
version: "0.4.3",
|
||||
mode: "store",
|
||||
runtimeVersion: null,
|
||||
channel: null,
|
||||
storeRequired: false,
|
||||
message: null,
|
||||
},
|
||||
}),
|
||||
).toThrow(/does not match release version/i)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user