forked from SteamWar/SteamWar
Only build and deploy changed projects in CI
Every project gets a checkAffected task that decides whether it has to
be rebuilt. A project is affected when one of these changed:
- its own files, or paths it adds with an affected { } block
- the versions of its resolved dependencies
- a project it depends on
- the root project, which owns the shared build and CI files
Projects list what they ship in a deploy { } block, and can add
afterDeploy commands there. Jars are now byte-reproducible. Only files
whose hash differs from the deployed one are uploaded, and only their
afterDeploy commands are run.
The deploy workflow diffs against the last deployed commit, which is
stored on the server with the dependency versions. PR builds diff
against the merge base. Shared setup steps moved into composite actions
under .gitea/actions.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2026 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import org.gradle.api.provider.SetProperty
|
||||
|
||||
abstract class AffectedExtension {
|
||||
|
||||
/**
|
||||
* Paths relative to the root directory whose changes affect this project, a trailing / matches everything
|
||||
* below a directory. Defaults to the project directory, files of subprojects never affect this project.
|
||||
*/
|
||||
abstract val files: SetProperty<String>
|
||||
|
||||
/**
|
||||
* Projects whose changes affect this project in addition to its project dependencies
|
||||
*/
|
||||
abstract val projects: SetProperty<String>
|
||||
|
||||
fun whenChanged(vararg paths: String) {
|
||||
files.addAll(*paths)
|
||||
}
|
||||
|
||||
fun withProject(vararg paths: String) {
|
||||
projects.addAll(*paths)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2026 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import org.gradle.api.Project
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Directory for exchanging files with the CI scripts, set with -PciDir
|
||||
*/
|
||||
val Project.ciDir: File?
|
||||
get() = findProperty("ciDir")?.let { rootProject.file(it) }
|
||||
|
||||
/**
|
||||
* Writes the lines prefixed with the project path to the CI directory, where the CI scripts collect them from all projects.
|
||||
* Removes the file if there are no lines.
|
||||
*/
|
||||
fun Project.writeCiFile(kind: String, lines: Collection<String>) {
|
||||
val name = if (this == rootProject) "root" else path.removePrefix(":").replace(':', '.')
|
||||
val file = ciDir?.resolve("projects/$kind/$name") ?: return
|
||||
if (lines.isEmpty()) {
|
||||
file.delete()
|
||||
} else {
|
||||
file.parentFile.mkdirs()
|
||||
file.writeText(lines.joinToString("") { "$path $it\n" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2026 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.file.RegularFile
|
||||
import org.gradle.api.provider.ListProperty
|
||||
import org.gradle.api.provider.MapProperty
|
||||
import org.gradle.api.tasks.TaskProvider
|
||||
import org.gradle.api.tasks.bundling.AbstractArchiveTask
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class DeployExtension {
|
||||
|
||||
@get:Inject
|
||||
abstract val project: Project
|
||||
|
||||
/**
|
||||
* Deployed file name -> build output, the CI deploys every file whose content changed.
|
||||
* The producing task has to run as part of the build task.
|
||||
*/
|
||||
abstract val artifacts: MapProperty<String, RegularFile>
|
||||
|
||||
/**
|
||||
* Shell commands run in the deploy directory on the deploy server after one of the artifacts changed,
|
||||
* only for deployments of main
|
||||
*/
|
||||
abstract val commands: ListProperty<String>
|
||||
|
||||
fun artifact(task: TaskProvider<out AbstractArchiveTask>, name: String = "${project.name}.jar") {
|
||||
artifacts.put(name, task.flatMap { it.archiveFile })
|
||||
}
|
||||
|
||||
fun afterDeploy(command: String) {
|
||||
commands.add(command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2026 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
val affected = extensions.create<AffectedExtension>("affected")
|
||||
|
||||
// By default a project watches its own directory, whenChanged adds to it and files.set replaces it
|
||||
affected.files.add(projectDir.relativeTo(rootDir).invariantSeparatorsPath + "/")
|
||||
|
||||
// Reads changed-files and previous-dependency-versions from the CI directory, without changed-files everything is affected.
|
||||
// Writes the reasons this project is affected and its dependency versions back for the CI scripts.
|
||||
tasks.register("checkAffected") {
|
||||
group = "help"
|
||||
description = "Checks whether this project has to be rebuilt because of the changes described in -PciDir"
|
||||
notCompatibleWithConfigurationCache("Inspects the project model and resolves configurations")
|
||||
|
||||
val upstreamProjects = provider {
|
||||
val projectDependencies = project.configurations
|
||||
.flatMap { it.dependencies.withType<ProjectDependency>() }
|
||||
.map { it.path }
|
||||
// The root project watches the files shared by all projects
|
||||
val rootProject = listOfNotNull(project.rootProject.path.takeIf { project.rootProject.plugins.hasPlugin("affected") })
|
||||
(projectDependencies + affected.projects.get() + rootProject).toSet() - project.path
|
||||
}
|
||||
dependsOn(upstreamProjects.map { paths -> paths.map { "$it:checkAffected" } })
|
||||
|
||||
doLast {
|
||||
val ciDir = project.ciDir ?: throw GradleException("checkAffected requires -PciDir")
|
||||
fun resultFile(target: Project) = target.layout.buildDirectory.file("affected/result").get().asFile
|
||||
|
||||
val reasons = mutableListOf<String>()
|
||||
|
||||
val changedFiles = ciDir.resolve("changed-files")
|
||||
if (changedFiles.exists()) {
|
||||
val subprojectDirs = project.subprojects.map { it.projectDir.relativeTo(project.rootDir).invariantSeparatorsPath + "/" }
|
||||
val watched = affected.files.get()
|
||||
for (changedFile in changedFiles.readLines().filter { it.isNotBlank() }) {
|
||||
if (watched.any { changedFile == it || (it.endsWith("/") && changedFile.startsWith(it)) } && subprojectDirs.none { changedFile.startsWith(it) }) {
|
||||
reasons += "$changedFile changed"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reasons += "no changed files known"
|
||||
}
|
||||
|
||||
val previousVersionsFile = ciDir.resolve("previous-dependency-versions")
|
||||
if (previousVersionsFile.exists()) {
|
||||
val versions = project.configurations
|
||||
.filter { it.isCanBeResolved && (it.name.endsWith("Classpath") || it.name.endsWith("annotationProcessor", ignoreCase = true)) }
|
||||
.flatMap { it.incoming.resolutionResult.allComponents }
|
||||
.mapNotNull { it.id as? ModuleComponentIdentifier }
|
||||
.map { "${it.group}:${it.module}:${it.version}" }
|
||||
.toSortedSet()
|
||||
project.writeCiFile("dependency-versions", versions)
|
||||
|
||||
val previousLines = previousVersionsFile.readLines()
|
||||
if (previousLines.none { it.isNotBlank() }) {
|
||||
reasons += "no previous dependency versions"
|
||||
} else {
|
||||
val previousVersions = previousLines
|
||||
.filter { it.startsWith("${project.path} ") }
|
||||
.map { it.substringAfter(' ') }
|
||||
.toSet()
|
||||
(versions - previousVersions).forEach { reasons += "dependency $it added" }
|
||||
(previousVersions - versions).forEach { reasons += "dependency $it removed" }
|
||||
}
|
||||
}
|
||||
|
||||
for (upstream in upstreamProjects.get()) {
|
||||
if (resultFile(project.project(upstream)).readText().trim() == "affected") {
|
||||
reasons += if (upstream == project.rootProject.path) "root project affected" else "depends on $upstream"
|
||||
}
|
||||
}
|
||||
|
||||
resultFile(project).apply {
|
||||
parentFile.mkdirs()
|
||||
writeText(if (reasons.isEmpty()) "unaffected" else "affected")
|
||||
}
|
||||
// Projects without anything to build only pass their state on to their dependents
|
||||
project.writeCiFile("affected", if (project != project.rootProject && project.tasks.findByName("build") != null) reasons else emptyList())
|
||||
if (reasons.isNotEmpty()) {
|
||||
logger.quiet("${if (project == project.rootProject) "Root project" else project.path}: ${reasons.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This file is a part of the SteamWar software.
|
||||
*
|
||||
* Copyright (C) 2026 SteamWar.de-Serverteam
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
val deploy = extensions.create<DeployExtension>("deploy")
|
||||
|
||||
tasks.register("deployInfo") {
|
||||
group = "help"
|
||||
description = "Writes the deployed files and after deploy commands of this project to -PciDir"
|
||||
notCompatibleWithConfigurationCache("Inspects the project model")
|
||||
|
||||
doLast {
|
||||
project.writeCiFile("deploy-artifacts", deploy.artifacts.get().map { (name, file) ->
|
||||
"${file.asFile.relativeTo(project.rootDir).invariantSeparatorsPath} $name"
|
||||
})
|
||||
project.writeCiFile("deploy-commands", deploy.commands.get())
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("affected")
|
||||
}
|
||||
|
||||
// The AccessWidener jar is only used through a task dependency
|
||||
the<AffectedExtension>().withProject(":AccessWidener")
|
||||
|
||||
val widener = extensions.create<WidenerExtension>("widener")
|
||||
|
||||
widener.accessWidenerFiles.setFrom(
|
||||
|
||||
Reference in New Issue
Block a user