511 lines
18 KiB
Kotlin
511 lines
18 KiB
Kotlin
/*
|
|
* Copyright (C) 2024 OpenAni and contributors.
|
|
*
|
|
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
|
|
* Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link.
|
|
*
|
|
* https://github.com/open-ani/ani/blob/main/LICENSE
|
|
*/
|
|
|
|
@file:Suppress("UnstableApiUsage")
|
|
|
|
import io.ktor.client.HttpClient
|
|
import io.ktor.client.plugins.ClientRequestException
|
|
import io.ktor.client.request.header
|
|
import io.ktor.client.request.parameter
|
|
import io.ktor.client.request.post
|
|
import io.ktor.client.request.setBody
|
|
import io.ktor.http.ContentType
|
|
import io.ktor.http.content.OutgoingContent
|
|
import io.ktor.http.contentType
|
|
import io.ktor.util.cio.readChannel
|
|
import io.ktor.utils.io.ByteReadChannel
|
|
import kotlinx.coroutines.runBlocking
|
|
import org.apache.tools.ant.taskdefs.condition.Os
|
|
import org.gradle.internal.impldep.com.amazonaws.auth.AWSStaticCredentialsProvider
|
|
import org.gradle.internal.impldep.com.amazonaws.auth.BasicAWSCredentials
|
|
import org.gradle.internal.impldep.com.amazonaws.client.builder.AwsClientBuilder
|
|
import org.gradle.internal.impldep.com.amazonaws.services.s3.AmazonS3ClientBuilder
|
|
import org.gradle.internal.impldep.com.amazonaws.services.s3.model.ObjectMetadata
|
|
import org.gradle.internal.impldep.com.amazonaws.services.s3.model.PutObjectRequest
|
|
import java.security.MessageDigest
|
|
|
|
plugins {
|
|
kotlin("jvm")
|
|
id("org.jetbrains.kotlinx.atomicfu")
|
|
}
|
|
|
|
val hostOS: OS by lazy {
|
|
when {
|
|
Os.isFamily(Os.FAMILY_WINDOWS) -> OS.WINDOWS
|
|
Os.isFamily(Os.FAMILY_MAC) -> OS.MACOS
|
|
Os.isFamily(Os.FAMILY_UNIX) -> OS.LINUX
|
|
else -> error("Unsupported OS: ${System.getProperty("os.name")}")
|
|
}
|
|
}
|
|
|
|
val hostArch: String by lazy {
|
|
when (val arch = System.getProperty("os.arch")) {
|
|
"x86_64" -> "x86_64"
|
|
"amd64" -> "x86_64"
|
|
"arm64" -> "aarch64"
|
|
"aarch64" -> "aarch64"
|
|
else -> error("Unsupported host architecture: $arch")
|
|
}
|
|
}
|
|
|
|
|
|
enum class OS(
|
|
val isUnix: Boolean,
|
|
) {
|
|
WINDOWS(false),
|
|
MACOS(true),
|
|
LINUX(true),
|
|
}
|
|
|
|
|
|
val namer = ArtifactNamer()
|
|
|
|
class ArtifactNamer {
|
|
private val APP_NAME = "ani"
|
|
|
|
fun getFullVersionFromTag(tag: String): String {
|
|
return tag.substringAfter("v")
|
|
}
|
|
|
|
// fullVersion example: 2.0.0-beta03
|
|
fun androidApp(fullVersion: String, arch: String): String {
|
|
return "$APP_NAME-$fullVersion-$arch.apk"
|
|
}
|
|
|
|
fun androidAppQR(fullVersion: String, arch: String, server: String): String {
|
|
return "${androidApp(fullVersion, arch)}.$server.qrcode.png"
|
|
}
|
|
|
|
// Ani-2.0.0-beta03-macos-amd64.dmg
|
|
// Ani-2.0.0-beta03-macos-arm64.dmg
|
|
// Ani-2.0.0-beta03-windows-amd64.msi
|
|
// Ani-2.0.0-beta03-debian-amd64.deb
|
|
// Ani-2.0.0-beta03-redhat-amd64.rpm
|
|
fun desktopDistributionFile(
|
|
fullVersion: String,
|
|
osName: String,
|
|
archName: String = hostArch,
|
|
extension: String
|
|
): String {
|
|
return "$APP_NAME-$fullVersion-$osName-$archName.$extension"
|
|
}
|
|
|
|
fun server(fullVersion: String, extension: String): String {
|
|
return "$APP_NAME-server-$fullVersion.$extension"
|
|
}
|
|
}
|
|
|
|
tasks.register("uploadAndroidApk") {
|
|
val buildDirectory = project(":app:android").layout.buildDirectory
|
|
doLast {
|
|
ReleaseEnvironment().run {
|
|
val files = buildDirectory.file("outputs/apk/release")
|
|
.get().asFile.walk()
|
|
.filter { it.extension == "apk" && it.name.contains("release") }
|
|
|
|
for (file in files) {
|
|
// android-arm64-v8a-release.apk
|
|
val arch = file.name.substringAfter("android-")
|
|
.substringBefore("-release.apk")
|
|
uploadReleaseAsset(
|
|
name = namer.androidApp(fullVersion, arch),
|
|
contentType = "application/vnd.android.package-archive",
|
|
file = file,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.register("uploadAndroidApkQR") {
|
|
doLast {
|
|
ReleaseEnvironment().run {
|
|
uploadReleaseAsset(
|
|
name = namer.androidAppQR(fullVersion, "universal", "github"),
|
|
contentType = "image/png",
|
|
file = rootProject.file("apk-qrcode-github.png"),
|
|
)
|
|
uploadReleaseAsset(
|
|
name = namer.androidAppQR(fullVersion, "universal", "cloudflare"),
|
|
contentType = "image/png",
|
|
file = rootProject.file("apk-qrcode-cloudflare.png"),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
val zipDesktopDistribution = tasks.register("zipDesktopDistribution", Zip::class) {
|
|
dependsOn(
|
|
":app:desktop:createReleaseDistributable",
|
|
)
|
|
from(project(":app:desktop").layout.buildDirectory.dir("compose/binaries/main-release/app"))
|
|
// ani-3.0.0-beta22-dev7.zip
|
|
archiveBaseName.set("ani")
|
|
archiveVersion.set(ReleaseEnvironment().fullVersion)
|
|
destinationDirectory.set(layout.buildDirectory.dir("distributions"))
|
|
archiveExtension.set("zip")
|
|
}
|
|
|
|
tasks.register("uploadDesktopInstallers") {
|
|
dependsOn(zipDesktopDistribution)
|
|
|
|
if (hostOS != OS.WINDOWS) {
|
|
dependsOn(
|
|
":app:desktop:packageReleaseDistributionForCurrentOS",
|
|
)
|
|
}
|
|
|
|
doLast {
|
|
ReleaseEnvironment().uploadDesktopDistributions()
|
|
}
|
|
}
|
|
|
|
//tasks.register("uploadServerDistribution") {
|
|
// dependsOn(
|
|
// ":server:distZip",
|
|
// ":server:distTar",
|
|
// )
|
|
//
|
|
// doLast {
|
|
// val distZip = project(":server").tasks.getByName("distZip", Zip::class).archiveFile.get().asFile
|
|
// val distTar = project(":server").tasks.getByName("distTar", Tar::class).archiveFile.get().asFile
|
|
//
|
|
// ReleaseEnvironment().run {
|
|
// uploadReleaseAsset(namer.server(fullVersion, "tar"), "application/x-tar", distTar)
|
|
// uploadReleaseAsset(namer.server(fullVersion, "zip"), "application/zip", distZip)
|
|
// }
|
|
// }
|
|
//}
|
|
|
|
tasks.register("prepareArtifactsForManualUpload") {
|
|
dependsOn(
|
|
":app:desktop:createReleaseDistributable",
|
|
":app:desktop:packageReleaseDistributionForCurrentOS",
|
|
)
|
|
dependsOn(zipDesktopDistribution)
|
|
|
|
doLast {
|
|
val distributionDir = project.layout.buildDirectory.dir("distribution").get().asFile.apply { mkdirs() }
|
|
|
|
object : ReleaseEnvironment() {
|
|
override val fullVersion: String = project.version.toString()
|
|
override fun uploadReleaseAsset(name: String, contentType: String, file: File) {
|
|
val target = distributionDir.resolve(name)
|
|
target.delete()
|
|
file.copyTo(target)
|
|
println("File written: ${target.absoluteFile}")
|
|
}
|
|
}.run {
|
|
uploadDesktopDistributions()
|
|
uploadReleaseAsset(
|
|
name = namer.desktopDistributionFile(
|
|
fullVersion,
|
|
osName = hostOS.name.lowercase(),
|
|
extension = "zip",
|
|
),
|
|
contentType = "application/octet-stream",
|
|
file = zipDesktopDistribution.get().archiveFile.get().asFile,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// do not use `object`, compiler bug
|
|
open class ReleaseEnvironment {
|
|
private fun getProperty(name: String) =
|
|
System.getProperty(name)
|
|
?: System.getenv(name)
|
|
?: properties[name]?.toString()
|
|
?: getLocalProperty(name)
|
|
?: ext.get(name).toString()
|
|
|
|
// K2 IDE can't resolve it if it's top-level
|
|
private fun findProperty(name: String) =
|
|
System.getProperty(name)
|
|
?: System.getenv(name)
|
|
?: properties[name]?.toString()
|
|
?: getLocalProperty(name)
|
|
?: runCatching { ext.get(name) }.getOrNull()?.toString()
|
|
|
|
private val tag: String by lazy {
|
|
(findProperty("CI_TAG") ?: "3.0.0-dev").also { println("tag = $it") }
|
|
}
|
|
private val branch by lazy {
|
|
getProperty("GITHUB_REF").substringAfterLast("/").also { println("branch = $it") }
|
|
}
|
|
private val shaShort by lazy {
|
|
getProperty("GITHUB_SHA").take(8).also { println("shaShort = $it") }
|
|
}
|
|
open val fullVersion by lazy {
|
|
namer.getFullVersionFromTag(tag).also { println("fullVersion = $it") }
|
|
}
|
|
val releaseId by lazy {
|
|
getProperty("CI_RELEASE_ID").also { println("releaseId = $it") }
|
|
}
|
|
val repository by lazy {
|
|
getProperty("GITHUB_REPOSITORY").also { println("repository = $it") }
|
|
}
|
|
val token by lazy {
|
|
getProperty("GITHUB_TOKEN").also { println("token = ${it.isNotEmpty()}") }
|
|
}
|
|
|
|
open fun uploadReleaseAsset(
|
|
name: String,
|
|
contentType: String,
|
|
file: File,
|
|
) {
|
|
check(file.exists()) { "File '${file.absolutePath}' does not exist when attempting to upload '$name'." }
|
|
val tag = getProperty("CI_TAG")
|
|
val fullVersion = namer.getFullVersionFromTag(tag)
|
|
val releaseId = getProperty("CI_RELEASE_ID")
|
|
val repository = getProperty("GITHUB_REPOSITORY")
|
|
val token = getProperty("GITHUB_TOKEN")
|
|
println("tag = $tag")
|
|
return uploadReleaseAsset(repository, releaseId, token, fullVersion, name, contentType, file)
|
|
}
|
|
|
|
private val s3Client by lazy {
|
|
AmazonS3ClientBuilder
|
|
.standard()
|
|
.withCredentials(
|
|
AWSStaticCredentialsProvider(
|
|
BasicAWSCredentials(
|
|
getProperty("AWS_ACCESS_KEY_ID"),
|
|
getProperty("AWS_SECRET_ACCESS_KEY"),
|
|
),
|
|
),
|
|
)
|
|
.apply {
|
|
setEndpointConfiguration(
|
|
AwsClientBuilder.EndpointConfiguration(
|
|
getProperty("AWS_BASEURL"),
|
|
getProperty("AWS_REGION"),
|
|
),
|
|
)
|
|
}
|
|
.build()
|
|
}
|
|
|
|
fun uploadReleaseAsset(
|
|
repository: String,
|
|
releaseId: String,
|
|
token: String,
|
|
fullVersion: String,
|
|
|
|
name: String,
|
|
contentType: String,
|
|
file: File,
|
|
) {
|
|
println("fullVersion = $fullVersion")
|
|
println("releaseId = $releaseId")
|
|
println("token = ${token.isNotEmpty()}")
|
|
println("repository = $repository")
|
|
|
|
// Compute the SHA-1 for the file
|
|
val sha1Checksum = computeSha1Checksum(file)
|
|
val sha1FileName = "$name.sha1"
|
|
// Write the checksum to a temporary file
|
|
val sha1File = createTempSha1File(sha1Checksum, sha1FileName)
|
|
|
|
runBlocking {
|
|
HttpClient {
|
|
expectSuccess = true
|
|
}.use { client ->
|
|
// 1) Upload the main file to GitHub
|
|
uploadFileToGitHub(client, repository, releaseId, token, file, name, ContentType.parse(contentType))
|
|
|
|
// 2) Upload the SHA-1 file to GitHub
|
|
uploadFileToGitHub(client, repository, releaseId, token, sha1File, sha1FileName, ContentType.Text.Plain)
|
|
|
|
// Optionally upload to S3
|
|
if (getProperty("UPLOAD_TO_S3") == "true") {
|
|
putS3Object(name, file, contentType)
|
|
// Upload the sha1 file
|
|
putS3Object(sha1FileName, sha1File, "text/plain")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload a file as a release asset to the specified GitHub repository/release.
|
|
*
|
|
* @param client An instance of [HttpClient] you manage (preferably use `.use { }`).
|
|
* @param repository The GitHub repository in "owner/repo" format (e.g. "open-ani/animeko").
|
|
* @param releaseId The numeric ID of the release (not the tag name).
|
|
* @param token Your GitHub personal access token (PAT) with the "repo" scope.
|
|
* @param file The [File] to upload.
|
|
* @param fileName How the file will appear on the release page.
|
|
* @param contentType The MIME type (e.g. "application/vnd.android.package-archive" for `.apk`).
|
|
*
|
|
* @return `true` if the upload was successful; `false` if the server returned a 422
|
|
* indicating the asset already exists.
|
|
*/
|
|
suspend fun uploadFileToGitHub(
|
|
client: HttpClient,
|
|
repository: String,
|
|
releaseId: String,
|
|
token: String,
|
|
file: File,
|
|
fileName: String,
|
|
contentType: ContentType
|
|
): Boolean {
|
|
return try {
|
|
client.post("https://uploads.github.com/repos/$repository/releases/$releaseId/assets") {
|
|
header("Authorization", "Bearer $token")
|
|
header("Accept", "application/vnd.github+json")
|
|
parameter("name", fileName)
|
|
|
|
contentType(contentType)
|
|
setBody(
|
|
object : OutgoingContent.ReadChannelContent() {
|
|
override val contentType: ContentType
|
|
get() = contentType
|
|
override val contentLength: Long
|
|
get() = file.length()
|
|
|
|
override fun readFrom(): ByteReadChannel = file.readChannel()
|
|
},
|
|
)
|
|
}
|
|
|
|
// If we made it here, the upload is successful
|
|
true
|
|
} catch (e: ClientRequestException) {
|
|
// Check for the 422 "already exists" error
|
|
if (e.response.status.value == 422) {
|
|
println("Asset already exists: $fileName")
|
|
false
|
|
} else {
|
|
// Propagate other errors
|
|
throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun putS3Object(name: String, file: File, contentType: String) {
|
|
val request = PutObjectRequest(getProperty("AWS_BUCKET"), "$tag/$name", file).apply {
|
|
this.metadata = ObjectMetadata().apply {
|
|
this.contentType = contentType
|
|
}
|
|
}
|
|
s3Client.putObject(request)
|
|
}
|
|
|
|
/**
|
|
* Computes the SHA-1 checksum of the given file.
|
|
*/
|
|
private fun computeSha1Checksum(file: File): String {
|
|
val digest = MessageDigest.getInstance("SHA-1")
|
|
file.inputStream().use { input ->
|
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
var bytesRead = input.read(buffer)
|
|
while (bytesRead != -1) {
|
|
digest.update(buffer, 0, bytesRead)
|
|
bytesRead = input.read(buffer)
|
|
}
|
|
}
|
|
// Convert the byte array to a hex string
|
|
return digest.digest().joinToString(separator = "") { "%02x".format(it) }
|
|
}
|
|
|
|
/**
|
|
* Writes the given SHA-1 checksum to a temporary file.
|
|
*/
|
|
private fun createTempSha1File(sha1Checksum: String, sha1FileName: String): File {
|
|
// You can adjust the directory for the temp file if needed
|
|
val tempFile = File.createTempFile(sha1FileName, null)
|
|
tempFile.writeText(sha1Checksum)
|
|
return tempFile
|
|
}
|
|
|
|
fun generateDevVersionName(
|
|
base: String,
|
|
): String {
|
|
return "$base-${branch}-${shaShort}"
|
|
}
|
|
|
|
fun generateReleaseVersionName(): String = tag.removePrefix("v")
|
|
}
|
|
|
|
fun ReleaseEnvironment.uploadDesktopDistributions() {
|
|
fun uploadBinary(
|
|
kind: String,
|
|
|
|
osName: String,
|
|
archName: String = hostArch,
|
|
) {
|
|
uploadReleaseAsset(
|
|
name = namer.desktopDistributionFile(
|
|
fullVersion,
|
|
osName,
|
|
archName,
|
|
extension = kind,
|
|
),
|
|
contentType = "application/octet-stream",
|
|
file = project(":app:desktop").layout.buildDirectory.dir("compose/binaries/main-release/$kind").get().asFile
|
|
.walk()
|
|
.single { it.extension == kind },
|
|
)
|
|
}
|
|
// installers
|
|
when (hostOS) {
|
|
OS.WINDOWS -> {
|
|
uploadReleaseAsset(
|
|
name = namer.desktopDistributionFile(
|
|
fullVersion,
|
|
osName = hostOS.name.lowercase(),
|
|
extension = "zip",
|
|
),
|
|
contentType = "application/x-zip",
|
|
file = layout.buildDirectory.dir("distributions").get().asFile.walk().single { it.extension == "zip" },
|
|
)
|
|
}
|
|
|
|
OS.MACOS -> {
|
|
uploadBinary("dmg", osName = "macos")
|
|
}
|
|
|
|
OS.LINUX -> {
|
|
uploadBinary("deb", osName = "debian")
|
|
uploadBinary("rpm", osName = "redhat")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ./gradlew updateDevVersionNameFromGit -DGITHUB_REF=refs/heads/master -DGITHUB_SHA=123456789 --no-configuration-cache
|
|
val gradleProperties = rootProject.file("gradle.properties")
|
|
tasks.register("updateDevVersionNameFromGit") {
|
|
doLast {
|
|
val properties = file(gradleProperties).readText()
|
|
val baseVersion =
|
|
(Regex("version.name=(.+)").find(properties)
|
|
?: error("Failed to find base version. Check version.name in gradle.properties"))
|
|
.groupValues[1]
|
|
.substringBefore("-")
|
|
val new = ReleaseEnvironment().generateDevVersionName(base = baseVersion)
|
|
println("New version name: $new")
|
|
file(gradleProperties).writeText(
|
|
properties.replaceFirst(Regex("version.name=(.+)"), "version.name=$new"),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ./gradlew updateReleaseVersionNameFromGit -DGITHUB_REF=refs/heads/master -DGITHUB_SHA=123456789 --no-configuration-cache
|
|
tasks.register("updateReleaseVersionNameFromGit") {
|
|
doLast {
|
|
val properties = file(gradleProperties).readText()
|
|
val new = ReleaseEnvironment().generateReleaseVersionName()
|
|
println("New version name: $new")
|
|
file(gradleProperties).writeText(
|
|
properties.replaceFirst(Regex("version.name=(.+)"), "version.name=$new"),
|
|
)
|
|
}
|
|
}
|