posts

How to build and release GraalVM native images using GitHub Actions

Aug 1, 2024
Pasha Finkelshteyn
11.2

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.

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:

  1. We want resources to be autodetected and 
  2. 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.

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading