Znacie ten moment, kiedy chcecie coś szybko sprawdzić, a okazuje się, że 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, że Gradle domyślnie generuje raporty w formacie .exec – binarnym formacie JaCoCo, który nie jest zazwyczaj 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 nawet 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, że 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, że 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 ręcznie 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:
- 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 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:
- 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, że 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