diff --git a/sdk-update/scripts/xuqm_release.gradle.kts b/sdk-update/scripts/xuqm_release.gradle.kts index e6d5ebb..4a154b5 100644 --- a/sdk-update/scripts/xuqm_release.gradle.kts +++ b/sdk-update/scripts/xuqm_release.gradle.kts @@ -55,9 +55,11 @@ fun loadXuqmConfig(projectDir: File): Properties { fun httpGet(url: String, token: String): String { val client = HttpClient.newHttpClient() - val req = HttpRequest.newBuilder(URI.create(url)) - .header("Authorization", "Bearer $token") - .GET().build() + 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() } @@ -92,11 +94,12 @@ fun httpMultipartPost(url: String, token: String, parts: Map): Stri val body = baos.toByteArray() val client = HttpClient.newHttpClient() - val req = HttpRequest.newBuilder(URI.create(url)) - .header("Authorization", "Bearer $token") + val builder = HttpRequest.newBuilder(URI.create(url)) .header("Content-Type", "multipart/form-data; boundary=$boundary") - .POST(HttpRequest.BodyPublishers.ofByteArray(body)) - .build() + 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() } @@ -105,6 +108,12 @@ fun parseJson(json: String, key: String): String? = 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 = + value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() + fun promptLine(message: String): String? { val console = System.console() ?: return null print(message) @@ -124,16 +133,55 @@ fun promptYesNo(message: String, default: Boolean = false): Boolean { fun promptReleaseMode(): String { val answer = promptLine( - "Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review]: " + "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 = 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") { @@ -148,18 +196,54 @@ tasks.register("xuqmRelease") { 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 storeTargets = cfg.getProperty("xuqm.storeTargets", "") // e.g. "HUAWEI,MI,OPPO" - val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean() - val publishImmediately = cfg.getProperty("xuqm.publishImmediately", "false").toBoolean() - var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "") - val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "") - var publishMode = cfg.getProperty("xuqm.publishMode", "").trim().uppercase() + 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") @@ -180,12 +264,34 @@ tasks.register("xuqmRelease") { .maxOrNull() ?: 0 println("[xuqm] Server latest versionCode: $serverVersionCode") - if (versionCode <= serverVersionCode) { - throw GradleException( - "[xuqm] Local versionCode ($versionCode) must be greater than server ($serverVersionCode). " + - "Please bump versionCode in build.gradle.kts before releasing." - ) + 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") @@ -193,21 +299,39 @@ tasks.register("xuqmRelease") { ?: 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( "appId" to appKey, "platform" to "ANDROID", - "versionName" to versionName, - "versionCode" to versionCode, - "forceUpdate" to "false", + "versionName" to releaseVersionName, + "versionCode" to releaseVersionCode, + "forceUpdate" to forceUpdate.toString(), "autoPublishAfterReview" to autoPublish.toString(), "apkFile" to apkFile, ) if (applicationId.isNotBlank()) parts["packageName"] = applicationId - if (storeTargets.isNotBlank()) parts["storeSubmitTargets"] = "[\"${storeTargets.split(",").joinToString("\",\"")}\"]" + 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) @@ -216,9 +340,9 @@ tasks.register("xuqmRelease") { println("[xuqm] Uploaded, version ID: $versionId") // ── 5. Trigger server-side store submission ──────────────────────── - if (storeTargets.isNotBlank()) { - println("[xuqm] Triggering store submission: $storeTargets ...") - val storeBody = """{"storeTypes":[${storeTargets.split(",").joinToString(",") { "\"$it\"" }}]}""" + 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") @@ -229,6 +353,18 @@ tasks.register("xuqmRelease") { 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()) {