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 reportMam 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 reportI 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 nativeCompileWynik? ~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:
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 }} versionGitHub Action – setup-jacoco-cli
Mając binarki, naturalnym krokiem jest ułatwienie ich użycia w CI. Napisałem GitHub Action, który:
- Wykrywa OS i architekturę runnera
- Pobiera odpowiednią binarkę z GitHub Releases
- 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 reportAkcja 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-cliFormula
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 endAutomatyczne aktualizacje
Przy każdym utwrzeniu releasu w jacoco-cli, workflow dispatchuje event do repozytorium homebrew-tap. Tam GitHub Actions:
- Pobiera metadane releasu (wersja, SHA256 artefaktów)
- Generuje nową formułę
- 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:
- jacoco-cli – główny projekt
- setup-jacoco-cli – GitHub Action
- homebrew-tap – Homebrew Formula








