From cbb29875042253d950d8a929c07c9ecf04ab0d55 Mon Sep 17 00:00:00 2001 From: YoyoNow Date: Sun, 1 Nov 2026 09:17:08 +0100 Subject: [PATCH] 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. --- .gitea/actions/setup-build/action.yml | 29 +++ .gitea/actions/setup-ssh/action.yml | 51 +++++ .gitea/scripts/affected-projects.sh | 82 ++++++++ .gitea/scripts/stage-artifacts.sh | 32 ++++ .gitea/workflows/build.yml | 176 ++++++++---------- .gitea/workflows/pull-request-build.yml | 46 +++-- AccessWidener/build.gradle.kts | 5 + BauSystem/build.gradle.kts | 5 + CLI/build.gradle.kts | 6 + FightSystem/build.gradle.kts | 5 + KotlinCore/build.gradle.kts | 5 + LobbySystem/build.gradle.kts | 5 + MissileWars/build.gradle.kts | 5 + Realtime/build.gradle.kts | 5 + SchematicSystem/build.gradle.kts | 5 + SpigotCore/build.gradle.kts | 5 + TNTLeague/build.gradle.kts | 5 + Teamserver/build.gradle.kts | 5 + TowerRun/build.gradle.kts | 5 + VelocityCore/Dependencies/build.gradle.kts | 5 + VelocityCore/Persistent/build.gradle.kts | 5 + VelocityCore/build.gradle.kts | 5 + WebsiteBackend/build.gradle.kts | 6 + build.gradle.kts | 29 +++ buildSrc/src/main/kotlin/AffectedExtension.kt | 42 +++++ buildSrc/src/main/kotlin/Ci.kt | 42 +++++ buildSrc/src/main/kotlin/DeployExtension.kt | 52 ++++++ buildSrc/src/main/kotlin/affected.gradle.kts | 100 ++++++++++ buildSrc/src/main/kotlin/deploy.gradle.kts | 33 ++++ buildSrc/src/main/kotlin/widener.gradle.kts | 7 + 30 files changed, 683 insertions(+), 125 deletions(-) create mode 100644 .gitea/actions/setup-build/action.yml create mode 100644 .gitea/actions/setup-ssh/action.yml create mode 100755 .gitea/scripts/affected-projects.sh create mode 100755 .gitea/scripts/stage-artifacts.sh create mode 100644 buildSrc/src/main/kotlin/AffectedExtension.kt create mode 100644 buildSrc/src/main/kotlin/Ci.kt create mode 100644 buildSrc/src/main/kotlin/DeployExtension.kt create mode 100644 buildSrc/src/main/kotlin/affected.gradle.kts create mode 100644 buildSrc/src/main/kotlin/deploy.gradle.kts diff --git a/.gitea/actions/setup-build/action.yml b/.gitea/actions/setup-build/action.yml new file mode 100644 index 00000000..f094ba55 --- /dev/null +++ b/.gitea/actions/setup-build/action.yml @@ -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 diff --git a/.gitea/actions/setup-ssh/action.yml b/.gitea/actions/setup-ssh/action.yml new file mode 100644 index 00000000..1679a279 --- /dev/null +++ b/.gitea/actions/setup-ssh/action.yml @@ -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 < [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" diff --git a/.gitea/scripts/stage-artifacts.sh b/.gitea/scripts/stage-artifacts.sh new file mode 100755 index 00000000..62c46f0d --- /dev/null +++ b/.gitea/scripts/stage-artifacts.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# Usage: stage-artifacts.sh [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 (" " +# 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" diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 6aced9ef..1a4a59c1 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitea/workflows/pull-request-build.yml b/.gitea/workflows/pull-request-build.yml index 236da389..65b7d50b 100644 --- a/.gitea/workflows/pull-request-build.yml +++ b/.gitea/workflows/pull-request-build.yml @@ -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 diff --git a/AccessWidener/build.gradle.kts b/AccessWidener/build.gradle.kts index a6632cf1..eb6fc33d 100644 --- a/AccessWidener/build.gradle.kts +++ b/AccessWidener/build.gradle.kts @@ -20,6 +20,11 @@ plugins { `java-library` alias(libs.plugins.shadow) + deploy +} + +deploy { + artifact(tasks.shadowJar) } dependencies { diff --git a/BauSystem/build.gradle.kts b/BauSystem/build.gradle.kts index 6a6bfac5..80273b78 100644 --- a/BauSystem/build.gradle.kts +++ b/BauSystem/build.gradle.kts @@ -20,6 +20,11 @@ plugins { `java-library` alias(libs.plugins.shadow) + deploy +} + +deploy { + artifact(tasks.shadowJar) } tasks.build { diff --git a/CLI/build.gradle.kts b/CLI/build.gradle.kts index 9629fc04..4c188dbe 100644 --- a/CLI/build.gradle.kts +++ b/CLI/build.gradle.kts @@ -1,6 +1,12 @@ plugins { steamwar.kotlin application + deploy +} + +deploy { + artifact(tasks.distZip, "sw.zip") + afterDeploy("unzip -o sw.zip -d /jars") } kotlin { diff --git a/FightSystem/build.gradle.kts b/FightSystem/build.gradle.kts index cc5a3957..96d4b426 100644 --- a/FightSystem/build.gradle.kts +++ b/FightSystem/build.gradle.kts @@ -20,6 +20,11 @@ plugins { `java-library` alias(libs.plugins.shadow) + deploy +} + +deploy { + artifact(tasks.shadowJar) } tasks.build { diff --git a/KotlinCore/build.gradle.kts b/KotlinCore/build.gradle.kts index d3b98b47..2196be8f 100644 --- a/KotlinCore/build.gradle.kts +++ b/KotlinCore/build.gradle.kts @@ -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 { diff --git a/LobbySystem/build.gradle.kts b/LobbySystem/build.gradle.kts index d0d6b708..86f3ff5c 100644 --- a/LobbySystem/build.gradle.kts +++ b/LobbySystem/build.gradle.kts @@ -20,6 +20,11 @@ plugins { steamwar.java widener + deploy +} + +deploy { + artifact(tasks.jar) } dependencies { diff --git a/MissileWars/build.gradle.kts b/MissileWars/build.gradle.kts index f347a5ec..9174c87d 100644 --- a/MissileWars/build.gradle.kts +++ b/MissileWars/build.gradle.kts @@ -19,6 +19,11 @@ plugins { steamwar.java + deploy +} + +deploy { + artifact(tasks.jar) } dependencies { diff --git a/Realtime/build.gradle.kts b/Realtime/build.gradle.kts index 98ae77c9..5d24066b 100644 --- a/Realtime/build.gradle.kts +++ b/Realtime/build.gradle.kts @@ -19,6 +19,11 @@ plugins { steamwar.java + deploy +} + +deploy { + artifact(tasks.jar, "RealTime.jar") } dependencies { diff --git a/SchematicSystem/build.gradle.kts b/SchematicSystem/build.gradle.kts index b48fc815..7c0ef59e 100644 --- a/SchematicSystem/build.gradle.kts +++ b/SchematicSystem/build.gradle.kts @@ -21,6 +21,11 @@ plugins { `java-library` steamwar.java alias(libs.plugins.shadow) + deploy +} + +deploy { + artifact(tasks.shadowJar) } tasks.build { diff --git a/SpigotCore/build.gradle.kts b/SpigotCore/build.gradle.kts index 88c99684..5a5611f8 100644 --- a/SpigotCore/build.gradle.kts +++ b/SpigotCore/build.gradle.kts @@ -20,6 +20,11 @@ plugins { `java-library` alias(libs.plugins.shadow) + deploy +} + +deploy { + artifact(tasks.shadowJar) } tasks.build { diff --git a/TNTLeague/build.gradle.kts b/TNTLeague/build.gradle.kts index f3b78d69..aa924607 100644 --- a/TNTLeague/build.gradle.kts +++ b/TNTLeague/build.gradle.kts @@ -19,6 +19,11 @@ plugins { steamwar.kotlin + deploy +} + +deploy { + artifact(tasks.jar) } kotlin { diff --git a/Teamserver/build.gradle.kts b/Teamserver/build.gradle.kts index 2f563c7e..7b6eea60 100644 --- a/Teamserver/build.gradle.kts +++ b/Teamserver/build.gradle.kts @@ -19,6 +19,11 @@ plugins { steamwar.java + deploy +} + +deploy { + artifact(tasks.jar, "Builder.jar") } dependencies { diff --git a/TowerRun/build.gradle.kts b/TowerRun/build.gradle.kts index e1b3b5fb..faf0e5f2 100644 --- a/TowerRun/build.gradle.kts +++ b/TowerRun/build.gradle.kts @@ -19,6 +19,11 @@ plugins { steamwar.java + deploy +} + +deploy { + artifact(tasks.jar) } dependencies { diff --git a/VelocityCore/Dependencies/build.gradle.kts b/VelocityCore/Dependencies/build.gradle.kts index 34b43f07..7b734468 100644 --- a/VelocityCore/Dependencies/build.gradle.kts +++ b/VelocityCore/Dependencies/build.gradle.kts @@ -21,6 +21,11 @@ plugins { steamwar.java steamwar.kotlin alias(libs.plugins.shadow) + deploy +} + +deploy { + artifact(tasks.shadowJar, "DependenciesVelocityCore.jar") } tasks.shadowJar { diff --git a/VelocityCore/Persistent/build.gradle.kts b/VelocityCore/Persistent/build.gradle.kts index 34e0830d..1ea763e3 100644 --- a/VelocityCore/Persistent/build.gradle.kts +++ b/VelocityCore/Persistent/build.gradle.kts @@ -19,6 +19,11 @@ plugins { steamwar.java + deploy +} + +deploy { + artifact(tasks.jar, "PersistentVelocityCore.jar") } dependencies { diff --git a/VelocityCore/build.gradle.kts b/VelocityCore/build.gradle.kts index 7d09049f..2bb79342 100644 --- a/VelocityCore/build.gradle.kts +++ b/VelocityCore/build.gradle.kts @@ -20,6 +20,11 @@ plugins { steamwar.java alias(libs.plugins.shadow) + deploy +} + +deploy { + artifact(tasks.shadowJar) } tasks.build { diff --git a/WebsiteBackend/build.gradle.kts b/WebsiteBackend/build.gradle.kts index 76a3ca71..a2420dbf 100644 --- a/WebsiteBackend/build.gradle.kts +++ b/WebsiteBackend/build.gradle.kts @@ -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 { diff --git a/build.gradle.kts b/build.gradle.kts index 6b3a4548..645a62a3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true + } +} + +subprojects { + apply(plugin = "affected") } tasks.register("0_ClearAllGradle") { diff --git a/buildSrc/src/main/kotlin/AffectedExtension.kt b/buildSrc/src/main/kotlin/AffectedExtension.kt new file mode 100644 index 00000000..54a59f1b --- /dev/null +++ b/buildSrc/src/main/kotlin/AffectedExtension.kt @@ -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 . + */ + +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 + + /** + * Projects whose changes affect this project in addition to its project dependencies + */ + abstract val projects: SetProperty + + fun whenChanged(vararg paths: String) { + files.addAll(*paths) + } + + fun withProject(vararg paths: String) { + projects.addAll(*paths) + } +} diff --git a/buildSrc/src/main/kotlin/Ci.kt b/buildSrc/src/main/kotlin/Ci.kt new file mode 100644 index 00000000..d7904dc0 --- /dev/null +++ b/buildSrc/src/main/kotlin/Ci.kt @@ -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 . + */ + +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) { + 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" }) + } +} diff --git a/buildSrc/src/main/kotlin/DeployExtension.kt b/buildSrc/src/main/kotlin/DeployExtension.kt new file mode 100644 index 00000000..7bbc9d49 --- /dev/null +++ b/buildSrc/src/main/kotlin/DeployExtension.kt @@ -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 . + */ + +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 + + /** + * 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 + + fun artifact(task: TaskProvider, name: String = "${project.name}.jar") { + artifacts.put(name, task.flatMap { it.archiveFile }) + } + + fun afterDeploy(command: String) { + commands.add(command) + } +} diff --git a/buildSrc/src/main/kotlin/affected.gradle.kts b/buildSrc/src/main/kotlin/affected.gradle.kts new file mode 100644 index 00000000..60d3c0ee --- /dev/null +++ b/buildSrc/src/main/kotlin/affected.gradle.kts @@ -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 . + */ + +val affected = extensions.create("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() } + .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() + + 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(", ")}") + } + } +} diff --git a/buildSrc/src/main/kotlin/deploy.gradle.kts b/buildSrc/src/main/kotlin/deploy.gradle.kts new file mode 100644 index 00000000..bd173274 --- /dev/null +++ b/buildSrc/src/main/kotlin/deploy.gradle.kts @@ -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 . + */ + +val deploy = extensions.create("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()) + } +} diff --git a/buildSrc/src/main/kotlin/widener.gradle.kts b/buildSrc/src/main/kotlin/widener.gradle.kts index c85ae3d5..4b901cc0 100644 --- a/buildSrc/src/main/kotlin/widener.gradle.kts +++ b/buildSrc/src/main/kotlin/widener.gradle.kts @@ -17,6 +17,13 @@ * along with this program. If not, see . */ +plugins { + id("affected") +} + +// The AccessWidener jar is only used through a task dependency +the().withProject(":AccessWidener") + val widener = extensions.create("widener") widener.accessWidenerFiles.setFrom(