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(