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:

  1. attached jar on screenshot is all-in-one jar (with included dependencies), which is not published to maven central

  2. 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:

  1. Release project into maven central and release tag creation (gradle release plugin scope)

  2. 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:

  1. Step selects target tag (will describe later)

  2. 3 steps build 3 binaries on 3 OS and upload them as action artifacts

  3. Final step attach binaries to github release

OS-specific steps did not publish directly on github page for proper debug and consistency:

  1. In case of debug run, final publish step simply not called (but all artifacts attached to action and so could be exemined)

  2. 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.

0
Subscribe to my newsletter

Read articles from Vyacheslav Rusakov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vyacheslav Rusakov
Vyacheslav Rusakov