Releasing GraalVM-generated native binaries with Github Actions (and Gradle)
Recently, I have to update binary release github action for my yaml-updater project and finally added debug and re-run abilities. So I decided to describe it here as it could be easilly reused for other projects (should be a good bootstrap for anyone planning to release native binaries).
What is binary release
First, what I mean by binary release: native binaries generated (with graalvm native image) from java application and attached on github release page:
attached jar on screenshot is all-in-one jar (with included dependencies), which is not published to maven central
I will explain why linux binary size differ later
It is not possible to build such binary release on local machine because each binary must be build on target os (linux, windows, mac). That’s why github actions required.
Overall release process consists of two parts:
Release project into maven central and release tag creation (gradle release plugin scope)
Github release creation (in my case manual, but it could be automated too) which trigger binraty action execution. Action attaches created binaries to the just created github release.
Native binary requirements
What java application could be converted to native binary?
In fact, alomost any java application (even “hello world”) with the main method (entry point required).
In my case, it was a CLI utility, generated with picocly (ideal candidate for native binary).
Project requirements
My project use gradle, but it is not important and you can easilly reuse this workflow for maven project (with minimal changes). The most important part is github action logic itself.
There are maven and gradle plugins maintained by graalvm.
Set up gradle plugin (groovy):
plugins {
id 'org.graalvm.buildtools.native' version '0.10.3'
}
graalvmNative {
binaries {
main {
imageName = project.name
mainClass = 'ru.vyarus.yaml.updater.cli.UpdateConfigCli'
}
all {
resources.autodetect()
}
}
}
Plugin needs to know only target main class.
Note that it is possible to avoid plugin usage, but then you’ll have to write native image command manually. Plugin makes life a bit simplier.
IMPORTANT: Plugin requires java 11! In my case, I keep java 8 compatibility and so have to use old plugins syntax and actiavate it ONLY when native compilation is required.
Local test
First of all, you may need to install some additional packages: see prerequisites.
Then custom JDK is required: see installation. On linux I use sdkman:
sdk install java 21.0.2-graalce
You don’t need to set it as default. Just activate it before running your project build:
sdk use java 21.0.2-graalce
./gradlew :nativeCompile
If build successful, your native binary could be found in folder:
build/native/nativeCompile
Github action
My action additionally publish docker image into github repository, but I will not describe this step here - it is very easy to add (just copy this part from my action, if required).
Workflow:
Step selects target tag (will describe later)
3 steps build 3 binaries on 3 OS and upload them as action artifacts
Final step attach binaries to github release
OS-specific steps did not publish directly on github page for proper debug and consistency:
In case of debug run, final publish step simply not called (but all artifacts attached to action and so could be exemined)
Last step will not run if any OS-specific step will fail (all or nothing)
Code
name: Publish native binaries
on:
# for manual debug run (and optional re-release)
workflow_dispatch:
inputs:
# when set, binaries would be uploaded to provided release tag (re-release)
tag:
description: 'Target tag (leave empty for test build)'
required: false
default: ''
type: string
release:
types: [published]
jobs:
selectTag:
name: Select target tag
runs-on: ubuntu-latest
outputs:
TAG_NAME: ${{ steps.select.outputs.TAG_NAME }}
steps:
- id: select
name: Select tag name
run: |
if [ -n "${{ inputs.tag }}" ]; then
tagName=${{ inputs.tag }}
else
tagName=${{ github.event.release.tag_name }}
fi
echo "Selected tag: $tagName"
echo "TAG_NAME=$tagName" >> $GITHUB_OUTPUT
build:
name: Build ${{ matrix.artifact }}
strategy:
fail-fast: false
matrix:
include:
- artifact: yaml-updater.exe
os: windows-2022
ext: .exe
# due to windows defender false detections
upx: false
- artifact: yaml-updater-mac-amd64
os: macos-latest
# https://github.com/upx/upx/issues/612
upx: false
- artifact: yaml-updater-linux-amd64
os: ubuntu-latest
upx: true
shadowJar: true
runs-on: ${{ matrix.os }}
continue-on-error: ${{ false }}
needs: [selectTag]
steps:
- run: |
echo "Selected tag: ${{ needs.selectTag.outputs.TAG_NAME }}"
- uses: actions/checkout@v4
with:
ref: ${{ needs.selectTag.outputs.TAG_NAME }}
- uses: graalvm/setup-graalvm@v1
with:
java-version: '22'
distribution: 'liberica'
github-token: ${{ secrets.GITHUB_TOKEN }}
cache: gradle
- name: Verify GraalVM
run: |
echo "GRAALVM_HOME: $GRAALVM_HOME"
echo "JAVA_HOME: $JAVA_HOME"
java --version
native-image --version
# cache
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build
id: build
shell: bash
run: |
if [ -n "${{ matrix.shadowJar }}" ]; then
BUILD_TARGET=":yaml-config-updater-cli:shadowJar :yaml-config-updater-cli:nativeCompile"
else
BUILD_TARGET=":yaml-config-updater-cli:nativeCompile"
fi
chmod +x gradlew
./gradlew ${BUILD_TARGET} --no-daemon
## Rename files
mkdir upload
cp yaml-config-updater-cli/build/native/nativeCompile/yaml-config-updater-cli${{ matrix.ext }} upload/${{ matrix.artifact }}
echo "binary=upload/${{ matrix.artifact }}" >> $GITHUB_OUTPUT
if [ -n "${{ matrix.shadowJar }}" ]; then
cp yaml-config-updater-cli/build/libs/*-all.jar upload/yaml-updater.jar
echo "jarFile=upload/yaml-updater.jar" >> $GITHUB_OUTPUT
fi
- name: Run UPX
uses: svenstaro/upx-action@v2
if: ${{ matrix.upx }}
continue-on-error: true
with:
file: ${{ steps.build.outputs.binary }}
args: "-9"
- name: Publish ${{ matrix.artifact }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ steps.build.outputs.binary }}
- name: Publish JAR
uses: actions/upload-artifact@v4
if: matrix.shadowJar
with:
name: yaml-updater.jar
path: ${{ steps.build.outputs.jarFile }}
- name: Quick Test
run: ./${{ steps.build.outputs.binary }} --version
publish:
runs-on: ubuntu-latest
if: ${{ needs.selectTag.outputs.TAG_NAME }}
strategy:
fail-fast: false
matrix:
artifact:
- yaml-updater.exe
- yaml-updater-mac-amd64
- yaml-updater-linux-amd64
- yaml-updater.jar
needs: [build, selectTag]
steps:
- run: mkdir -p tmp
- name: Download artifact ${{ matrix.artifact }}
uses: actions/download-artifact@v4
with:
name: ${{ matrix.artifact }}
path: tmp
- name: Upload ${{ matrix.artifact }}
if: ${{ needs.selectTag.outputs.TAG_NAME }}
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: tmp/${{ matrix.artifact }}
tag: ${{ needs.selectTag.outputs.TAG_NAME }}
overwrite: true
Run modes
As I mention before, action will run as soon as new github release would be published (manually or automatically).
on:
.....
release:
types: [published]
There are also 2 manual executions:
on:
# for manual debug run (and optional re-release)
workflow_dispatch:
inputs:
# when set, binaries would be uploaded to provided release tag (re-release)
tag:
description: 'Target tag (leave empty for test build)'
required: false
default: ''
type: string
If “target tag” input would be empty - debug execution would be started. It will only run 3 native builds without final step. Build artifacts would be available on action build summary page:
(showing build with error on purpose - it was one of many debug runs).
Pay attention that attached files are zipped here: so it is not the real size! On release page files would be attached as-is.
The third run option is manual re-release. By default, github action would use the same commit as current action itself, so you can’t fix action yaml and re-perform release on tag.
The third mode exactly workarounds this default behaviour: you can run actual github action, but build and release sources from exact tag (and binaries would be attached to an appropriate release page).
Tag selection job
In order to make all this work, additional (first) step is required:
selectTag:
name: Select target tag
runs-on: ubuntu-latest
outputs:
TAG_NAME: ${{ steps.select.outputs.TAG_NAME }}
steps:
- id: select
name: Select tag name
run: |
if [ -n "${{ inputs.tag }}" ]; then
tagName=${{ inputs.tag }}
else
tagName=${{ github.event.release.tag_name }}
fi
echo "Selected tag: $tagName"
echo "TAG_NAME=$tagName" >> $GITHUB_OUTPUT
It will look if tag name provided as input or it was a release event and will take release tag from event. In case of manual debug run (without tag name), tag name will remain empty.
Result is written into declared job output:
outputs:
TAG_NAME: ${{ steps.select.outputs.TAG_NAME }}
....
echo "TAG_NAME=$tagName" >> $GITHUB_OUTPUT
Other jobs declare dependency on first job and so could reference its output values:
needs: [selectTag]
....
if: ${{ needs.selectTag.outputs.TAG_NAME }}
In the above example, “if” would prevent job execution when TAG_NAME not set (in debug mode).
Binary build jobs
The main build use matrix to run on three different OS:
build:
name: Build ${{ matrix.artifact }}
strategy:
fail-fast: false
matrix:
include:
- artifact: yaml-updater.exe
os: windows-2022
ext: .exe
# due to windows defender false detections
upx: false
- artifact: yaml-updater-mac-amd64
os: macos-latest
# https://github.com/upx/upx/issues/612
upx: false
- artifact: yaml-updater-linux-amd64
os: ubuntu-latest
upx: true
shadowJar: true
Checkout
First step would checkout correct version:
steps:
- run: |
echo "Selected tag: ${{ needs.selectTag.outputs.TAG_NAME }}"
- uses: actions/checkout@v4
with:
ref: ${{ needs.selectTag.outputs.TAG_NAME }}
Note that in case of debug build (manual run without tag name), TAG_NAME would be empty and so action would checkout current branch head (the same as if ref option would not be declared at all). But in case of manual run with tag, it would checkout tag sources (which is important for re-releasing binaries).
Setup graal and gradle
- uses: graalvm/setup-graalvm@v1
with:
java-version: '22'
distribution: 'liberica'
github-token: ${{ secrets.GITHUB_TOKEN }}
cache: gradle
- name: Verify GraalVM
run: |
echo "GRAALVM_HOME: $GRAALVM_HOME"
echo "JAVA_HOME: $JAVA_HOME"
java --version
native-image --version
# cache
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
Graalvm setup action is also provided by graalvm team and that’s wonderful! Before, you have to manually install additional pre-requisites on windows and now its just a one simple step!
See action docs for options description.
Build itself
- name: Build
id: build
shell: bash
run: |
if [ -n "${{ matrix.shadowJar }}" ]; then
BUILD_TARGET=":yaml-config-updater-cli:shadowJar :yaml-config-updater-cli:nativeCompile"
else
BUILD_TARGET=":yaml-config-updater-cli:nativeCompile"
fi
chmod +x gradlew
./gradlew ${BUILD_TARGET} --no-daemon
## Rename files
mkdir upload
cp yaml-config-updater-cli/build/native/nativeCompile/yaml-config-updater-cli${{ matrix.ext }} upload/${{ matrix.artifact }}
echo "binary=upload/${{ matrix.artifact }}" >> $GITHUB_OUTPUT
if [ -n "${{ matrix.shadowJar }}" ]; then
cp yaml-config-updater-cli/build/libs/*-all.jar upload/yaml-updater.jar
echo "jarFile=upload/yaml-updater.jar" >> $GITHUB_OUTPUT
fi
This is the only step that depends on build tool.
As my application requires 3rd party jars, I want to also build an all-in-one jar (which is simpier to use, in case if binary can’t be used). There is an shadowJar matrix option to build complete jar only once on linux (and so on linux :shadowJar task must be called to build it).
Next, copying generated files into upload folder in order to properly re-name generated files (and simplify paths).
Note that undeclared outputs declared here:
echo "binary=upload/${{ matrix.artifact }}" >> $GITHUB_OUTPUT
echo "jarFile=upload/yaml-updater.jar" >> $GITHUB_OUTPUT
To reference them in further steps as: ${{ steps.build.outputs.binary }}
UPX
As you may note, resulted binaries are quite big (~20mb in my case). It is possible to shrink it with graalvm but with a lot of efforts (mainly by getting rid of reflection), but it is not possible in my case.
Instead, upx tool could shrink binary size (in my case from 20mb into 6mb).
- name: Run UPX
uses: svenstaro/upx-action@v2
if: ${{ matrix.upx }}
continue-on-error: true
with:
file: ${{ steps.build.outputs.binary }}
args: "-9"
But, currently upx is not compatible with mac (homebrew would even not allow you to install it).
On windows, compressed binaries are quite often being detected as infected by windows defender. It is strange, because in theory any antivirus could unupx (upx -d binary) it and check correctly. I didn’t find a way to workaround this.
So upx would be applied only for linux (and that’s why linux binary on screen at page head is the smallest one).
if: ${{ matrix.upx }}
Store generated binary as action artifact
- name: Publish ${{ matrix.artifact }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ steps.build.outputs.binary }}
- name: Publish JAR
uses: actions/upload-artifact@v4
if: matrix.shadowJar
with:
name: yaml-updater.jar
path: ${{ steps.build.outputs.jarFile }}
- name: Quick Test
run: ./${{ steps.build.outputs.binary }} --version
This is important for debug run, because we could download generated binary and test/investigate it locally. Also, uploading all-in-one jar (generated only on linux).
Quick test is required just to reveal potentially incorrect binary. For example, on mac, it detected upx-compressed binary incompatibility. It is important to test after upload to be able to download and check binary after error.
Publish job
publish:
runs-on: ubuntu-latest
if: ${{ needs.selectTag.outputs.TAG_NAME }}
strategy:
fail-fast: false
matrix:
artifact:
- yaml-updater.exe
- yaml-updater-mac-amd64
- yaml-updater-linux-amd64
- yaml-updater.jar
needs: [build, selectTag]
steps:
- run: mkdir -p tmp
- name: Download artifact ${{ matrix.artifact }}
uses: actions/download-artifact@v4
with:
name: ${{ matrix.artifact }}
path: tmp
- name: Upload ${{ matrix.artifact }}
if: ${{ needs.selectTag.outputs.TAG_NAME }}
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: tmp/${{ matrix.artifact }}
tag: ${{ needs.selectTag.outputs.TAG_NAME }}
overwrite: true
Publish job starts only if target tag declared (release event or manual re-release):
if: ${{ needs.selectTag.outputs.TAG_NAME }}
And if previous steps were successfull:
needs: [build, selectTag]
Then downloading artifacts from action artifacts storage (uploaded by build jobs) and attaching them to github release.