name: PR Compile Check on: pull_request: branches: - main workflow_dispatch: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: compile: name: Compile (Java 21) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 - name: Set up Java 21 uses: actions/setup-java@v6.1.0 with: distribution: temurin java-version: "21" - name: Ensure gradlew is executable run: chmod +x gradlew # classes + testClasses rather than compileJava + compileTestJava. The old pair did # cover Kotlin -- checked with a deliberately broken Kotlin test file, and also in # --dry-run: the Kotlin plugin makes compileTestJava depend on compileTestKotlin, # because Java has to see the Kotlin output. But that is the plugin's wiring and not # this job's statement of intent, and it covered no resources. These two aggregates # say "compile everything in all three modules" and do not depend on knowing that. # # They also run the translation generator, which app-desktop's compileKotlin depends # on. That is why a pull request contributing a language needs no workflow of its own: # an orphan key, a dropped placeholder or a duplicate fails here, with the generator's # own message naming the file. - name: Compile run: ./gradlew -S classes testClasses # Report only: both tools run with ignoreFailures, so a finding cannot fail this step # -- a crash of the tool itself still does, which is wanted. The findings used to exist # only in the console log; since #110 they are files, and the table and the artifact # below are what makes them readable from a pull request. - name: Static analysis (report only) run: ./gradlew -S pmdMain spotbugsMain # Counted from the report XML, never from Gradle's output or exit code. A missing # report prints "no report" and does not fail the step: that case is already visible # in the step above. - name: Summarise the static analysis if: always() run: | set -euo pipefail shopt -s nullglob count() { local file="$1" element="$2" if [ -f "$file" ]; then # grep exits 1 on zero matches, which pipefail would turn into a failed step. { grep -o "<$element " "$file" || true; } | wc -l | tr -d ' ' else echo "no report" fi } { echo "### Static analysis" echo "" echo "| module | PMD violations | SpotBugs bugs |" echo "|---|---|---|" } >> "$GITHUB_STEP_SUMMARY" for module in core app-desktop i18n-generator; do pmd=$(count "$module/build/reports/pmd/main.xml" violation) spotbugs=$(count "$module/build/reports/spotbugs/main.xml" BugInstance) echo "$module: pmd=$pmd spotbugs=$spotbugs" echo "| \`$module\` | $pmd | $spotbugs |" >> "$GITHUB_STEP_SUMMARY" done { echo "" echo "Report only: nothing fails on a finding. The XML and HTML reports are the" echo "static-analysis artifact of this run. i18n-generator is Kotlin only, so PMD" echo "has no Java source there and writes no report." } >> "$GITHUB_STEP_SUMMARY" - name: Keep the static analysis reports if: always() uses: actions/upload-artifact@v7 with: name: static-analysis path: | */build/reports/pmd/ */build/reports/spotbugs/ if-no-files-found: ignore retention-days: 14 # What a translation PR is actually about, put where its author will look. The counts # come from the files rather than from Gradle's output, so an up-to-date task cannot # make the table disappear. - name: Summarise the interface texts if: always() run: | set -euo pipefail shopt -s nullglob base="app-desktop/src/main/i18n/strings_en.properties" if [ ! -f "$base" ]; then echo "no base translation file at $base" >&2 exit 1 fi count_keys() { grep -cE '^[^#!][^=]*=' "$1" || true; } total=$(count_keys "$base") { echo "### Interface texts" echo "" echo "| language | translated | of | missing |" echo "|---|---|---|---|" } >> "$GITHUB_STEP_SUMMARY" for file in app-desktop/src/main/i18n/strings_*.properties; do lang=$(basename "$file" .properties); lang=${lang#strings_} have=$(count_keys "$file") missing=$(( total - have )) [ "$missing" -lt 0 ] && missing=0 echo "| \`$lang\` | $have | $total | $missing |" >> "$GITHUB_STEP_SUMMARY" done { echo "" echo "A language may be incomplete: a key it leaves out falls back to English," echo "which is what lets a translation be contributed a piece at a time. German is" echo "held complete by a test, because it ships." } >> "$GITHUB_STEP_SUMMARY" worksheets=(app-desktop/build/generated/i18n/todo/*.todo.properties) if [ ${#worksheets[@]} -gt 0 ]; then { echo "" echo "The build wrote a worksheet of what is still missing for:" echo "" } >> "$GITHUB_STEP_SUMMARY" for w in "${worksheets[@]}"; do echo "- \`$(basename "$w")\`" >> "$GITHUB_STEP_SUMMARY" done fi test: name: Test (Java 21, headless) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 - name: Set up Java 21 uses: actions/setup-java@v6.1.0 with: distribution: temurin java-version: "21" - name: Ensure gradlew is executable run: chmod +x gradlew # Skiko's native library links against libGL, so it has to be loadable even though # nothing here renders through it: the drawing tests use Skia's software backend and # runComposeUiTest never opens a window. Without this the whole Compose half of the # suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object # file" -- which reads like a missing display and is not one. Found by running this # job under act, where the container has no libGL; GitHub's own runner image happens # to ship it, so the step is what makes the job reproducible off GitHub. - name: Install the OpenGL client library skiko links against run: | sudo apt-get update sudo apt-get install -y --no-install-recommends libgl1 # No Xvfb, deliberately. Everything that pins behaviour runs without a display: # runComposeUiTest reaches the semantics tree headless (measured before this job was # written), and the ImageComposeScene drawing tests render in software. The handful # of tests that open a real window guard themselves with assumeFalse(isHeadless) and # skip here; the summary below prints the skip count, so that is visible rather than # assumed. # # Software GL under Xvfb was considered and rejected: a missing Skia GL context is # the hardest failure in this codebase to read, and importing that class of flake # into CI would cost more than the few window tests are worth here. # All three modules. The translation generator is a Gradle module and not buildSrc # precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew # build` -- measured, not assumed. The summary below globs */build/test-results, so # the module was counted the moment it existed. - name: Test run: ./gradlew -S test # Coverage is a report, not a gate: no threshold, nothing in it can fail on a # percentage. Deliberately not `if: always()` -- without a completed test run the # report would be partial, and after a failed test the result XML below is what matters. # jacocoTestReport writes the per-module XML the summary reads; the aggregate merges # core and app-desktop (i18n-generator is build tooling and stays out). - name: Coverage report run: ./gradlew -S jacocoTestReport jacocoAggregatedReport - name: Summarise the test results if: always() run: | set -euo pipefail shopt -s nullglob files=(*/build/test-results/test/TEST-*.xml) if [ ${#files[@]} -eq 0 ]; then echo "No test result XML was produced -- the test task did not run." >&2 exit 1 fi # Counted from the XML and not from Gradle's console output: this project's own # rule is never to infer "all tests passed" from an exit code. summary=$(grep -h "> "$GITHUB_STEP_SUMMARY" if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then echo "The result XML reports failures or errors." >&2 exit 1 fi - name: Summarise the coverage if: always() run: | set -euo pipefail # The last LINE/BRANCH counter in a JaCoCo XML is the report-level total; the # per-package and per-class counters come before it. percent() { local file="$1" type="$2" { grep -o "]*/>" "$file" || true; } | tail -1 \ | sed 's/.*missed="\([0-9]*\)".*covered="\([0-9]*\)".*/\1 \2/' \ | awk 'NF == 2 { t=$1+$2; if (t==0) print "n/a"; else printf "%.1f%% (%d/%d)", 100*$2/t, $2, t }' } row() { local label="$1" file="$2" if [ -f "$file" ]; then echo "| $label | $(percent "$file" LINE) | $(percent "$file" BRANCH) |" else echo "| $label | no report | no report |" fi } { echo "" echo "### Coverage" echo "" echo "| module | lines | branches |" echo "|---|---|---|" row "core" core/build/reports/jacoco/test/jacocoTestReport.xml row "app-desktop" app-desktop/build/reports/jacoco/test/jacocoTestReport.xml row "core + app-desktop" build/reports/jacoco/aggregated/jacoco.xml echo "" echo "Report only: nothing fails on a percentage. The aggregated HTML report is the" echo "coverage-report artifact of this run. i18n-generator is build tooling and is" echo "not counted." } >> "$GITHUB_STEP_SUMMARY" - name: Keep the coverage report if: always() uses: actions/upload-artifact@v7 with: name: coverage-report path: build/reports/jacoco/aggregated/html/ if-no-files-found: ignore retention-days: 14 - name: Keep the test reports on a failure if: failure() uses: actions/upload-artifact@v7 with: name: test-results path: | */build/test-results/test/ */build/reports/tests/test/ if-no-files-found: ignore retention-days: 7