diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8f81a54d2e..064c0dc997 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,65 +21,95 @@ jobs: build: runs-on: ubuntu-latest + timeout-minutes: 90 steps: - uses: actions/checkout@v6 + + # Same pattern as TDLM ForestryMC/IndustrialCraft2 setup-build: cache the JDK toolchain. - name: Set up JDK 17 uses: actions/setup-java@v6 with: distribution: 'liberica' java-version: '17' java-package: 'jdk+fx' + cache-jdk: true + + # Nest mirrors github.com/gradle/actions as actions/gradle-actions (also as gradle/actions). + # v5 rejects cache-provider; v6 supports it. Default on v6 is "enhanced" (commercial) — + # on Gitea use external and cache Gradle home with actions/cache below (org IC2/Forestry pattern). - name: Setup Gradle - # Nest hosts this under actions/gradle-actions (mirror of github.com/gradle/actions). - # uses: gradle/actions/... makes act clone git.nest…/gradle/actions which is not seeded. - uses: actions/gradle-actions/setup-gradle@v5 + uses: actions/gradle-actions/setup-gradle@v6 with: cache-cleanup: never - # Only main writes the dependency cache; pull requests read it. - cache-read-only: ${{ github.ref != 'refs/heads/main' }} - # The repository's wrapper downloads Gradle itself, so cache that download too. - - name: Cache Gradle distribution + cache-provider: ${{ github.server_url == 'https://github.com' && 'basic' || 'external' }} + + # Cache wrapper + dependency/build caches (covers what setup-gradle skips under external). + - name: Cache Gradle home uses: actions/cache@v4 with: path: | + ~/.gradle/caches ~/.gradle/wrapper .gradle/wrapper-neo - key: gradle-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }} - - name: Install Xvfb + key: gradle-home-${{ runner.os }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java', '**/libs.versions.toml', '**/*.gradle.kts', '**/*.gradle') }} + restore-keys: | + gradle-home-${{ runner.os }}- + + # Xvfb alone is not enough: JavaFX Glass needs GTK/X11 libs or Platform.startup fails with + # GtkPlatformFactory "Internal Error" and :HMCL:test can hang (looks like a checkstyle stall). + - name: Cache apt packages + uses: actions/cache@v4 + with: + path: ~/apt-cache + key: apt-javafx-xvfb-${{ runner.os }}-${{ runner.arch }}-1 + + - name: Install Xvfb and JavaFX GTK deps run: | - # Installing is the slowest setup step, so skip it when the runner already has xvfb, and - # only refresh the package index when a plain install fails. - command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \ - || { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; } + pkgs='xvfb libgtk-3-0 libxtst6 libxxf86vm1 libgl1 libxi6 libx11-6 libxext6 libxrender1' + sudo mkdir -p /var/cache/apt/archives/partial + if [ -d "${HOME}/apt-cache" ] && [ -n "$(ls -A "${HOME}/apt-cache" 2>/dev/null)" ]; then + sudo cp -a "${HOME}/apt-cache/." /var/cache/apt/archives/ + fi + # Prefer cached debs; refresh the index only when install cannot resolve packages. + sudo apt-get install -y --no-install-recommends $pkgs \ + || { sudo apt-get update && sudo apt-get install -y --no-install-recommends $pkgs; } + mkdir -p "${HOME}/apt-cache" + cp -a /var/cache/apt/archives/*.deb "${HOME}/apt-cache/" 2>/dev/null || true + # One Gradle run builds, tests and runs the code and translation checks, so the # project is configured and compiled once instead of in two workflows. - name: Build and check with Gradle run: xvfb-run --auto-servernum ./gradlew build checkstyle checkTranslations --no-daemon --parallel env: - JAVA_TOOL_OPTIONS: -Djava.awt.headless=true + JAVA_TOOL_OPTIONS: -Djava.awt.headless=true -Dprism.order=sw MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }} CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} + - name: Get short SHA run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + - name: Upload JAR uses: actions/upload-artifact@v7 with: name: HMCL-${{ env.SHORT_SHA }}-jar path: HMCL/build/libs/HMCL-*.jar archive: false + - name: Upload EXE uses: actions/upload-artifact@v7 with: name: HMCL-${{ env.SHORT_SHA }}-exe path: HMCL/build/libs/HMCL-*.exe archive: false + - name: Upload SH uses: actions/upload-artifact@v7 with: name: HMCL-${{ env.SHORT_SHA }}-sh path: HMCL/build/libs/HMCL-*.sh archive: false + - name: Upload DEB uses: actions/upload-artifact@v7 with: diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java b/HMCL/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java index db0aeb0a3f..74eec7304f 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java @@ -10,6 +10,9 @@ import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; /// Owns the shared JavaFX toolkit for the lifetime of the test process. @NotNullByDefault @@ -27,11 +30,36 @@ public final class JavaFXLauncher { System.setProperty("hmcl.scroll.speed", "1"); try { initializeAnimationPolicy(); - Platform.startup(() -> { - }); - started = true; - } catch (IllegalStateException e) { - started = true; + // Platform.startup can hang when GTK/Glass is broken (minimal CI images with only xvfb). + // Bound class-init so :HMCL:test cannot stall the whole Gradle build. + CountDownLatch done = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + Thread t = new Thread(() -> { + try { + Platform.startup(() -> { + }); + } catch (Throwable e) { + error.set(e); + } finally { + done.countDown(); + } + }, "hmcl-javafx-startup"); + t.setDaemon(true); + t.start(); + if (!done.await(45, TimeUnit.SECONDS)) { + System.err.println("JavaFX Platform.startup timed out; JavaFX tests will be skipped"); + } else { + Throwable e = error.get(); + if (e == null) { + started = true; + } else if (e instanceof IllegalStateException) { + started = true; + } else { + e.printStackTrace(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } catch (Throwable e) { e.printStackTrace(); } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java index 334fe46d5d..6afcb63125 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/JavaFXLauncher.java @@ -2,6 +2,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL package org.jackhuang.hmcl; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + public final class JavaFXLauncher { private JavaFXLauncher() { @@ -10,13 +14,37 @@ public final class JavaFXLauncher { private static boolean started = false; static { - // init JavaFX Toolkit + // init JavaFX Toolkit — never block class init forever if GTK/Glass hangs under CI. try { - javafx.application.Platform.startup(() -> { - }); - started = true; - } catch (IllegalStateException e) { - started = true; + CountDownLatch done = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + Thread t = new Thread(() -> { + try { + javafx.application.Platform.startup(() -> { + }); + } catch (Throwable e) { + error.set(e); + } finally { + done.countDown(); + } + }, "hmcl-javafx-startup"); + t.setDaemon(true); + t.start(); + if (!done.await(45, TimeUnit.SECONDS)) { + System.err.println("JavaFX Platform.startup timed out; JavaFX tests will be skipped"); + } else { + Throwable e = error.get(); + if (e == null) { + started = true; + } else if (e instanceof IllegalStateException) { + // Toolkit already running in this JVM. + started = true; + } else { + e.printStackTrace(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } catch (Throwable e) { e.printStackTrace(); }