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:
2026-11-01 09:36:19 +01:00
parent 937905f296
commit cbb2987504
30 changed files with 683 additions and 125 deletions
+29
View File
@@ -0,0 +1,29 @@
name: Setup build
description: Installs the JDKs and Gradle and configures the SteamWar Maven repository
inputs:
maven-credentials:
description: Contents of steamwar.properties
required: true
runs:
using: composite
steps:
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
# The last version becomes the default
java-version: |
8
11
17
21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Setup Maven Repository
shell: bash
env:
SW_MAVEN_CREDENTIALS: ${{ inputs.maven-credentials }}
run: |
echo "$SW_MAVEN_CREDENTIALS" > steamwar.properties
+51
View File
@@ -0,0 +1,51 @@
name: Setup SSH
description: Configures the deploy server as the SSH host "deploy" for ssh and scp
inputs:
host:
description: Deploy server host name
required: true
user:
description: Deploy server user
required: true
port:
description: Deploy server SSH port
required: false
default: '22'
key:
description: Private SSH key of the deploy user
required: true
runs:
using: composite
steps:
- name: Configure SSH
shell: bash
env:
DEPLOY_HOST: ${{ inputs.host }}
DEPLOY_USER: ${{ inputs.user }}
DEPLOY_PORT: ${{ inputs.port }}
DEPLOY_SSH_KEY: ${{ inputs.key }}
run: |
set -euo pipefail
: "${DEPLOY_HOST:?Missing DEPLOY_HOST secret}"
: "${DEPLOY_USER:?Missing DEPLOY_USER secret}"
: "${DEPLOY_SSH_KEY:?Missing DEPLOY_SSH_KEY secret}"
port="${DEPLOY_PORT:-22}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$port" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
cat >> ~/.ssh/config <<EOF
Host deploy
HostName ${DEPLOY_HOST}
User ${DEPLOY_USER}
Port ${port}
IdentityFile ~/.ssh/deploy_key
EOF
chmod 600 ~/.ssh/config
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# Usage: affected-projects.sh <state-dir> [base-commit]
#
# Runs the checkAffected task of every project for the changes between the base commit and HEAD
# and writes the result to $GITHUB_OUTPUT:
# projects - space separated paths of the affected projects
# tasks - space separated Gradle tasks to run (empty if nothing is affected)
#
# The projects exchange files with this script through the state directory. If it contains
# previous-dependency-versions (deploys only), projects whose resolved dependency versions changed are
# affected as well and the collected dependency-versions, deploy-artifacts and deploy-commands of all
# projects are written to it. Without a known base commit everything is affected.
set -euo pipefail
shopt -s nullglob
mkdir -p "$1"
state="$(realpath "$1")"
base="${2:-}"
deploy=false
if [[ -f "${state}/previous-dependency-versions" ]]; then
deploy=true
fi
# Whether the diff of settings.gradle.kts only touches library versions of the version catalog
catalog_only() {
! git diff -U0 --no-renames "$base" HEAD -- settings.gradle.kts \
| grep -E '^[+-]' \
| grep -vE '^(\+\+\+|---) ' \
| grep -qvE '^[+-][[:space:]]*(library\("|val [A-Za-z]+Version = "|$)'
}
# All lines the projects wrote for the given kind
collect() {
local files=("${state}/projects/$1"/*)
if (( ${#files[@]} )); then
sort "${files[@]}"
fi
}
rm -f "${state}/changed-files"
if [[ -z "$base" ]]; then
echo "No base commit, everything is affected"
elif ! git cat-file -e "${base}^{commit}" 2>/dev/null; then
echo "Base commit ${base} is unknown, everything is affected"
else
echo "Changed files since ${base}:"
git diff --name-only --no-renames "$base" HEAD | tee "${state}/changed-files"
# Library version changes are detected through the resolved dependency versions of the projects using them
if [[ "$deploy" == "true" ]] && grep -qx 'settings.gradle.kts' "${state}/changed-files" && catalog_only; then
echo "settings.gradle.kts only changed library versions, relying on the dependency versions"
grep -vx 'settings.gradle.kts' "${state}/changed-files" > "${state}/changed-files.filtered" || true
mv "${state}/changed-files.filtered" "${state}/changed-files"
fi
fi
args=(-q --no-daemon checkAffected -PciDir="$state")
if [[ "$deploy" == "true" ]]; then
args+=(deployInfo --refresh-dependencies)
fi
./gradlew "${args[@]}"
if [[ "$deploy" == "true" ]]; then
collect dependency-versions > "${state}/dependency-versions"
collect deploy-artifacts > "${state}/deploy-artifacts"
collect deploy-commands > "${state}/deploy-commands"
fi
projects="$(collect affected | cut -d ' ' -f 1 | sort -u | paste -sd ' ' -)"
tasks="$(collect affected | cut -d ' ' -f 1 | sort -u | sed 's/$/:build/' | paste -sd ' ' -)"
echo "projects=${projects}"
echo "tasks=${tasks}"
{
echo "projects=${projects}"
echo "tasks=${tasks}"
} >> "$GITHUB_OUTPUT"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
#
# Usage: stage-artifacts.sh <state-dir> <output-dir> [projects]
#
# Copies the deployed files of the given projects to the output directory under their deployed name,
# skipping those identical to the deployed file, and lists the projects with copied files in
# state-dir/deployed-projects. Reads state-dir/deploy-artifacts ("<project> <build output> <deployed name>"
# per line, written by affected-projects.sh) and state-dir/deployed-hashes (sha256sum output of the deployed files).
set -euo pipefail
state="$1"
output="$2"
projects=" ${3:-} "
mkdir -p "$output"
: > "${state}/deployed-projects"
while read -r project source name; do
if [[ "$projects" != *" ${project} "* ]]; then
continue
fi
hash="$(sha256sum "$source" | cut -d ' ' -f 1)"
if grep -qxF "${hash} ${name}" "${state}/deployed-hashes"; then
echo "${name} from ${project} is unchanged"
else
echo "Staging ${name} from ${project}"
cp "$source" "${output}/${name}"
echo "$project" >> "${state}/deployed-projects"
fi
done < "${state}/deploy-artifacts"
+76 -100
View File
@@ -7,84 +7,25 @@ on:
- version/*
jobs:
build:
name: Build
deploy:
name: Build and deploy
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v6
- name: Setup Java 8
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 8
- name: Setup Java 11
uses: actions/setup-java@v5
fetch-depth: 0
- name: Setup build
uses: ./.gitea/actions/setup-build
with:
distribution: 'temurin'
java-version: 11
- name: Setup Java 17
uses: actions/setup-java@v5
maven-credentials: ${{ secrets.SW_MAVEN_CREDENTIALS }}
- name: Setup SSH
uses: ./.gitea/actions/setup-ssh
with:
distribution: 'temurin'
java-version: 17
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Setup Maven Repository
env:
SW_MAVEN_CREDENTIALS: ${{ secrets.SW_MAVEN_CREDENTIALS }}
run: |
echo "$SW_MAVEN_CREDENTIALS" > steamwar.properties
- name: Build with Gradle
run: ./gradlew clean build --no-daemon --no-build-cache --refresh-dependencies
- name: Stage deploy artifacts
shell: bash
run: |
set -euo pipefail
rm -rf deploy
mkdir -p deploy
cp "AccessWidener/build/libs/AccessWidener-all.jar" "deploy/AccessWidener.jar"
cp "BauSystem/build/libs/BauSystem-all.jar" "deploy/BauSystem.jar"
cp "FightSystem/build/libs/FightSystem-all.jar" "deploy/FightSystem.jar"
cp "KotlinCore/build/libs/KotlinCore-all.jar" "deploy/KotlinCore.jar"
cp "TNTLeague/build/libs/TNTLeague.jar" "deploy/TNTLeague.jar"
cp "LobbySystem/build/libs/LobbySystem.jar" "deploy/LobbySystem.jar"
cp "MissileWars/build/libs/MissileWars.jar" "deploy/MissileWars.jar"
cp "Realtime/build/libs/Realtime.jar" "deploy/RealTime.jar"
cp "SchematicSystem/build/libs/SchematicSystem-all.jar" "deploy/SchematicSystem.jar"
cp "SpigotCore/build/libs/SpigotCore-all.jar" "deploy/SpigotCore.jar"
cp "Teamserver/build/libs/Teamserver.jar" "deploy/Builder.jar"
cp "TowerRun/build/libs/TowerRun.jar" "deploy/TowerRun.jar"
cp "VelocityCore/Persistent/build/libs/Persistent.jar" "deploy/PersistentVelocityCore.jar"
cp "VelocityCore/Dependencies/build/libs/Dependencies-all.jar" "deploy/DependenciesVelocityCore.jar"
cp "VelocityCore/build/libs/VelocityCore-all.jar" "deploy/VelocityCore.jar"
cp "WebsiteBackend/build/libs/WebsiteBackend-all.jar" "deploy/website-api.jar"
cp "CLI/build/distributions/sw.zip" "deploy/sw.zip"
- name: Upload deploy artifacts
uses: actions/upload-artifact@v3
with:
name: steamwar-jars
path: deploy/
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
steps:
- name: Download deploy artifacts
uses: actions/download-artifact@v3
with:
name: steamwar-jars
path: deploy
host: ${{ secrets.DEPLOY_HOST }}
user: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Resolve deploy target
id: target
@@ -106,45 +47,80 @@ jobs:
exit 1
fi
- name: Upload jars with scp
- name: Read deployed state
id: deployed
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_PATH: ${{ steps.target.outputs.path }}
run: |
set -euo pipefail
: "${DEPLOY_HOST:?Missing DEPLOY_HOST secret}"
: "${DEPLOY_USER:?Missing DEPLOY_USER secret}"
: "${DEPLOY_SSH_KEY:?Missing DEPLOY_SSH_KEY secret}"
mkdir -p .ci
ssh deploy "cat '$DEPLOY_PATH/.dependency-versions' 2>/dev/null || true" > .ci/previous-dependency-versions
ssh deploy "cd '$DEPLOY_PATH' 2>/dev/null && sha256sum -- *.jar *.zip 2>/dev/null || true" > .ci/deployed-hashes
port="${DEPLOY_PORT:-22}"
commit="$(ssh deploy "cat '$DEPLOY_PATH/.deployed-commit' 2>/dev/null || true")"
if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then
echo "No valid deployed commit found in ${DEPLOY_PATH}"
commit=""
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$port" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
echo "Deployed commit: ${commit:-none}"
echo "commit=${commit}" >> "$GITHUB_OUTPUT"
ssh -i ~/.ssh/deploy_key -p "$port" "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p '$DEPLOY_PATH'"
ssh -i ~/.ssh/deploy_key -p "$port" "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p '$DEPLOY_PATH/upload'"
scp -i ~/.ssh/deploy_key -P "$port" deploy/* "${DEPLOY_USER}@${DEPLOY_HOST}:$DEPLOY_PATH/upload"
ssh -i ~/.ssh/deploy_key -p "$port" "${DEPLOY_USER}@${DEPLOY_HOST}" "rm -f '$DEPLOY_PATH'/*.jar '$DEPLOY_PATH'/*.zip"
ssh -i ~/.ssh/deploy_key -p "$port" "${DEPLOY_USER}@${DEPLOY_HOST}" "mv '$DEPLOY_PATH'/upload/* '$DEPLOY_PATH'"
ssh -i ~/.ssh/deploy_key -p "$port" "${DEPLOY_USER}@${DEPLOY_HOST}" "rm -r '$DEPLOY_PATH/upload'"
ssh -i ~/.ssh/deploy_key -p "$port" "${DEPLOY_USER}@${DEPLOY_HOST}" "chmod o-w '$DEPLOY_PATH'/*"
- name: Restart Services
- name: Determine affected projects
id: affected
shell: bash
run: bash .gitea/scripts/affected-projects.sh .ci "${{ steps.deployed.outputs.commit }}"
- name: Build with Gradle
if: steps.affected.outputs.tasks != ''
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
TASKS: ${{ steps.affected.outputs.tasks }}
run: |
set -euo pipefail
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" "${DEPLOY_USER}@${DEPLOY_HOST}" "sudo systemctl restart api.service"
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" "${DEPLOY_USER}@${DEPLOY_HOST}" "unzip -o /jars/current/sw.zip -d /jars"
read -ra tasks <<< "$TASKS"
./gradlew clean "${tasks[@]}" --no-daemon --no-build-cache --refresh-dependencies
# Only jars whose content differs from the deployed one, so dependents that merely compile against a change stay untouched
- name: Stage changed artifacts
shell: bash
run: bash .gitea/scripts/stage-artifacts.sh .ci .ci/jars "${{ steps.affected.outputs.projects }}"
- name: Deploy
shell: bash
env:
DEPLOY_PATH: ${{ steps.target.outputs.path }}
run: |
set -euo pipefail
ssh deploy "mkdir -p '$DEPLOY_PATH'"
if compgen -G ".ci/jars/*" > /dev/null; then
ssh deploy "rm -rf '$DEPLOY_PATH/upload' && mkdir -p '$DEPLOY_PATH/upload'"
scp .ci/jars/* "deploy:$DEPLOY_PATH/upload"
ssh deploy "mv -f '$DEPLOY_PATH'/upload/* '$DEPLOY_PATH' && rm -r '$DEPLOY_PATH/upload' && chmod o-w '$DEPLOY_PATH'/*"
else
echo "No changed artifacts to deploy"
fi
# The next run only builds what changed since this commit
scp .ci/dependency-versions "deploy:$DEPLOY_PATH/.dependency-versions"
ssh deploy "echo '$GITHUB_SHA' > '$DEPLOY_PATH/.deployed-commit'"
# The afterDeploy commands of the deploy blocks of all projects with changed artifacts
- name: Run after deploy commands
if: github.ref_name == 'main'
shell: bash
env:
DEPLOY_PATH: ${{ steps.target.outputs.path }}
run: |
set -euo pipefail
while read -r project command; do
if grep -qxF "$project" .ci/deployed-projects; then
echo "Running '${command}' for ${project}"
ssh -n deploy "cd '$DEPLOY_PATH' && ${command}"
fi
done < .ci/deploy-commands
+21 -25
View File
@@ -14,35 +14,31 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v6
- name: Setup Java 8
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 8
- name: Setup Java 11
uses: actions/setup-java@v5
fetch-depth: 0
- name: Setup build
uses: ./.gitea/actions/setup-build
with:
distribution: 'temurin'
java-version: 11
- name: Setup Java 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 17
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Setup Maven Repository
env:
SW_MAVEN_CREDENTIALS: ${{ secrets.SW_MAVEN_CREDENTIALS }}
maven-credentials: ${{ secrets.SW_MAVEN_CREDENTIALS }}
- name: Determine affected projects
id: affected
shell: bash
run: |
echo "$SW_MAVEN_CREDENTIALS" > steamwar.properties
set -euo pipefail
base_branch="$(jq -r '.pull_request.base.ref' "$GITHUB_EVENT_PATH")"
base="$(git merge-base "origin/${base_branch}" HEAD || true)"
bash .gitea/scripts/affected-projects.sh .ci "$base"
- name: Build with Gradle
run: ./gradlew build --no-daemon
if: steps.affected.outputs.tasks != ''
shell: bash
env:
TASKS: ${{ steps.affected.outputs.tasks }}
run: |
set -euo pipefail
read -ra tasks <<< "$TASKS"
./gradlew "${tasks[@]}" --no-daemon
- name: Merge successful backport PR
shell: bash
+5
View File
@@ -20,6 +20,11 @@
plugins {
`java-library`
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar)
}
dependencies {
+5
View File
@@ -20,6 +20,11 @@
plugins {
`java-library`
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar)
}
tasks.build {
+6
View File
@@ -1,6 +1,12 @@
plugins {
steamwar.kotlin
application
deploy
}
deploy {
artifact(tasks.distZip, "sw.zip")
afterDeploy("unzip -o sw.zip -d /jars")
}
kotlin {
+5
View File
@@ -20,6 +20,11 @@
plugins {
`java-library`
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar)
}
tasks.build {
+5
View File
@@ -22,6 +22,11 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
steamwar.kotlin
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar)
}
tasks.build {
+5
View File
@@ -20,6 +20,11 @@
plugins {
steamwar.java
widener
deploy
}
deploy {
artifact(tasks.jar)
}
dependencies {
+5
View File
@@ -19,6 +19,11 @@
plugins {
steamwar.java
deploy
}
deploy {
artifact(tasks.jar)
}
dependencies {
+5
View File
@@ -19,6 +19,11 @@
plugins {
steamwar.java
deploy
}
deploy {
artifact(tasks.jar, "RealTime.jar")
}
dependencies {
+5
View File
@@ -21,6 +21,11 @@ plugins {
`java-library`
steamwar.java
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar)
}
tasks.build {
+5
View File
@@ -20,6 +20,11 @@
plugins {
`java-library`
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar)
}
tasks.build {
+5
View File
@@ -19,6 +19,11 @@
plugins {
steamwar.kotlin
deploy
}
deploy {
artifact(tasks.jar)
}
kotlin {
+5
View File
@@ -19,6 +19,11 @@
plugins {
steamwar.java
deploy
}
deploy {
artifact(tasks.jar, "Builder.jar")
}
dependencies {
+5
View File
@@ -19,6 +19,11 @@
plugins {
steamwar.java
deploy
}
deploy {
artifact(tasks.jar)
}
dependencies {
@@ -21,6 +21,11 @@ plugins {
steamwar.java
steamwar.kotlin
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar, "DependenciesVelocityCore.jar")
}
tasks.shadowJar {
+5
View File
@@ -19,6 +19,11 @@
plugins {
steamwar.java
deploy
}
deploy {
artifact(tasks.jar, "PersistentVelocityCore.jar")
}
dependencies {
+5
View File
@@ -20,6 +20,11 @@
plugins {
steamwar.java
alias(libs.plugins.shadow)
deploy
}
deploy {
artifact(tasks.shadowJar)
}
tasks.build {
+6
View File
@@ -22,6 +22,12 @@ plugins {
id("io.ktor.plugin") version "2.3.12"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.10"
application
deploy
}
deploy {
artifact(tasks.shadowJar, "website-api.jar")
afterDeploy("sudo systemctl restart api.service")
}
kotlin {
+29
View File
@@ -19,11 +19,40 @@
plugins {
base
affected
}
// Files shared by all projects, every project is affected when the root project is
affected {
files.set(
listOf(
"build.gradle.kts",
"settings.gradle.kts",
"gradle.properties",
"gradlew",
"gradlew.bat",
"gradle/",
"buildSrc/",
".gitea/workflows/",
".gitea/actions/",
".gitea/scripts/",
)
)
}
allprojects {
group = "de.steamwar"
version = ""
// Byte identical archives for identical inputs, so CI only deploys jars whose content changed
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
}
subprojects {
apply(plugin = "affected")
}
tasks.register("0_ClearAllGradle") {
@@ -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)
}
}
+42
View File
@@ -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(