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

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:

  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, że bariera między „mam JAR” a „mam narzędzie CLI” praktycznie nie istnieje.

Kod źródłowy:


Autor Bartosz Gałek

Tak jak Obelix nie potrzebował magicznego napoju do bycia silnym, tak Bartosz nie potrzebuje kawy do tego, aby tryskać energią na prawo i lewo i zarażać nią zespół, któremu przewodzi jako Team Leader w Allegro.
Prywatnie, ogromny fan gier planszowych, zwolennik i współtwórca otwartego oprogramowania oraz ruchu DevOps.

Zostaw odpowiedź