JaCoCo CLI – natywna binarka, której brakowało

detektywi.it 1 tydzień temu

Znacie ten moment, kiedy chcecie coś gwałtownie sprawdzić, a okazuje się, iż proste rzeczy wcale nie są takie proste?

Potrzebowałem przeanalizować raport pokrycia kodu z JaCoCo. Nic wielkiego – odpalić parser, wyświetlić raport w komentarzu do PR. Jest do tego wiele akcji na gh – np. https://github.com/marketplace/actions/jacoco-report Problem w tym, iż Gradle domyślnie generuje raporty w formacie .exec – binarnym formacie JaCoCo, który nie jest zwykle wspierany. Jasne, można w każdym projekcie dodać eksport do XML:

tasks.jacocoTestReport { reports { xml.required = true } }

Ale kto chce to robić w każdym projekcie? Nie ja.

Parser istnieje – ale…

JaCoCo dostarcza CLI do parsowania raportów .exec. Oficjalnie. Jest choćby na Maven Central jako org.jacoco:org.jacoco.cli. Jedyny problem? To JAR. Żeby go odpalić, trzeba mieć Javę:

java -jar jacococli.jar report coverage.exec --classfiles build/classes --html report

Mam Javę na swojej maszynie? Mam. Ale czy chcę odpalać java -jar za każdym razem? Nie. Czy chcę pamiętać, gdzie leży ten JAR? Też nie. Chciałbym po prostu wpisać:

jacoco-cli report coverage.exec --classfiles build/classes --html report

I dostać wynik. Bez JVM, bez jarów, bez ceremonii. (i bez skomplikowanych aliasów!)
Szczególnie, iż w dobie agentów AI, CLI mają swój renesans :)

GraalVM na ratunek

Jeśli macie JAR i chcecie z niego zrobić natywną binarkę – GraalVM jest do tego idealny. Kompiluje bajtkod Javy do natywnego kodu maszynowego. Zero JVM w runtimie, start w milisekundach.

Cały projekt to dosłownie jeden plik Javy i konfiguracja Gradle’a.

Wrapper w mniej niż 50 linii kodu

Jedyny plik źródłowy w projekcie:

package org.jacoco.cli.internal; import java.io.OutputStream; import java.io.PrintWriter; public class App { static void main(String[] args) throws Exception { final PrintWriter out = new ReplacingPrintWriter(new PrintWriter(System.out, true)); final PrintWriter err = new ReplacingPrintWriter(new PrintWriter(System.err, true)); final int returncode = new Main(args).execute(out, err); System.exit(returncode); } private static class ReplacingPrintWriter extends PrintWriter { public ReplacingPrintWriter(PrintWriter delegate) { super(delegate, true); } @Override public void println(String x) { super.println(x.replace("java -jar jacococli.jar", "jacoco-cli")); } } }

Delegujemy do oryginalnego Main z JaCoCo, ale owijamy stdout i stderr w ReplacingPrintWriter, który zamienia java -jar jacococli.jar na jacoco-cli w tekście pomocy. Detal, ale uznałem, iż się przyda. Jak dostałem się do package-scoped klasy Main? Użyłem tego samego pakietu org.jacoco.cli.internal :)

build.gradle.kts

plugins { application id("org.graalvm.buildtools.native") version "1.0.0" } application { mainClass = "org.jacoco.cli.internal.App" } graalvmNative { binaries.all { resources.autodetect() } binaries { named("main") { imageName.set("jacoco-cli") mainClass.set("org.jacoco.cli.internal.App") buildArgs.add("-H:IncludeResourceBundles=org.kohsuke.args4j.Messages") buildArgs.add("-H:IncludeResourceBundles=org.jacoco.core.jacoco") buildArgs.add("-H:IncludeResourceBundles=org.jacoco.cli.internal.Messages") javaLauncher.set(javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) }) } } } dependencies { implementation("org.jacoco:org.jacoco.cli:0.8.14") }

JaCoCo CLI używa biblioteki args4j do parsowania argumentów. args4j korzysta z refleksji – a GraalVM przy kompilacji AOT tego nie wykryje. Trzeba manualnie zadeklarować, które klasy będą refleksyjnie dostępne.

