XuqmGroup-AndroidSDK/sdk-update/scripts/xuqm_release.gradle.kts
XuqmGroup 6f5ce42e50 docs(sdk): 添加 React Native SDK 文档和 Android/HarmonyOS 发版脚本
- 新增 XuqmGroup React Native SDK 使用文档,包含安装、初始化、HTTP客户端、IM模块、推送模块、版本管理等功能说明
- 添加 Android Gradle 发版任务脚本,支持构建发布 APK 并上传到更新服务
- 添加 HarmonyOS hvigorw 发版任务脚本,支持 HAP 包构建和上传功能
- 实现多平台版本检查、自动重连、灰度发布等发版流程自动化
- 集成商店提交、定时发布、Webhook 回调等发布后处理功能
2026-04-29 17:35:52 +08:00

390 行
20 KiB
Plaintext

/**
* XuqmGroup Update Service — Android Gradle Release Task
*
* Copy this file to your app module directory and apply it:
* apply(from = "xuqm_release.gradle.kts")
*
* Then run:
* ./gradlew xuqmRelease
*
* Config: xuqm.properties in the project root (or module root)
* ---
* xuqm.serverUrl=https://update.dev.xuqinmin.com
* xuqm.appKey=your-app-key
* xuqm.apiToken=your-api-token
* xuqm.storeTargets=HUAWEI,MI,OPPO # optional
* xuqm.autoPublishAfterReview=false
* xuqm.publishImmediately=false # optional: publish update-service record immediately
* xuqm.scheduledPublishAt= # optional ISO datetime
* xuqm.webhookUrl= # optional webhook for review status changes
* ---
*
* The task:
* 1. Reads versionName / versionCode from the android extension
* 2. Checks the latest version on the update server
* 3. Aborts if the local versionCode is not greater than the server's
* 4. Assembles the release APK (assembleRelease)
* 5. Uploads the APK + metadata to the update service as a DRAFT
* 6. Optionally triggers server-side store submission
*/
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.util.Properties
import java.util.UUID
// ── Config loading ────────────────────────────────────────────────────────
fun loadXuqmConfig(projectDir: File): Properties {
val props = Properties()
listOf(
File(projectDir, "xuqm.properties"),
File(projectDir.parentFile, "xuqm.properties"),
).firstOrNull { it.exists() }?.inputStream()?.use(props::load)
?: throw GradleException("xuqm.properties not found in module or project root")
return props
}
// ── HTTP helper ───────────────────────────────────────────────────────────
fun httpGet(url: String, token: String): String {
val client = HttpClient.newHttpClient()
val builder = HttpRequest.newBuilder(URI.create(url))
if (token.isNotBlank()) {
builder.header("Authorization", "Bearer $token")
}
val req = builder.GET().build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
/**
* Multipart POST — minimal implementation without external dependencies.
* Returns response body string.
*/
fun httpMultipartPost(url: String, token: String, parts: Map<String, Any>): String {
val boundary = UUID.randomUUID().toString()
val baos = java.io.ByteArrayOutputStream()
fun writeln(s: String) = baos.write(("$s\r\n").toByteArray())
for ((name, value) in parts) {
writeln("--$boundary")
when (value) {
is File -> {
writeln("Content-Disposition: form-data; name=\"$name\"; filename=\"${value.name}\"")
writeln("Content-Type: application/octet-stream")
writeln("")
baos.write(value.readBytes())
writeln("")
}
else -> {
writeln("Content-Disposition: form-data; name=\"$name\"")
writeln("")
writeln(value.toString())
}
}
}
writeln("--$boundary--")
val body = baos.toByteArray()
val client = HttpClient.newHttpClient()
val builder = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "multipart/form-data; boundary=$boundary")
if (token.isNotBlank()) {
builder.header("Authorization", "Bearer $token")
}
val req = builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
fun parseJson(json: String, key: String): String? =
Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"").find(json)?.groupValues?.get(1)
fun parseJsonInt(json: String, key: String): Int? =
Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull()
fun parseJsonBool(json: String, key: String, default: Boolean = false): Boolean =
Regex("\"$key\"\\s*:\\s*(true|false)").find(json)?.groupValues?.get(1)?.toBooleanStrictOrNull() ?: default
fun parseCsv(value: String?): List<String> =
value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
fun promptLine(message: String): String? {
val console = System.console() ?: return null
print(message)
return console.readLine()
}
fun promptYesNo(message: String, default: Boolean = false): Boolean {
val suffix = if (default) " [Y/n]: " else " [y/N]: "
val answer = promptLine(message + suffix)?.trim().orEmpty()
return when {
answer.isBlank() -> default
answer.equals("y", ignoreCase = true) || answer.equals("yes", ignoreCase = true) -> true
answer.equals("n", ignoreCase = true) || answer.equals("no", ignoreCase = true) -> false
else -> default
}
}
fun promptReleaseMode(): String {
val answer = promptLine(
"Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review, 5=dry-run]: "
)?.trim().orEmpty()
return when (answer) {
"2" -> "NOW"
"3" -> "SCHEDULED"
"4" -> "AUTO_REVIEW"
"5" -> "DRY_RUN"
else -> "MANUAL"
}
}
data class ReleaseDefaults(
val enabled: Boolean = true,
val defaultStoreTargets: List<String> = emptyList(),
val defaultPublishMode: String = "MANUAL",
val defaultPublishImmediately: Boolean = false,
val defaultScheduledPublishAt: String = "",
val defaultAutoPublishAfterReview: Boolean = false,
val defaultWebhookUrl: String = "",
val defaultForceUpdate: Boolean = false,
val defaultGrayEnabled: Boolean = false,
val defaultGrayPercent: Int = 0,
val defaultPackageName: String = "",
val defaultAppStoreUrl: String = "",
val defaultMarketUrl: String = "",
)
fun fetchReleaseDefaults(tenantUrl: String, appKey: String): ReleaseDefaults? {
if (tenantUrl.isBlank()) return null
val url = "$tenantUrl/api/sdk/config?appId=$appKey&platform=ANDROID"
val json = runCatching { httpGet(url, "") }.getOrNull() ?: return null
if (!parseJsonBool(json, "updateEnabled", false)) return ReleaseDefaults(enabled = false)
return ReleaseDefaults(
enabled = true,
defaultStoreTargets = parseCsv(parseJson(json, "updateDefaultStoreTargets")),
defaultPublishMode = parseJson(json, "updateDefaultPublishMode") ?: "MANUAL",
defaultPublishImmediately = parseJsonBool(json, "updateDefaultPublishImmediately"),
defaultScheduledPublishAt = parseJson(json, "updateDefaultScheduledPublishAt").orEmpty(),
defaultAutoPublishAfterReview = parseJsonBool(json, "updateDefaultAutoPublishAfterReview"),
defaultWebhookUrl = parseJson(json, "updateDefaultWebhookUrl").orEmpty(),
defaultForceUpdate = parseJsonBool(json, "updateDefaultForceUpdate"),
defaultGrayEnabled = parseJsonBool(json, "updateDefaultGrayEnabled"),
defaultGrayPercent = parseJsonInt(json, "updateDefaultGrayPercent") ?: 0,
defaultPackageName = parseJson(json, "updateDefaultPackageName").orEmpty(),
defaultAppStoreUrl = parseJson(json, "updateDefaultAppStoreUrl").orEmpty(),
defaultMarketUrl = parseJson(json, "updateDefaultMarketUrl").orEmpty(),
)
}
// ── Task registration ─────────────────────────────────────────────────────
tasks.register("xuqmRelease") {
group = "xuqm"
description = "Build release APK and upload to XuqmGroup Update Service"
// Ensure assembleRelease runs first
dependsOn("assembleRelease")
doLast {
val cfg = loadXuqmConfig(projectDir)
val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing")
val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing")
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
val tenantUrl = cfg.getProperty("xuqm.tenantUrl", "").trim()
val dryRunMode = cfg.getProperty("xuqm.dryRun", "false").toBoolean()
val tenantDefaults = fetchReleaseDefaults(tenantUrl, appKey)
val updateEnabled = tenantDefaults?.enabled ?: true
val defaultStoreTargets = tenantDefaults?.defaultStoreTargets?.joinToString(",").orEmpty()
val defaultPublishMode = tenantDefaults?.defaultPublishMode ?: "MANUAL"
val defaultPublishImmediately = tenantDefaults?.defaultPublishImmediately ?: false
val defaultScheduledAt = tenantDefaults?.defaultScheduledPublishAt.orEmpty()
val defaultAutoPublishAfterReview = tenantDefaults?.defaultAutoPublishAfterReview ?: false
val defaultWebhookUrl = tenantDefaults?.defaultWebhookUrl.orEmpty()
val defaultForceUpdate = tenantDefaults?.defaultForceUpdate ?: false
val defaultGrayEnabled = tenantDefaults?.defaultGrayEnabled ?: false
val defaultGrayPercent = tenantDefaults?.defaultGrayPercent ?: 0
var storeTargets = cfg.getProperty("xuqm.storeTargets", defaultStoreTargets)
var autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", defaultAutoPublishAfterReview.toString()).toBoolean()
var publishImmediately = cfg.getProperty("xuqm.publishImmediately", defaultPublishImmediately.toString()).toBoolean()
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", defaultScheduledAt)
var webhookUrl = cfg.getProperty("xuqm.webhookUrl", defaultWebhookUrl)
var publishMode = cfg.getProperty("xuqm.publishMode", defaultPublishMode).trim().uppercase()
val forceUpdate = cfg.getProperty("xuqm.forceUpdate", defaultForceUpdate.toString()).toBoolean()
val grayEnabled = cfg.getProperty("xuqm.grayEnabled", defaultGrayEnabled.toString()).toBoolean()
val grayPercent = cfg.getProperty("xuqm.grayPercent", defaultGrayPercent.toString()).toIntOrNull()?.coerceIn(1, 100) ?: defaultGrayPercent
val allowVersionMismatch = cfg.getProperty("xuqm.allowVersionMismatch", "false").toBoolean()
var dryRun = dryRunMode || publishMode == "DRY_RUN" || !updateEnabled
if (publishMode.isBlank() && !publishImmediately && scheduledAt.isBlank() && !autoPublish && System.console() != null) {
publishMode = promptReleaseMode()
if (publishMode == "SCHEDULED" && scheduledAt.isBlank()) {
scheduledAt = promptLine("Scheduled publish time (ISO datetime): ")?.trim().orEmpty()
}
}
if (publishMode == "DRY_RUN") {
dryRun = true
println("[xuqm] Dry-run mode enabled: release will stop after validation and packaging.")
}
if (scheduledAt.isNotBlank()) {
publishImmediately = false
autoPublish = false
}
if (publishMode == "NOW") {
publishImmediately = true
autoPublish = false
} else if (publishMode == "AUTO_REVIEW") {
autoPublish = true
publishImmediately = false
}
// ── 1. Read local version ──────────────────────────────────────────
val android = project.extensions.findByName("android")
?: throw GradleException("This task must run in an Android module")
val defaultConfig = android::class.java.getMethod("getDefaultConfig").invoke(android)
val versionName = defaultConfig::class.java.getMethod("getVersionName").invoke(defaultConfig) as? String
?: throw GradleException("versionName not set")
val versionCode = (defaultConfig::class.java.getMethod("getVersionCode").invoke(defaultConfig) as? Int)
?: throw GradleException("versionCode not set")
val applicationId = defaultConfig::class.java.getMethod("getApplicationId").invoke(defaultConfig) as? String ?: ""
println("[xuqm] Local version: $versionName ($versionCode), packageName: $applicationId, appKey: $appKey")
// ── 2. Check server latest ─────────────────────────────────────────
val listResp = httpGet("$serverUrl/api/v1/updates/app/list?appId=$appKey&platform=ANDROID", apiToken)
// Find highest published versionCode
val serverVersionCode = Regex("\"versionCode\"\\s*:\\s*(\\d+)").findAll(listResp)
.mapNotNull { it.groupValues[1].toIntOrNull() }
.maxOrNull() ?: 0
println("[xuqm] Server latest versionCode: $serverVersionCode")
var releaseVersionName = versionName
var releaseVersionCode = versionCode
if (releaseVersionCode <= serverVersionCode) {
if (System.console() != null && !allowVersionMismatch) {
println("[xuqm] Local version is not greater than server latest. Please enter corrected release version info.")
val inputVersionName = promptLine("Release versionName [$versionName]: ")?.trim().orEmpty()
val inputVersionCode = promptLine("Release versionCode [$versionCode]: ")?.trim().orEmpty()
if (inputVersionName.isNotBlank()) releaseVersionName = inputVersionName
if (inputVersionCode.isNotBlank()) releaseVersionCode = inputVersionCode.toInt()
}
if (releaseVersionCode <= serverVersionCode) {
throw GradleException(
"[xuqm] Release versionCode ($releaseVersionCode) must be greater than server ($serverVersionCode). " +
"Bump versionCode in build.gradle.kts or pass xuqm.allowVersionMismatch=true if you deliberately override."
)
}
}
if (releaseVersionName != versionName || releaseVersionCode != versionCode) {
println("[xuqm] Using release override: $releaseVersionName ($releaseVersionCode)")
}
if (storeTargets.isBlank() && System.console() != null && !dryRun) {
val inputTargets = promptLine("Store targets (comma separated, blank for none): ")?.trim().orEmpty()
if (inputTargets.isNotBlank()) {
storeTargets = inputTargets
}
}
val storeTargetList = parseCsv(storeTargets)
// ── 3. Locate APK ─────────────────────────────────────────────────
val apkDir = File(buildDir, "outputs/apk/release")
val apkFile = apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull()
?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?")
println("[xuqm] APK: ${apkFile.absolutePath}")
if (dryRun) {
println("[xuqm] Dry-run summary:")
println(" updateEnabled=$updateEnabled")
println(" releaseVersion=$releaseVersionName ($releaseVersionCode)")
println(" publishMode=$publishMode")
println(" publishImmediately=$publishImmediately")
println(" autoPublishAfterReview=$autoPublish")
println(" scheduledPublishAt=${scheduledAt.ifBlank { "-" }}")
println(" webhookUrl=${webhookUrl.ifBlank { "-" }}")
println(" forceUpdate=$forceUpdate")
println(" grayEnabled=$grayEnabled, grayPercent=$grayPercent")
println(" storeTargets=${storeTargetList.joinToString(",").ifBlank { "-" }}")
println(" packageName=${applicationId.ifBlank { "-" }}")
println("[xuqm] Dry-run completed. No upload performed.")
return@doLast
}
// ── 4. Upload to update service ───────────────────────────────────
val parts = mutableMapOf<String, Any>(
"appId" to appKey,
"platform" to "ANDROID",
"versionName" to releaseVersionName,
"versionCode" to releaseVersionCode,
"forceUpdate" to forceUpdate.toString(),
"autoPublishAfterReview" to autoPublish.toString(),
"apkFile" to apkFile,
)
if (applicationId.isNotBlank()) parts["packageName"] = applicationId
if (storeTargetList.isNotEmpty()) parts["storeSubmitTargets"] = "[\"${storeTargetList.joinToString("\",\"")}\"]"
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
if (publishMode == "NOW") parts["publishImmediately"] = "true"
if (publishMode == "AUTO_REVIEW") parts["autoPublishAfterReview"] = "true"
println("[xuqm] Uploading APK...")
val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
val versionId = parseJson(uploadResp, "id")
?: throw GradleException("[xuqm] Upload failed:\n$uploadResp")
println("[xuqm] Uploaded, version ID: $versionId")
// ── 5. Trigger server-side store submission ────────────────────────
if (storeTargetList.isNotEmpty()) {
println("[xuqm] Triggering store submission: ${storeTargetList.joinToString(",")} ...")
val storeBody = """{"storeTypes":[${storeTargetList.joinToString(",") { "\"$it\"" }}]}"""
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/store/app/$versionId/execute-submit"))
.header("Authorization", "Bearer $apiToken")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(storeBody))
.build()
val storeResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})")
}
if (grayEnabled && (publishImmediately || publishMode == "NOW")) {
val client = HttpClient.newHttpClient()
val grayBody = """{"enabled":true,"percent":$grayPercent}"""
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/gray"))
.header("Authorization", "Bearer $apiToken")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(grayBody))
.build()
val grayResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Gray release configured (HTTP ${grayResp.statusCode()})")
}
if (publishImmediately || publishMode == "NOW") {
println("[xuqm] Published immediately in update service.")
} else if (publishMode == "SCHEDULED" || scheduledAt.isNotBlank()) {
println("[xuqm] Will auto-publish at: ${scheduledAt.ifBlank { "(use update service scheduled publish config)" }}")
} else if (autoPublish) {
println("[xuqm] Will auto-publish after all store reviews pass.")
} else if (System.console() != null && promptYesNo("[xuqm] Publish now?", false)) {
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/publish"))
.header("Authorization", "Bearer $apiToken")
.POST(HttpRequest.BodyPublishers.noBody())
.build()
val publishResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Publish HTTP ${publishResp.statusCode()}")
}
println("[xuqm] Done. Version is in DRAFT state.")
if (!publishImmediately && publishMode != "NOW" && scheduledAt.isBlank() && !autoPublish) {
println("[xuqm] Publish manually: POST $serverUrl/api/v1/updates/app/$versionId/publish")
}
}
}