In this article, I’m going to demonstrate how to create a GitHub Action for building Java native images for Linux, macOS, and Windows.
The source code is available on GitHub.
Table of Contents
What is PlantUML and why I chose this project for demonstration
As an example application, I will use PlantUML. It is a GUI Java Swing application that enables you to create diagrams from plain text. You can create diagrams on the website or use PlantUML as a GUI desktop application.
If you want to code along, you can download the JAR file with PlantUML GPLv2 here. Note that if you want to run PlantUML on macOS, you will need to install Graphviz. If you use Homebrew, you can do that by running brew install graphviz
.
Let’s see PlantUML in action. Create a *.puml file in the same directory where PlantUML JAR resides with the following content:
@startuml
skin rose
actor User
actor Administrator
User -> [Customer]
[Customer] -> [Shipment]
[Customer] -> [Calculator]
[Admin] -> Customer
[Admin] -> Shipment
@enduml
Run the JAR to verify that the app works:
java -jar plantuml-gplv2-1.2024.6.jar
The following window will pop up.
PlantUML User Interface
By clicking on the file, you should see a generated diagram.
Example Diagram with PlantUML
So why did I choose this program to demonstrate GraalVM Native Image?
Firstly, using GraalVM Native Image with PlantUML resulted in two times faster startup and operation of the application. Another reason for choosing this program was to show how to efficiently tackle issues related to collecting full resource metadata for the Native Image compiler.
Collecting metadata for Native Image: what could go wrong?
As PlantUML is a Swing application, you can most conveniently turn it into a native image with Liberica Native Image Kit that comes with Swing unlike other GraalVM distributions.
Download Liberica Native Image Kit Full for your platform. You can add Contents/Home/bin subdirectory to $PATH, or remember /Library/Java/LibericaNativeImageKit/bellsoft-liberica-vm-openjdk21-23.1.4/Contents/Home/ as the $NIK_HOME environment variable. In this tutorial, I will use $NIK_HOME.
Before we go any further, a quick reminder: GraalVM Native Image is not compatible with the dynamic features of Java. All classes, resources, etc. should be reachable at build-time, or else they won’t get into the native executable.
So if you will try to convert PlantUML into a native image with a standard command native-image -jar ./your.jar
$NIK_HOME/bin/native-image --no-fallback --report-unsupported-elements-at-runtime -jar ./plantuml-gplv2-1.2024.6.jar
The image will be built successfully. However, if you try to run it
./plantuml-gplv2-1.2024.6 my.puml
Note: my.puml here is just an example of a PlantUML diagram.
The application will fail to start. Apparently, some essential resources didn’t get into the native executable.
One solution is to use the Tracing Agent, which tracks the usage of dynamic features and resources during application execution and then creates config files with the collected metadata in a specified output directory:
$NIK_HOME/bin/java -agentlib:native-image-agent=config-output-dir=./puml-agent-data -jar plantuml-gplv2-1.2024.6.jar
After that, you should place the config files to the META-INF/native-image/ directory on the class path, where they will be reachable to the native-image tool:
$NIK_HOME/bin/native-image -H:ConfigurationFileDirectories=./puml-agent-data -jar plantuml-gplv2-1.2024.6.jar
If you run the app now, it will start. Success? Yes and no.
The problem is that the Tracing Agent does a good job at detecting resources, but to get a complete set of metadata, you need to run your application with different execution paths and covering as much possible input as possible.
In my case, I faced another problem. When I tried to build native images with GitHub Actions, they all had only headless mode because I couldn’t specify the -Djava.awt.headless=true
option. It is understandable because it’s not possible to run the app with a GUI during the build process on GitHub. But how do we tackle the issue?
We can try to collect the resource metadata manually. But we want to minimize manual labor: after all, there can be thousands of resources! Luckily, there’s another way to provide metadata to native-image
.
Add support for Native Image to the build script
Let’s update the build.gradle file with some essential configs.
First, we need to add the official GraalVM plugin and the application plugin:
plugins {
java
`maven-publish`
signing
id("org.graalvm.buildtools.native") version "0.10.2"
application
}
One more block specifies the main class of the application (that’s why we needed the application plugin):
application {
mainClass = "net.sourceforge.plantuml.Run"
}
And finally, we add another block to configure Native Image:
graalvmNative {
binaries.all {
resources.autodetect()
buildArgs(listOf("-Djava.awt.headless=false"))
}
toolchainDetection = false
}
Here, we specify that for all binaries we build:
- We want resources to be autodetected and
- We pass an argument
-Djava.awt.headless=false
.
How does the resource autodetection plugin work? By default, it detects resources available in the src/main/resources, but it can also detect the resources in other location specified in the script:
sourceSets {
main {
java {
srcDirs("build/generated/sjpp")
}
resources {
srcDirs("build/sources/sjpp/java")
include("**/graphviz.dat")
include("**/*.png")
include("**/*.svg")
include("**/*.txt")
}
}
}
As a result, it finds all resources required for the application to run with a GUI, which turned out to be about 1,500 files for PlantUML. And in the case of this application, it detects way more resources than Tracing Agent. Theoretically, it might be possible to detect all these resources with Tracing Agent, but to do that, we must run the app through all possible scenarios and execution paths, which doesn’t seem practical, at least, not always.
Therefore, using a plugin for resource autodetection enables us to collect all necessary metadata without much effort.
Create a GitHub Action workflow file for GraalVM Native Image
The next step is to write a GitHub Action file.
Let’s look at the GitHub Actions workflow file (you can find it under the name native-image.yml in the repository).
At the Build Native Image stage, it includes instructions on building binaries for four platforms using a GitHub Action for GraalVM. We specify explicitly that we want to use CE-based Liberica Native Image Kit for Java 21.
In the case of this application, it is essential to use Liberica NIK because it supports AWT. Another NIK feature that you may find beneficial is ParallelGC, which is absent in the GraalVM Community version.
At the Build stage, we run ./gradlew :plantuml-gplv2:nativeCompile -x test
to generate a native image skipping the tests.
At the Archive Release stage, we pack the native executable into a ZIP archive specifying the name for each platform.
Finally, at the Upload binaries to release stage, we load the artifact for each operating system to the release.
name: Native Image
on:
workflow_dispatch:
push:
branches:
- master
jobs:
build_non_win_images:
name: 'Build Native Image ${{ matrix.platform }}'
strategy:
matrix:
os: [ macos-latest, windows-latest, ubuntu-latest ]
include:
- os: 'ubuntu-latest'
platform: 'linux-amd64'
- os: 'macos-latest'
platform: 'darwin-arm64'
- os: 'macos-13'
platform: 'darwin-amd64'
- os: 'windows-latest'
platform: 'win-amd64'
runs-on: ${{matrix.os}}
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
github-token: ${{ secrets.GITHUB_TOKEN }}
distribution: liberica
cache: gradle
- name: Build the GPLv2 plantuml
run: |
VERSION=$(grep 'version =' gradle.properties | cut -d' ' -f 3)
echo VERSION $VERSION
echo "VERSION=$VERSION" >> $GITHUB_ENV
shell: bash
- name: Build
shell: bash
run: |
./gradlew :plantuml-gplv2:nativeCompile -x test
- name: Archive Release
uses: thedoctor0/zip-[email protected]
with:
type: 'zip'
filename: "plantuml-${{ matrix.platform }}-${{ env.VERSION }}.zip"
directory: plantuml-gplv2/build/native/nativeCompile/
- name: Upload binaries to release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: "plantuml-gplv2/build/native/nativeCompile/plantuml-${{ matrix. platform }}-${{ env.VERSION }}.zip"
tag: ${{ env.VERSION }}
overwrite: true
make_latest: true
That’s it! Now, if you push the project to the repository, the workflow will run automatically.
Build Summary
You can check the information about each build in the sidebar on the left:
Build Stages
And finally, the binaries for all platforms as well as the source code are located under the Releases:
Resulting Binaries
You can download the binary for your platform and verify that it is working.
Conclusion
In this article, we discussed how to set up a GitHub Action for building releases using GraalVM Native Image and examined several approaches to collecting metadata for the native-image tool.
You can collect metadata manually, using the Tracing Agent, or a resource autodetection plugin that picks up the resources in specified directories. The choice, as usual, depends on your use case.