Plik reflect-config.json w src/main/resources/META-INF/native-image/ deklaruje 36 klas – komendy JaCoCo (Report, Merge, Dump, Instrument) i handlery args4j. Bez tego binarka się skompiluje, ale przy próbie użycia rzuci wam ClassNotFoundException.

IncludeResourceBundles potrzebne jest również, by na classpath do kompilacji trafiły używane przez refleksję pakiety.

Kompilacja

./gradlew nativeCompile

Wynik? ~17 MB w build/native/nativeCompile/jacoco-cli.
Gotowa do użycia, bez żadnych zależności.

Budowanie na cztery platformy

Samotna binarka na macOS to za mało.
Ludzie używają Linuxa, Windowsa, różnych architektur. GitHub Actions i matrix sprawdzą się idealnie:

strategy: matrix: include: - os: linux arch: x86_64 runner: ubuntu-latest binary: jacoco-cli - os: linux arch: aarch64 runner: ubuntu-24.04-arm binary: jacoco-cli - os: macos arch: aarch64 runner: macos-latest binary: jacoco-cli - os: windows arch: x86_64 runner: windows-latest binary: jacoco-cli.exe

Cztery konfiguracje, automatyczny build, upload artefaktów do GitHub Release. Przy okazji – smoke test na każdej platformie:

- name: Smoke test run: ./build/native/nativeCompile/${{ matrix.binary }} version

GitHub Action – setup-jacoco-cli

Mając binarki, naturalnym krokiem jest ułatwienie ich użycia w CI. Napisałem GitHub Action, który:

  1. Wykrywa OS i architekturę runnera
  2. Pobiera odpowiednią binarkę z GitHub Releases
  3. Dodaje ją do PATH

Użycie w workflow:

steps: - uses: bgalek/setup-jacoco-cli@v1 - run: jacoco-cli report coverage.exec --classfiles build/classes --html report

Akcja jest composite action – bash, bez Dockera, bez Node’a. Obsługuje latest (pobiera najnowszą wersję z API) i pinowanie konkretnej wersji.

Cały action.yml to wykrywanie platformy, resolving wersji i pobranie binarki. Nic więcej. Takie rzeczy powinny być nudne.

Homebrew Tap

Ostatni element układanki – dystrybucja na macOS i Linux przez Homebrew:

brew install bgalek/tap/jacoco-cli

Formula

Formula jest minimalna – pobiera prekompilowaną binarkę dla odpowiedniej platformy:

class JacocoCli < Formula desc "Native CLI for JaCoCo code coverage tools (no JVM required)" homepage "https://github.com/bgalek/jacoco-cli" license "EPL-2.0" version "v0.0.1" on_macos do on_arm do url "https://github.com/bgalek/jacoco-cli/releases/download/v0.0.1/jacoco-cli-macos-aarch64" sha256 "965ab1db..." end end on_linux do on_arm do url "https://github.com/bgalek/jacoco-cli/releases/download/v0.0.1/jacoco-cli-linux-aarch64" sha256 "b46c9370..." end on_intel do url "https://github.com/bgalek/jacoco-cli/releases/download/v0.0.1/jacoco-cli-linux-x86_64" sha256 "20a259c7..." end end def install bin.install stable.url.split("/").last => "jacoco-cli" end test do assert_match "JaCoCo", shell_output("#{bin}/jacoco-cli version") end end

Automatyczne aktualizacje

Przy każdym utwrzeniu releasu w jacoco-cli, workflow dispatchuje event do repozytorium homebrew-tap. Tam GitHub Actions:

  1. Pobiera metadane releasu (wersja, SHA256 artefaktów)
  2. Generuje nową formułę
  3. Commituje i pushuje

Nowy release w jacoco-cli → formuła w tapie zaktualizowana automatycznie.

Podsumowanie

Cały projekt to:

  • 1 plik Javy – wrapper na oryginalne CLI
  • 1 plik reflect-config.json – konfiguracja refleksji dla GraalVM
  • 1 build.gradle.kts – konfiguracja Native Image
  • GitHub Actions – build na 4 platformy, release, aktualizacja Homebrew
  • GitHub Action – setup dla CI
  • Homebrew Tap – dystrybucja

Od „chcę przeczytać raport .exec” do natywnej binarki z automatyczną dystrybucją. GraalVM sprawia, iż bariera między „mam JAR” a „mam narzędzie CLI” praktycznie nie istnieje.

Kod źródłowy:

Idź do oryginalnego materiału