# Releasing GraalVM-generated native binaries with Github Actions (and Gradle)

Recently, I have to update [binary release](https://github.com/xvik/yaml-updater/blob/master/.github/workflows/binaries.yml) github action for my [yaml-updater](https://github.com/xvik/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](https://www.graalvm.org/latest/reference-manual/native-image/)) from java application and attached on github release page:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1729154121054/a6ed96e3-2968-4b24-b680-68eaa3eb5ae1.png align="center")

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](https://github.com/xvik/yaml-updater/tree/master/yaml-config-updater-cli), generated with [picocly](https://picocli.info/) (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](https://www.graalvm.org/latest/reference-manual/native-image/#build-a-native-executable-using-maven-or-gradle) maintained by graalvm.

Set up [gradle plugin](https://graalvm.github.io/native-build-tools/latest/gradle-plugin.html) (groovy):

```plaintext
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](https://github.com/xvik/yaml-updater/blob/1.4.4/yaml-config-updater-cli/build.gradle#L3) and actiavate it ONLY [when native compilation is required](https://github.com/xvik/yaml-updater/blob/1.4.4/yaml-config-updater-cli/build.gradle#L66).

# Local test

First of all, you may need to install some additional packages: see [prerequisites](https://www.graalvm.org/latest/reference-manual/native-image/#prerequisites).

Then custom JDK is required: see [installation](https://www.graalvm.org/latest/getting-started/#installing). On linux I use sdkman:

```bash
sdk install java 21.0.2-graalce
```

You don’t need to set it as default. Just activate it before running your project build:

```bash
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](https://github.com/xvik/yaml-updater/blob/master/.github/workflows/binaries.yml) 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).

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1729157126130/753c731b-c4d6-4dbe-946c-0ad4f095ff44.png align="center")

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

```yaml
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).

```yaml
on:
  .....
  release:
    types: [published]
```

There are also 2 manual executions:

```yaml
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
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1729157937262/1f33f4e7-c7c8-45a3-bc7c-beadd6177df6.png align="center")

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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1729158059529/752d609e-3f8a-4298-839b-b9bbfb7840fc.png align="center")

(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:

```yaml
  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:

```yaml
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:

```yaml
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:

```yaml
  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:

```yaml
 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

```yaml
      - 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](https://github.com/graalvm/setup-graalvm) and that’s wonderful! Before, you have to [manually install additional pre-requisites](https://github.com/xvik/yaml-updater/blob/1.4.2/.github/workflows/binaries.yml#L38) on windows and now its just a one simple step!

See action docs for [options description](https://github.com/graalvm/setup-graalvm?tab=readme-ov-file#options).

### Build itself

```yaml
      - 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:

```yaml
        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](https://github.com/upx/upx) tool could shrink binary size (in my case from 20mb into 6mb).

```yaml
      - 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](https://github.com/upx/upx/issues/612) (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).

```yaml
if: ${{ matrix.upx }}
```

### Store generated binary as action artifact

```yaml
      - 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

```yaml
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):

```yaml
    if: ${{ needs.selectTag.outputs.TAG_NAME }}
```

And if previous steps were successfull:

```yaml
    needs: [build, selectTag]
```

Then downloading artifacts from action artifacts storage (uploaded by build jobs) and attaching them to github release.
