Files
coco 723ce1af5c a
2026-07-03 15:12:48 +08:00

1290 lines
44 KiB
Kotlin

#!/usr/bin/env 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
*/
// 也可以在 IDE 里右键 Run
@file:CompilerOptions("-Xmulti-dollar-interpolation", "-Xdont-warn-on-error-suppression")
@file:Suppress("UNSUPPORTED_FEATURE", "UNSUPPORTED")
@file:Repository("https://repo.maven.apache.org/maven2/")
@file:DependsOn("io.github.typesafegithub:github-workflows-kt:3.0.1")
@file:Repository("https://bindings.krzeminski.it")
// Build
@file:DependsOn("actions:checkout:v4")
@file:DependsOn("gmitch215:setup-java:6d2c5e1f82f180ae79f799f0ed6e3e5efb4e664d")
@file:DependsOn("org.jetbrains:annotations:23.0.0")
@file:DependsOn("actions:github-script:v7")
@file:DependsOn("gradle:actions__setup-gradle:v3")
@file:DependsOn("nick-fields:retry:v2")
@file:DependsOn("timheuer:base64-to-file:v1.1")
@file:DependsOn("actions:upload-artifact:v4")
@file:DependsOn("actions:download-artifact:v4")
// Release
@file:DependsOn("dawidd6:action-get-tag:v1")
@file:DependsOn("bhowell2:github-substring-action:v1.0.0")
@file:DependsOn("softprops:action-gh-release:v1")
@file:DependsOn("snow-actions:qrcode:v1.0.0")
import Secrets.AWS_ACCESS_KEY_ID
import Secrets.AWS_BASEURL
import Secrets.AWS_BUCKET
import Secrets.AWS_REGION
import Secrets.AWS_SECRET_ACCESS_KEY
import Secrets.GITHUB_REPOSITORY
import Secrets.SIGNING_RELEASE_KEYALIAS
import Secrets.SIGNING_RELEASE_KEYPASSWORD
import Secrets.SIGNING_RELEASE_STOREFILE
import Secrets.SIGNING_RELEASE_STOREPASSWORD
import io.github.typesafegithub.workflows.actions.actions.Checkout
import io.github.typesafegithub.workflows.actions.actions.DownloadArtifact
import io.github.typesafegithub.workflows.actions.actions.GithubScript
import io.github.typesafegithub.workflows.actions.actions.UploadArtifact
import io.github.typesafegithub.workflows.actions.bhowell2.GithubSubstringAction_Untyped
import io.github.typesafegithub.workflows.actions.dawidd6.ActionGetTag_Untyped
import io.github.typesafegithub.workflows.actions.gmitch215.SetupJava_Untyped
import io.github.typesafegithub.workflows.actions.gradle.ActionsSetupGradle
import io.github.typesafegithub.workflows.actions.nickfields.Retry_Untyped
import io.github.typesafegithub.workflows.actions.snowactions.Qrcode_Untyped
import io.github.typesafegithub.workflows.actions.softprops.ActionGhRelease
import io.github.typesafegithub.workflows.actions.timheuer.Base64ToFile_Untyped
import io.github.typesafegithub.workflows.domain.AbstractResult
import io.github.typesafegithub.workflows.domain.ActionStep
import io.github.typesafegithub.workflows.domain.CommandStep
import io.github.typesafegithub.workflows.domain.Job
import io.github.typesafegithub.workflows.domain.JobOutputs
import io.github.typesafegithub.workflows.domain.Mode
import io.github.typesafegithub.workflows.domain.Permission
import io.github.typesafegithub.workflows.domain.RunnerType
import io.github.typesafegithub.workflows.domain.Shell
import io.github.typesafegithub.workflows.domain.Step
import io.github.typesafegithub.workflows.domain.triggers.PullRequest
import io.github.typesafegithub.workflows.domain.triggers.Push
import io.github.typesafegithub.workflows.dsl.JobBuilder
import io.github.typesafegithub.workflows.dsl.WorkflowBuilder
import io.github.typesafegithub.workflows.dsl.expressions.contexts.GitHubContext
import io.github.typesafegithub.workflows.dsl.expressions.contexts.SecretsContext
import io.github.typesafegithub.workflows.dsl.expressions.expr
import io.github.typesafegithub.workflows.dsl.workflow
import io.github.typesafegithub.workflows.yaml.ConsistencyCheckJobConfig
import org.intellij.lang.annotations.Language
check(KotlinVersion.CURRENT.isAtLeast(2, 0, 0)) {
"This script requires Kotlin 2.0.0 or later"
}
enum class OS {
WINDOWS,
UBUNTU,
MACOS;
override fun toString(): String = name.lowercase()
}
enum class Arch {
X64,
AARCH64;
override fun toString(): String = name.lowercase()
}
//enum class AndroidArch(
// val id: String,
//) {
// ARM64_V8A("arm64-v8a"),
// X86_64("x86_64"),
// ARMEABI_V7A("armeabi-v7a"),
// UNIVERSAL("universal"),
// ;
//}
object AndroidArch {
const val ARM64_V8A = "arm64-v8a"
const val X86_64 = "x86_64"
const val ARMEABI_V7A = "armeabi-v7a"
const val UNIVERSAL = "universal"
val entriesWithoutUniversal = listOf(ARM64_V8A, X86_64, ARMEABI_V7A)
val entriesWithUniversal = entriesWithoutUniversal + UNIVERSAL
}
// Build 和 Release 共享这个
// Configuration for a Runner
class MatrixInstance(
// 定义属性为 val, 就会生成到 yml 的 `matrix` 里.
/**
* 用于 matrix 的 id
*/
val runner: Runner,
/**
* 显示的名字, 不能变更, 否则会导致 PR Rules 失效
*/
val name: String = runner.name,
/**
* GitHub Actions 的规范名称, e.g. `ubuntu-20.04`, `windows-2019`.
*/
val runsOn: Set<String> = runner.labels,
/**
* 只在脚本内部判断 OS 使用, 不影响 github 调度机器
* @see OS
*/
val os: OS = runner.os,
/**
* 只在脚本内部判断 OS 使用, 不影响 github 调度机器
* @see Arch
*/
val arch: Arch = runner.arch,
/**
* `false` = GitHub Actions 的免费机器
*/
val selfHosted: Boolean = runner is Runner.SelfHosted,
/**
* 有一台机器是 true 就行
*/
val uploadApk: Boolean,
/**
* Compose for Desktop 的 resource 标识符, e.g. `windows-x64`
*/
val composeResourceTriple: String,
val runTests: Boolean = true,
/**
* 每种机器必须至少有一个是 true, 否则 release 时传不全
*/
val uploadDesktopInstallers: Boolean = true,
/**
* 追加到所有 Gradle 命令的参数. 无需 quote
*/
val extraGradleArgs: List<String> = emptyList(),
/**
* Self hosted 机器已经配好了环境, 无需安装
*/
val installNativeDeps: Boolean = !selfHosted,
val buildIosFramework: Boolean = false,
val buildAllAndroidAbis: Boolean = true,
// Gradle command line args
gradleHeap: String = "4g",
kotlinCompilerHeap: String = "4g",
/**
* 只能在内存比较大的时候用.
*/
gradleParallel: Boolean = selfHosted,
) {
@Suppress("unused")
val gradleArgs = buildList {
/**
* Windows 上必须 quote, Unix 上 quote 或不 quote 都行. 所以我们统一 quote.
*/
fun quote(s: String): String {
if (s.startsWith("\"")) {
return s // already quoted
}
return "\"$s\""
}
add(quote("--scan"))
add(quote("--no-configuration-cache"))
add(quote("-Porg.gradle.daemon.idletimeout=60000"))
add(quote("-Pkotlin.native.ignoreDisabledTargets=true"))
add(quote("-Dfile.encoding=UTF-8"))
if (os == OS.WINDOWS) {
add(quote("-DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake"))
add(quote("-DBoost_INCLUDE_DIR=C:/vcpkg/installed/x64-windows/include"))
}
add(quote("-Dorg.gradle.jvmargs=-Xmx${gradleHeap}"))
add(quote("-Dkotlin.daemon.jvm.options=-Xmx${kotlinCompilerHeap}"))
if (gradleParallel) {
add(quote("--parallel"))
}
extraGradleArgs.forEach {
add(quote(it))
}
}.joinToString(" ")
init {
if (buildAllAndroidAbis) {
require(!gradleArgs.contains(ANI_ANDROID_ABIS)) { "You must not set `-P${ANI_ANDROID_ABIS}` when you want to build all Android ABIs" }
} else {
require(gradleArgs.contains(ANI_ANDROID_ABIS)) { "You must set `-P${ANI_ANDROID_ABIS}` when you don't want to build all Android ABIs" }
}
}
}
@Suppress("PropertyName")
val ANI_ANDROID_ABIS = "ani.android.abis"
sealed class Runner(
val id: String,
val name: String,
val os: OS,
val arch: Arch,
// GitHub Actions labels, e.g. `windows-2019`, `macos-13`, `self-hosted`, `Windows`, `X64`
val labels: Set<String>,
) {
// Intermediate sealed classes
sealed class GithubHosted(
id: String,
displayName: String,
os: OS,
arch: Arch,
labels: Set<String>
) : Runner(id, displayName, os, arch, labels)
sealed class SelfHosted(
id: String,
displayName: String,
os: OS,
arch: Arch,
labels: Set<String>
) : Runner(id, displayName, os, arch, labels)
// Objects under GithubHosted
object GithubWindowsServer2019 : GithubHosted(
id = "github-windows-2019",
displayName = "Windows Server 2019 x86_64 (GitHub)",
os = OS.WINDOWS,
arch = Arch.X64,
labels = setOf("windows-2019"),
)
object GithubWindowsServer2022 : GithubHosted(
id = "github-windows-2022",
displayName = "Windows Server 2022 x86_64 (GitHub)",
os = OS.WINDOWS,
arch = Arch.X64,
labels = setOf("windows-2022"),
)
object GithubMacOS13 : GithubHosted(
id = "github-macos-13",
displayName = "macOS 13 x86_64 (GitHub)",
os = OS.MACOS,
arch = Arch.X64,
labels = setOf("macos-13"),
)
object GithubMacOS14 : GithubHosted(
id = "github-macos-14",
displayName = "macOS 14 AArch64 (GitHub)",
os = OS.MACOS,
arch = Arch.AARCH64,
labels = setOf("macos-14"),
)
object GithubMacOS15 : GithubHosted(
id = "github-macos-15",
displayName = "macOS 15 AArch64 (GitHub)",
os = OS.MACOS,
arch = Arch.AARCH64,
labels = setOf("macos-15"),
)
object GithubUbuntu2004 : GithubHosted(
id = "github-ubuntu-2004",
displayName = "Ubuntu 20.04 x86_64 (GitHub)",
os = OS.UBUNTU,
arch = Arch.X64,
labels = setOf("ubuntu-20.04"),
)
// Objects under SelfHosted
object SelfHostedWindows10 : SelfHosted(
id = "self-hosted-windows-10",
displayName = "Windows 10 x86_64 (Self-Hosted)",
os = OS.WINDOWS,
arch = Arch.X64,
labels = setOf("self-hosted", "Windows", "X64"),
)
object SelfHostedMacOS15 : SelfHosted(
id = "self-hosted-macos-15",
displayName = "macOS 15 AArch64 (Self-Hosted)",
os = OS.MACOS,
arch = Arch.AARCH64,
labels = setOf("self-hosted", "macOS", "ARM64"),
)
// companion object {
// val entries: List<Runner> = listOf(
// GithubWindowsServer2019,
// GithubWindowsServer2022,
// GithubMacOS13,
// GithubMacOS14,
// GithubUbuntu2004,
// SelfHostedWindows10,
// SelfHostedMacOS15,
// )
// }
override fun toString(): String = id
}
val Runner.isSelfHosted: Boolean
get() = this is Runner.SelfHosted
// Machines for Build and Release
val buildMatrixInstances = listOf(
MatrixInstance(
runner = Runner.SelfHostedWindows10,
uploadApk = false,
composeResourceTriple = "windows-x64",
gradleHeap = "6g",
kotlinCompilerHeap = "6g",
uploadDesktopInstallers = false, // 只有 win server 2019 构建的包才能正常使用 anitorrent
extraGradleArgs = listOf(
"-P$ANI_ANDROID_ABIS=x86_64",
),
buildAllAndroidAbis = false,
),
MatrixInstance(
runner = Runner.GithubWindowsServer2019,
name = "Windows Server 2019 x86_64",
uploadApk = false,
composeResourceTriple = "windows-x64",
gradleHeap = "4g",
gradleParallel = true,
uploadDesktopInstallers = true,
extraGradleArgs = listOf(
"-P$ANI_ANDROID_ABIS=x86_64",
),
buildAllAndroidAbis = false,
),
// MatrixInstance(
// runner = Runner.GithubUbuntu2004,
// name = "Ubuntu x86_64 (Compile only)",
// uploadApk = false,
// composeResourceTriple = "linux-x64",
// runTests = false,
// uploadDesktopInstallers = false,
// extraGradleArgs = listOf(),
// ),
MatrixInstance(
runner = Runner.GithubMacOS13,
uploadApk = true, // all ABIs
composeResourceTriple = "macos-x64",
buildIosFramework = false,
gradleHeap = "4g",
uploadDesktopInstallers = true,
extraGradleArgs = listOf(),
buildAllAndroidAbis = true,
),
MatrixInstance(
runner = Runner.SelfHostedMacOS15,
uploadApk = true, // upload arm64-v8a once finished
composeResourceTriple = "macos-arm64",
uploadDesktopInstallers = true,
extraGradleArgs = listOf(
"-P$ANI_ANDROID_ABIS=arm64-v8a",
),
gradleHeap = "6g",
kotlinCompilerHeap = "4g",
gradleParallel = true,
buildAllAndroidAbis = false,
),
)
class BuildJobOutputs : JobOutputs() {
var macosAarch64DmgSuccess by output()
var windowsX64PortableSuccess by output()
}
fun getBuildJobBody(matrix: MatrixInstance): JobBuilder<BuildJobOutputs>.() -> Unit = {
uses(action = Checkout(submodules_Untyped = "recursive"))
with(WithMatrix(matrix)) {
freeSpace()
installJbr21()
chmod777()
setupGradle()
runGradle(
name = "Update dev version name",
tasks = ["updateDevVersionNameFromGit"],
)
val prepareSigningKey = prepareSigningKey()
compileAndAssemble()
prepareSigningKey?.let {
buildAndroidApk(it)
}
gradleCheck()
val packageOutputs = packageDesktopAndUpload()
packageOutputs.macosAarch64DmgOutcome?.let {
jobOutputs.macosAarch64DmgSuccess = it.eq(AbstractResult.Status.Success)
}
packageOutputs.windowsX64PortableOutcome?.let {
jobOutputs.windowsX64PortableSuccess = it.eq(AbstractResult.Status.Success)
}
cleanupTempFiles()
}
}
object ArtifactNames {
fun windowsPortable() = "ani-windows-portable"
fun macosDmg(arch: Arch) = "ani-macos-dmg-${arch}"
fun macosPortable(arch: Arch) = "ani-macos-portable-${arch}"
}
fun getVerifyJobBody(
buildJobOutputs: BuildJobOutputs,
runner: Runner,
): JobBuilder<JobOutputs.EMPTY>.() -> Unit = {
uses(action = Checkout()) // not recursive
if (!runner.isSelfHosted) {
// We must not destroy the self-hosted runner,
// but we are free to remove anything from the GitHub-hosted runners
when (runner.os) {
OS.MACOS -> {
run(
name = "Delete libraries from system",
command = shell(
$$"""
sudo rm -rfv /usr/local/lib/libssl* || true
sudo rm -rfv /usr/local/lib/libcrypto* || true
sudo rm -rfv /opt/homebrew/lib/libssl* || true
sudo rm -rfv /opt/homebrew/lib/libcrypto* || true
""".trimIndent(),
),
continueOnError = true,
)
}
OS.WINDOWS -> {
run(
name = "Delete libraries from system",
shell = Shell.PowerShell,
command = shell(
$$"""
Remove-Item -Path "C:\vcpkg\installed\x64-windows\lib\libssl*" -Recurse -Force -Verbose
Remove-Item -Path "C:\vcpkg\installed\x64-windows\lib\libcrypto*" -Recurse -Force -Verbose
""".trimIndent(),
),
continueOnError = true,
)
}
OS.UBUNTU -> {}
}
}
class VerifyTask(
val name: String,
val step: String,
val timeoutMinutes: Int = 5,
)
val tasksToExecute = listOf(
VerifyTask(
name = "anitorrent-load-test",
step = "Check that Anitorrent can be loaded",
),
)
when (runner.os to runner.arch) {
OS.WINDOWS to Arch.X64 -> {
uses(
name = "Download Windows x64 Portable",
action = DownloadArtifact(
name = ArtifactNames.windowsPortable(),
path = "${expr { github.workspace }}/ci-helper/verify",
),
)
tasksToExecute.forEach { task ->
run(
name = task.step,
shell = Shell.PowerShell,
command = shell(
$$"""
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$${expr { github.workspace }}/ci-helper/verify/run-ani-test-windows-x64.ps1" "$${expr { github.workspace }}\ci-helper\verify" "$${task.name}"
""".trimIndent(),
),
timeoutMinutes = 5,
)
}
}
OS.MACOS to Arch.AARCH64 -> {
uses(
name = "Download DMG",
action = DownloadArtifact(name = ArtifactNames.macosDmg(Arch.AARCH64)),
)
tasksToExecute.forEach { task ->
run(
name = task.step,
command = shell($$""""$GITHUB_WORKSPACE/ci-helper/verify/run-ani-test-macos-aarch64.sh" "$GITHUB_WORKSPACE"/*.dmg $${task.name}"""),
timeoutMinutes = task.timeoutMinutes,
)
}
}
else -> error("Unsupported OS and arch combination: ${runner.os} ${runner.arch}")
}
}
fun WorkflowBuilder.addVerifyJob(build: Job<BuildJobOutputs>, runner: Runner, ifExpr: String) {
job(
id = "verify_${runner.id}",
name = """Verify (${runner.name})""",
needs = listOf(build),
`if` = if (runner.isSelfHosted) {
expr { github.isAnimekoRepository and ifExpr }
} else {
expr { ifExpr }
},
permissions = mapOf(
Permission.Actions to Mode.Read, // Download artifacts
),
runsOn = RunnerType.Labelled(runner.labels),
block = getVerifyJobBody(build.outputs, runner),
)
}
workflow(
name = "Build",
on = listOf(
// Including:
// - pushing directly to main
// - pushing to a branch that has an associated PR
Push(pathsIgnore = listOf("**/*.md")),
PullRequest(pathsIgnore = listOf("**/*.md")),
),
sourceFile = __FILE__,
targetFileName = "build.yml",
consistencyCheckJobConfig = ConsistencyCheckJobConfig.Disabled,
) {
// Expands job matrix at compile-time so that we set job-level `if` condition.
val builds: List<Pair<MatrixInstance, Job<BuildJobOutputs>>> = buildMatrixInstances.map { matrix ->
matrix to job(
id = "build_${matrix.runner.id}",
name = """Build (${matrix.name})""",
runsOn = RunnerType.Labelled(matrix.runsOn),
permissions = mapOf(
Permission.Actions to Mode.Write, // Upload artifacts
),
`if` = if (matrix.selfHosted) {
// For self-hosted runners, only run if it's our main repository (not a fork).
// For security concerns, all external contributors will need approval to run the workflow.
expr { github.isAnimekoRepository }
} else {
null // always
},
outputs = BuildJobOutputs(),
block = getBuildJobBody(matrix),
)
}
builds.filter { (matrix, _) ->
matrix.runner.os == OS.WINDOWS && matrix.uploadDesktopInstallers
}.forEach { (_, build) ->
listOf(
Runner.GithubWindowsServer2019,
Runner.GithubWindowsServer2022,
Runner.SelfHostedWindows10,
).forEach { runner ->
addVerifyJob(build, runner, build.outputs.windowsX64PortableSuccess)
}
}
builds.filter { (matrix, _) ->
matrix.runner.os == OS.MACOS && matrix.runner.arch == Arch.AARCH64
&& matrix.uploadDesktopInstallers
}.forEach { (_, build) ->
listOf(
Runner.SelfHostedMacOS15,
Runner.GithubMacOS14,
Runner.GithubMacOS15,
).forEach { runner ->
addVerifyJob(build, runner, build.outputs.macosAarch64DmgSuccess)
}
}
}
operator fun List<Pair<MatrixInstance, Job<BuildJobOutputs>>>.get(runner: Runner): Job<BuildJobOutputs> {
return first { it.first.runner == runner }.second
}
workflow(
name = "Release",
permissions = mapOf(
Permission.Actions to Mode.Write,
Permission.Contents to Mode.Write, // Releases
),
on = listOf(
// Only commiter with write-access can trigger this
Push(tags = listOf("v*")),
),
sourceFile = __FILE__,
targetFileName = "release.yml",
consistencyCheckJobConfig = ConsistencyCheckJobConfig.Disabled,
) {
val createRelease = job(
id = "create-release",
name = "Create Release",
runsOn = RunnerType.UbuntuLatest,
outputs = object : JobOutputs() {
var uploadUrl by output()
var id by output()
},
) {
uses(action = Checkout()) // No need to be recursive
val gitTag = getGitTag()
val releaseNotes = run(
name = "Generate Release Notes",
command = shell(
$$"""
# Specify the file path
FILE_PATH="ci-helper/release-template.md"
# Read the file content
file_content=$(cat "$FILE_PATH")
modified_content="$file_content"
# Replace 'string_to_find' with 'string_to_replace_with' in the content
modified_content="${modified_content//\$GIT_TAG/$${expr { gitTag.tagExpr }}}"
modified_content="${modified_content//\$TAG_VERSION/$${expr { gitTag.tagVersionExpr }}}"
# Output the result as a step output
echo "result<<EOF" >> $GITHUB_OUTPUT
echo "$modified_content" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
""".trimIndent(),
),
)
val createRelease = uses(
name = "Create Release",
action = ActionGhRelease(
tagName = expr { gitTag.tagExpr },
name = expr { gitTag.tagVersionExpr },
body = expr { releaseNotes.outputs["result"] },
draft = true,
prerelease_Untyped = expr { contains(gitTag.tagExpr, "'-'") },
),
env = mapOf("GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }),
)
jobOutputs.uploadUrl = createRelease.outputs.uploadUrl
jobOutputs.id = createRelease.outputs.id
}
val matrixInstancesForRelease = buildMatrixInstances.filterNot { it.os == OS.UBUNTU }
fun addJob(matrix: MatrixInstance) = with(WithMatrix(matrix)) {
val jobBody: JobBuilder<JobOutputs.EMPTY>.() -> Unit = {
uses(action = Checkout(submodules_Untyped = "recursive"))
val gitTag = getGitTag()
freeSpace()
installJbr21()
chmod777()
setupGradle()
runGradle(
name = "Update Release Version Name",
tasks = ["updateReleaseVersionNameFromGit"],
env = mapOf(
"GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN },
"GITHUB_REPOSITORY" to expr { secrets.GITHUB_REPOSITORY },
"CI_RELEASE_ID" to expr { createRelease.outputs.id },
"CI_TAG" to expr { gitTag.tagExpr },
),
)
val prepareSigningKey = prepareSigningKey()
compileAndAssemble()
prepareSigningKey?.let {
buildAndroidApk(it)
}
// No Check. We've already checked in build
with(
CIHelper(
releaseIdExpr = createRelease.outputs.id,
gitTag,
),
) {
uploadAndroidApkToCloud()
generateQRCodeAndUpload()
uploadDesktopInstallers()
uploadComposeLogs()
}
cleanupTempFiles()
}
job(
id = "release_${matrix.runner.id}",
name = matrix.name,
needs = listOf(createRelease),
runsOn = RunnerType.Labelled(matrix.runsOn),
`if` = if (matrix.selfHosted) expr { github.isAnimekoRepository } else null, // Don't run on forks
block = jobBody,
)
}
for (matrix in matrixInstancesForRelease) {
addJob(matrix)
}
}
data class GitTag(
/**
* The full git tag, e.g. `v1.0.0`
*/
val tagExpr: String,
/**
* The tag version, e.g. `1.0.0`
*/
val tagVersionExpr: String,
)
fun JobBuilder<*>.getGitTag(): GitTag {
val tag = uses(
name = "Get Tag",
action = ActionGetTag_Untyped(),
)
val tagVersion = uses(
action = GithubSubstringAction_Untyped(
value_Untyped = expr { tag.outputs.tag },
indexOfStr_Untyped = "v",
defaultReturnValue_Untyped = expr { tag.outputs.tag },
),
)
return GitTag(
tagExpr = tag.outputs.tag,
tagVersionExpr = tagVersion.outputs["substring"],
)
}
class WithMatrix(
val matrix: MatrixInstance
) {
fun JobBuilder<*>.runGradle(
name: String? = null,
`if`: String? = null,
@Language("shell", prefix = "./gradlew ") vararg tasks: String,
env: Map<String, String> = emptyMap(),
): CommandStep = run(
name = name,
`if` = `if`,
command = shell(
buildString {
append("./gradlew ")
tasks.joinTo(this, " ")
append(' ')
append(matrix.gradleArgs)
},
),
env = env,
)
/**
* GitHub Actions 上给的硬盘比较少, 我们删掉一些不必要的文件来腾出空间.
*/
fun JobBuilder<*>.freeSpace() {
if (matrix.isMacOS && !matrix.selfHosted) {
run(
name = "Free space for macOS",
command = shell($$"""chmod +x ./ci-helper/free-space-macos.sh && ./ci-helper/free-space-macos.sh"""),
continueOnError = true,
)
}
}
fun JobBuilder<*>.installJbr21() {
// For mac
val jbrUrl = "https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk_jcef-21.0.5-osx-aarch64-b631.32.tar.gz"
val jbrChecksumUrl =
"https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk_jcef-21.0.5-osx-aarch64-b631.32.tar.gz.checksum"
val jbrFilename = jbrUrl.substringAfterLast('/')
when (matrix.runner.os to matrix.runner.arch) {
OS.MACOS to Arch.AARCH64 -> {
val jbrLocationExpr = run(
name = "Resolve JBR location",
command = shell(
$$"""
# Expand jbrLocationExpr
jbr_location_expr=$(eval echo $${expr { runner.tool_cache } + "/" + jbrFilename})
echo "jbrLocation=$jbr_location_expr" >> $GITHUB_OUTPUT
""".trimIndent(),
),
).outputs["jbrLocation"]
run(
name = "Get JBR 21 for macOS AArch64",
command = shell(
$$"""
jbr_location="$jbrLocation"
checksum_url="$$jbrChecksumUrl"
checksum_file="checksum.tmp"
wget -q -O $checksum_file $checksum_url
expected_checksum=$(awk '{print $1}' $checksum_file)
file_checksum=""
if [ -f "$jbr_location" ]; then
file_checksum=$(shasum -a 512 "$jbr_location" | awk '{print $1}')
fi
if [ "$file_checksum" != "$expected_checksum" ]; then
wget -q --tries=3 $$jbrUrl -O "$jbr_location"
file_checksum=$(shasum -a 512 "$jbr_location" | awk '{print $1}')
fi
if [ "$file_checksum" != "$expected_checksum" ]; then
echo "Checksum verification failed!" >&2
rm -f $checksum_file
exit 1
fi
rm -f $checksum_file
file "$jbr_location"
""".trimIndent(),
),
env = mapOf(
"jbrLocation" to expr { jbrLocationExpr },
),
)
uses(
name = "Setup JBR 21 for macOS AArch64",
action = SetupJava_Untyped(
distribution_Untyped = "jdkfile",
javaVersion_Untyped = "21",
jdkFile_Untyped = expr { jbrLocationExpr },
),
env = mapOf("GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }),
)
}
else -> {
// For Windows + Ubuntu
uses(
name = "Setup JBR 21 for other OS",
action = SetupJava_Untyped(
distribution_Untyped = "jetbrains",
javaVersion_Untyped = "21",
),
env = mapOf("GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }),
)
}
}
run(
command = shell($$"""echo "jvm.toolchain.version=21" >> local.properties"""),
)
}
fun JobBuilder<*>.installNativeDeps() {
// Windows
if (matrix.isWindows and matrix.installNativeDeps) {
uses(
name = "Setup vcpkg cache",
action = GithubScript(
script = """
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
""".trimIndent(),
),
)
run(
name = "Install Native Dependencies for Windows",
command = "./ci-helper/install-deps-windows.cmd",
env = mapOf("VCPKG_BINARY_SOURCES" to "clear;x-gha,readwrite"),
)
}
if (matrix.isMacOS and matrix.installNativeDeps) {
// MacOS
run(
name = "Install Native Dependencies for MacOS",
command = shell($$"""chmod +x ./ci-helper/install-deps-macos-ci.sh && ./ci-helper/install-deps-macos-ci.sh"""),
)
}
}
fun JobBuilder<*>.chmod777() {
if (matrix.isUnix) {
run(
command = "chmod -R 777 .",
)
}
}
fun JobBuilder<*>.setupGradle() {
uses(
name = "Setup Gradle",
action = ActionsSetupGradle(
cacheDisabled = true,
),
)
uses(
name = "Clean and download dependencies",
action = Retry_Untyped(
maxAttempts_Untyped = "3",
timeoutMinutes_Untyped = "60",
command_Untyped = """./gradlew """ + matrix.gradleArgs.replace(
"--scan",
"--stacktrace",
), // com.gradle.develocity.DevelocityException: Internal error in Develocity Gradle plugin: finished notification
),
)
}
/**
* Returns the action step if it's enabled, otherwise returns `null`.
*/
fun JobBuilder<*>.prepareSigningKey(): ActionStep<Base64ToFile_Untyped.Outputs>? {
return if (matrix.uploadApk) {
uses(
name = "Prepare signing key",
`if` = expr { github.isAnimekoRepository and !github.isPullRequest },
action = Base64ToFile_Untyped(
fileName_Untyped = "android_signing_key",
fileDir_Untyped = "./",
encodedString_Untyped = expr { secrets.SIGNING_RELEASE_STOREFILE },
),
continueOnError = true,
)
} else {
null
}
}
fun JobBuilder<*>.compileAndAssemble() {
runGradle(
name = "Compile Kotlin",
tasks = [
"compileKotlin",
"compileCommonMainKotlinMetadata",
"compileDebugKotlinAndroid",
"compileReleaseKotlinAndroid",
"compileJvmMainKotlinMetadata",
"compileKotlinDesktop",
"compileKotlinMetadata",
],
)
}
fun JobBuilder<*>.buildAndroidApk(prepareSigningKey: ActionStep<Base64ToFile_Untyped.Outputs>) {
if (matrix.uploadApk) {
runGradle(
name = "Build Android Debug APKs",
tasks = [
"assembleDebug",
],
)
}
for (arch in AndroidArch.entriesWithUniversal) {
val shouldUpload = if (arch == AndroidArch.UNIVERSAL) {
matrix.uploadApk and matrix.buildAllAndroidAbis
} else {
matrix.uploadApk
}
if (shouldUpload) {
uses(
name = "Upload Android Debug APK $arch",
action = UploadArtifact(
name = "ani-android-${arch}-debug",
path_Untyped = "app/android/build/outputs/apk/debug/android-${arch}-debug.apk",
overwrite = true,
),
)
}
}
if (matrix.uploadApk) {
runGradle(
name = "Build Android Release APKs",
`if` = expr { github.isAnimekoRepository and !github.isPullRequest },
tasks = [
"assembleRelease",
],
env = mapOf(
"signing_release_storeFileFromRoot" to expr { prepareSigningKey.outputs.filePath },
"signing_release_storePassword" to expr { secrets.SIGNING_RELEASE_STOREPASSWORD },
"signing_release_keyAlias" to expr { secrets.SIGNING_RELEASE_KEYALIAS },
"signing_release_keyPassword" to expr { secrets.SIGNING_RELEASE_KEYPASSWORD },
),
)
}
for (arch in AndroidArch.entriesWithUniversal) {
val shouldUpload = if (arch == AndroidArch.UNIVERSAL) {
matrix.uploadApk and matrix.buildAllAndroidAbis
} else {
matrix.uploadApk
}
if (shouldUpload) {
uses(
name = "Upload Android Release APK $arch",
action = UploadArtifact(
name = "ani-android-${arch}-release",
path_Untyped = "app/android/build/outputs/apk/release/android-${arch}-release.apk",
overwrite = true,
),
)
}
}
}
fun JobBuilder<*>.gradleCheck() {
if (matrix.runTests) {
uses(
name = "Check",
action = Retry_Untyped(
maxAttempts_Untyped = "2",
timeoutMinutes_Untyped = "60",
command_Untyped = "./gradlew check " + matrix.gradleArgs,
),
)
}
}
class PackageDesktopAndUploadOutputs {
// null means not enabled on this machine
var macosAarch64DmgOutcome: Step<*>.Outcome? = null
var windowsX64PortableOutcome: Step<*>.Outcome? = null
}
fun JobBuilder<*>.packageDesktopAndUpload(): PackageDesktopAndUploadOutputs {
if (matrix.isMacOSX64 // not supported
|| !matrix.uploadDesktopInstallers // disabled
) {
return PackageDesktopAndUploadOutputs()
}
if (matrix.isWindows) {
// Windows does not support installers
runGradle(
name = "Package Desktop",
tasks = [
"createReleaseDistributable", // portable
],
)
} else {
// macOS uses installers
runGradle(
name = "Package Desktop",
tasks = [
"packageReleaseDistributionForCurrentOS", // dmg
],
)
}
uploadComposeLogs()
return PackageDesktopAndUploadOutputs().apply {
if (matrix.isMacOS && matrix.isAArch64) {
val macosAarch64Dmg = uses(
name = "Upload macOS dmg",
action = UploadArtifact(
name = ArtifactNames.macosDmg(matrix.arch),
path_Untyped = "app/desktop/build/compose/binaries/main-release/dmg/Ani-*.dmg",
overwrite = true,
ifNoFilesFound = UploadArtifact.BehaviorIfNoFilesFound.Error,
),
)
this.macosAarch64DmgOutcome = macosAarch64Dmg.outcome
}
if (matrix.isMacOS && matrix.isX64) {
uses(
name = "Upload macOS dmg",
action = UploadArtifact(
name = ArtifactNames.macosPortable(matrix.arch),
path_Untyped = "app/desktop/build/compose/binaries/main-release/app/Ani.app",
overwrite = true,
ifNoFilesFound = UploadArtifact.BehaviorIfNoFilesFound.Error,
),
)
}
if (matrix.isWindows) {
val windowsX64Portable = uses(
name = "Upload Windows packages",
action = UploadArtifact(
name = ArtifactNames.windowsPortable(),
path_Untyped = "app/desktop/build/compose/binaries/main-release/app",
overwrite = true,
ifNoFilesFound = UploadArtifact.BehaviorIfNoFilesFound.Error,
),
)
this.windowsX64PortableOutcome = windowsX64Portable.outcome
}
}
}
fun JobBuilder<*>.uploadComposeLogs() {
if (matrix.uploadDesktopInstallers) {
uses(
name = "Upload compose logs",
action = UploadArtifact(
name = "compose-logs-${matrix.runner.id}",
path_Untyped = "app/desktop/build/compose/logs",
),
)
}
}
fun JobBuilder<*>.cleanupTempFiles() {
if (matrix.selfHosted and matrix.isMacOSAArch64) {
run(
name = "Cleanup temp files",
command = shell("""chmod +x ./ci-helper/cleanup-temp-files-macos.sh && ./ci-helper/cleanup-temp-files-macos.sh"""),
continueOnError = true,
)
}
}
inner class CIHelper(
releaseIdExpr: String,
private val gitTag: GitTag,
) {
private val ciHelperSecrets: Map<String, String> = mapOf(
"GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN },
"GITHUB_REPOSITORY" to expr { secrets.GITHUB_REPOSITORY },
"CI_RELEASE_ID" to expr { releaseIdExpr },
"CI_TAG" to expr { gitTag.tagExpr },
"UPLOAD_TO_S3" to "true",
"AWS_ACCESS_KEY_ID" to expr { secrets.AWS_ACCESS_KEY_ID },
"AWS_SECRET_ACCESS_KEY" to expr { secrets.AWS_SECRET_ACCESS_KEY },
"AWS_BASEURL" to expr { secrets.AWS_BASEURL },
"AWS_REGION" to expr { secrets.AWS_REGION },
"AWS_BUCKET" to expr { secrets.AWS_BUCKET },
)
fun JobBuilder<*>.uploadAndroidApkToCloud() {
if (matrix.uploadApk) {
runGradle(
name = "Upload Android APK for Release",
tasks = [":ci-helper:uploadAndroidApk"],
env = ciHelperSecrets,
)
}
}
fun JobBuilder<*>.generateQRCodeAndUpload() {
if (matrix.uploadApk and matrix.buildAllAndroidAbis) {
uses(
name = "Generate QR code for APK (GitHub)",
`if` = condition,
action = Qrcode_Untyped(
text_Untyped = """https://github.com/Him188/ani/releases/download/${expr { gitTag.tagExpr }}/ani-${expr { gitTag.tagVersionExpr }}-universal.apk""",
path_Untyped = "apk-qrcode-github.png",
),
)
uses(
name = "Generate QR code for APK (Cloudflare)",
`if` = condition,
action = Qrcode_Untyped(
text_Untyped = """https://d.myani.org/${expr { gitTag.tagExpr }}/ani-${expr { gitTag.tagVersionExpr }}-universal.apk""",
path_Untyped = "apk-qrcode-cloudflare.png",
),
)
runGradle(
name = "Upload QR code",
`if` = condition,
tasks = [":ci-helper:uploadAndroidApkQR"],
env = ciHelperSecrets,
)
}
}
fun JobBuilder<*>.uploadDesktopInstallers() {
if (matrix.uploadDesktopInstallers and (!matrix.isMacOSX64)) {
runGradle(
name = "Upload Desktop Installers",
tasks = [":ci-helper:uploadDesktopInstallers"],
env = ciHelperSecrets,
)
}
}
}
}
/// ENV
object Secrets {
val SecretsContext.GITHUB_REPOSITORY by SecretsContext.propertyToExprPath
val SecretsContext.SIGNING_RELEASE_STOREFILE by SecretsContext.propertyToExprPath
val SecretsContext.SIGNING_RELEASE_STOREPASSWORD by SecretsContext.propertyToExprPath
val SecretsContext.SIGNING_RELEASE_KEYALIAS by SecretsContext.propertyToExprPath
val SecretsContext.SIGNING_RELEASE_KEYPASSWORD by SecretsContext.propertyToExprPath
val SecretsContext.AWS_ACCESS_KEY_ID by SecretsContext.propertyToExprPath
val SecretsContext.AWS_SECRET_ACCESS_KEY by SecretsContext.propertyToExprPath
val SecretsContext.AWS_BASEURL by SecretsContext.propertyToExprPath
val SecretsContext.AWS_REGION by SecretsContext.propertyToExprPath
val SecretsContext.AWS_BUCKET by SecretsContext.propertyToExprPath
}
/// EXTENSIONS
val GitHubContext.isAnimekoRepository
get() = """$repository == 'open-ani/animeko'"""
val GitHubContext.isPullRequest
get() = """$event_name == 'pull_request'"""
val MatrixInstance.isX64 get() = arch == Arch.X64
val MatrixInstance.isAArch64 get() = arch == Arch.AARCH64
val MatrixInstance.isMacOS get() = os == OS.MACOS
val MatrixInstance.isWindows get() = os == OS.WINDOWS
val MatrixInstance.isUbuntu get() = os == OS.UBUNTU
val MatrixInstance.isUnix get() = (os == OS.UBUNTU) or (os == (OS.MACOS))
val MatrixInstance.isMacOSAArch64 get() = (os == OS.MACOS) and (arch == Arch.AARCH64)
val MatrixInstance.isMacOSX64 get() = (os == OS.MACOS) and (arch == Arch.X64)
// only for highlighting (though this does not work in KT 2.1.0)
fun shell(@Language("shell") command: String) = command
infix fun String.and(other: String) = "($this) && ($other)"
infix fun String.or(other: String) = "($this) || ($other)"
// 由于 infix 优先级问题, 这里要求使用传统调用方式.
fun String.eq(other: OS) = this.eq(other.toString())
fun String.eq(other: String) = "($this == '$other')"
fun String.eq(other: Boolean) = "($this == $other)"
fun String.neq(other: String) = "($this != '$other')"
fun String.neq(other: Boolean) = "($this != $other)"
operator fun String.not() = "!($this)"