DEV Community

Cover image for From Jar to Brew: distribute your Java programs easily with Homebrew and GitHub Actions
Lucy Linder
Lucy Linder

Posted on • Originally published at blog.derlin.ch

From Jar to Brew: distribute your Java programs easily with Homebrew and GitHub Actions

In this article, we'll explore how to make a program packaged as a Jar (Java, Kotlin, Scala, ...) easily installable by end users using homebrew and GitHub actions.

This is inspired by my work to distribute bitdowntoc with brew and aims at answering two questions:

  1. how to create a homebrew tap to distribute a jar with brew?

  2. how to automatically update the tap when a new version is available (using GitHub and GitHub actions)?

Let's get started!


(TOC generated with bitdowntoc)


Prerequisites

  • you have standalone software written in a JVM-compatible language (Java, Scala, Kotlin, ...) that you want to distribute easily.

  • you are hosting it on GitHub. For the second part, you use GitHub releases.

  • you have homebrew installed and roughly know what it is about.

In this article, I will use bitdowntoc, a command line (and online) tool to add a table of contents to your Markdown files. Written in Kotlin, it is available on the web and in the command line (using a jar or a native executable).


Publish your JAR via homebrew

Step 1: create a fat jar

The best way to ship JVM-compatible software to all architectures and OSes is through a fat jar. The advantage of a jar (Java ARchive) is it only requires a JRE (Java Runtime Environment) on the target machine to execute. The colloquial term "fat jar" designates a self-contained jar - an archive that includes all its necessary dependencies.

Depending on your language and build system, there are many ways to create a fat jar - too much to enumerate here. If you use Java + Gradle (groovy), baeldung's article "Creating a Far Jar in Gradle" is a good start.

🔥 Ensure your app follows semver (semantic versioning) and the jar is called <app>-<version>.jar, where <version> matches <major>.<minor>.<patch>. This should be the default when using common tooling.

Step 2: make your jar available from a public URL

If you are using GitHub for release management, this means attaching your jar to each release. This can be done manually, or completely automated using GitHub actions.

bitdowntoc uses release-please for release automation, and action-gh-release to attach build artifacts to the release. For more details, check out the release-please and upload-jar in the release.yml workflow (I should write an article on release automation 😄).

However you do it, ensure you can download a properly named jar from a public URL. Here is the URL for bitdowntoc version 2.1.0:

https://github.com/derlin/bitdowntoc/releases/download/v2.1.0/bitdowntoc-jvm-2.1.0.jar
Enter fullscreen mode Exit fullscreen mode

Step 3: create a homebrew tap

Homebrew supports formulae and casks. Formulae build from upstream sources, while casks install macOS native applications. We could argue a JAR is a native application, but it still requires some fiddling during installation, so we'll use a formula.

taps are git repositories of formulae and casks that homebrew uses for discovery. When installing homebrew, it comes with some built-in taps such as homebrew-core.

Adding your software to built-in taps requires it to be "notable enough" (at least 30 forks, 30 watchers, and 75 stars). If it is your case, go ahead and create a pull request by following the Formula Cookbook! If like me you lack exposure, you'll have to create and maintain your own tap.

👉 The following steps are a condensed version of brew's How to Create and Maintain a Tap.

Brew is very developer-friendly. To bootstrap a new tap, run the following in a terminal:

GH_USERNAME=derlin
APP_NAME=bitdowntoc

brew tap-new $GH_USERNAME/$APP_NAME
cd $(brew --repository)/Library/Taps/$GH_USERNAME/homebrew-$APP_NAME
Enter fullscreen mode Exit fullscreen mode

Replace derlin with your GitHub username, and bitdowntoc with the name of your application. This will initialize a git repository called homebrew-<appname> at the path of the taps on your system with a README.md and a Formula directory. Cd into it using:

cd $(brew --repository)/Library/Taps/derlin/homebrew-bitdowntoc
Enter fullscreen mode Exit fullscreen mode

The tap is created, let's write the formula.

Step 4: write the formula

In the tap repository created above, add a ruby file in the Formula repository. Here is the bitdowntoc formula, that you can adapt to your app:

class Bitdowntoc < Formula
  # ↓↓ ①
  desc "Markdown Table Of Content (TOC) generator"
  homepage "https://derlin.github.io/bitdowntoc"
  url "https://github.com/derlin/bitdowntoc/releases/download/v2.1.0/bitdowntoc-jvm-2.1.0.jar"
  sha256 "b7a21d2a793a2ecb6c20f27aa8fffc72702daa364b2a89bed32580e302194650"
  license "Apache-2.0"

  ## ↓↓ ②
  depends_on "openjdk"

  ## ↓↓ ③
  def install
    libexec.install "bitdowntoc-jvm-#{version}.jar"
    bin.write_jar_script libexec/"bitdowntoc-jvm-#{version}.jar", "bitdowntoc"
  end

  ## ↓↓ ④
  test do
    assert_match "BitDownToc Version: #{version}", shell_output("#{bin}/bitdowntoc --version")
  end
end
Enter fullscreen mode Exit fullscreen mode

There are 4 parts to this Ruby file.

Section â‘  defines the metadata and the "source" URL (the jar). To get the correct sha, use sha256sum on Linux or shasum -a 256 on Mac. Ensure your description is less than 80 characters.

Section â‘¡ specifies that the formula requires Java to be installed.

Section â‘¢ copies the jar (downloaded and unpacked automatically by homebrew using the URL in â‘ ) in the libexec folder and creates a bash executable to launch it in the bin folder. The write_jar_script method takes care of resolving to the proper JDK. In my system, for instance, the bin script looks like this after install:

cat /opt/homebrew/Cellar/bitdowntoc/2.1.0/bin/bitdowntoc
#!/bin/bash
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home}"
exec "${JAVA_HOME}/bin/java"  -jar "/opt/homebrew/Cellar/bitdowntoc/2.1.0/libexec/bitdowntoc-jvm-2.1.0.jar" "$@"
Enter fullscreen mode Exit fullscreen mode

Section â‘£ is completely optional and defines some tests to check the installation worked properly.

Step 5: validate and test the formula

Now, let's install the formula locally (note that it will download the world if you don't have openjdk already installed):

HOMEBREW_NO_INSTALL_FROM_API=1 brew install bitdowntoc
Enter fullscreen mode Exit fullscreen mode

If this works, the next steps are to run the tests and audit the formula:

brew test bitdowntoc
brew audit --strict bitdowntoc
Enter fullscreen mode Exit fullscreen mode

The last step is to commit and push everything to GitHub. Once done, anyone can install your software using either:

brew tap derlin/bitdowntoc
brew install bitdowntoc
Enter fullscreen mode Exit fullscreen mode

Or in one line (will fetch the tap and run the install in one command):

brew install derlin/bitdowntoc/bitdowntoc
Enter fullscreen mode Exit fullscreen mode

Keep the homebrew tap in sync

The homebrew tap lives in a different repository and must be updated every time a new version is available. In the age of the "*** as Code", this manual step is frowned upon. This section shows how I automated it for bitdowntoc.

âš  Note that this assumes you use GitHub Releases and have a GitHub Actions release workflow.

The idea is simple:

  1. create a workflow on the homebrew tap repository able to check for new versions of the app and update the formula accordingly.

  2. trigger this workflow from the release workflow of the app.

(Note that we could also schedule 1 to run e.g. every week, but this adds latency - imagine you release on Monday but the workflow runs on Sunday! GitHub may also disable scheduled workflows e.g. due to a lack of activity in the repo. So meh.)

Step 1: the tap workflow

First, let's create a workflow on the homebrew tap repository. Its role is to:

  1. check the latest version available upstream (the main repository),

  2. if a new version is available, update the formula and commit the changes.

This is what it looks like for bitdowntoc (homebrew-bitdowntoc/.github/workflows/update.yml):

name: update version from upstream

on:
  # Allow manual triggers
  workflow_dispatch:
permissions:
  # Necessary to commit the changes back
  contents: write

jobs:
  update_version:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}

      # ↓↓ ①
      - name: get upstream version
        id: upstream
        run: |
          res=$(curl -s https://api.github.com/repos/derlin/bitdowntoc/releases/latest)
          url=$(jq -r '.assets[] | select( .content_type == "application/java-archive" ) | .browser_download_url' <<< "$res")
          version=$(echo $url | sed -E 's/.*-([0-9]\.[0-9]\.[0-9]).*/\1/')

          curl -L -o /tmp/bitdowntoc.jar $url
          sha=$(sha256sum /tmp/bitdowntoc.jar | cut -d' ' -f1)

          echo "✅ Found version $version: $url" | tee -a $GITHUB_STEP_SUMMARY
          echo "url=$url" >> $GITHUB_OUTPUT
          echo "version=$version" >> $GITHUB_OUTPUT
          echo "sha=$sha" >> $GITHUB_OUTPUT

      ## ↓↓ ②
      - name: update bitdowntoc formula
        id: update
        run: |
          formula="Formula/bitdowntoc.rb"
          sed -i'' -E 's#^  url ".*"$#  url "${{ steps.upstream.outputs.url }}"#' $formula
          sed -i'' -E 's#^  sha256 ".*"$#  sha256 "${{ steps.upstream.outputs.sha }}"#' $formula

          if [[ -z "$(git status -s $formula)" ]]; then
            echo "✅ Already up-to-date" | tee -a $GITHUB_STEP_SUMMARY
            echo "changed=" >> $GITHUB_OUTPUT
          else
            echo "🟠 Updated $formula" | tee -a $GITHUB_STEP_SUMMARY
            git add $formula
            cat $formula
            echo "changed=true" >> $GITHUB_OUTPUT
          fi

      ## ↓↓ ③
      - name: commit changes
        if: steps.update.outputs.changed == 'true'
        run: |
          git config --global user.name 'Github Workflow'
          git config --global user.email 'workflow@bot.github.com'
          git commit -am "chore: update bitdowntoc to ${{ steps.upstream.outputs.version }}"
          git push

          echo "✅ Changes committed" | tee -a $GITHUB_STEP_SUMMARY
Enter fullscreen mode Exit fullscreen mode

It may seem daunting, but this boils down to some basic bash. Let's break it down together.

In â‘ , I use the GitHub API to fetch the list of artifacts from the latest GitHub release of bitdowntoc, and find the URL of the one with a application/java-archive type. Next, I extract the version from the URL (looking for a X.X.X pattern), download the jar, and compute its SHA. The URL, SHA, and version are stored as a GitHub output of this step.

In â‘¡, I update the Formula/bitdowntoc.rb file with the URL and SHA of step â‘  using a regex (sed). I can now ask git if there is any change, and store the result as an output of this step.

Step â‘¢ only runs if a change was detected in step â‘¡. I configure the git user and commit the changes back to the repo.

This workflow is idempotent: I can run it as often as I want, it will only update the tap if a new release is present upstream.

Step 2: the app workflow

The last step is to trigger the tap workflow on every release of bitdowntoc. This means adding a new job to my release workflow that uses the GitHub API to trigger the tap workflow:

  update_homebrew:
    runs-on: ubuntu-latest
    needs: [release-please, upload-jar]
    steps:
      - name: trigger homebrew update
        # The PAT should have actions:read-write
        run: |
          curl -L \
            -X POST \
            --fail-with-body \
            -H "Accept: application/vnd.github+json" \
            -H "Authorization: Bearer ${{ secrets.HOMEBREW_PAT }}" \
            -H "X-GitHub-Api-Version: 2022-11-28" \
            https://api.github.com/repos/derlin/homebrew-bitdowntoc/actions/workflows/${{ secrets.HOMEBREW_WORKFLOW_ID }}/dispatches \
            -d '{"ref":"main","inputs":{}}'
Enter fullscreen mode Exit fullscreen mode

For the trigger to work, I had to create a PAT (Personal Access Token) with write permissions on the tap repository and add it as a secret (HOMEBREW_PAT) on the bitdowntoc repository (Settings > Secrets and variables > Actions: Repository secrets).

The needs here is specific to my workflow, and is necessary to ensure the GitHub release exists (with the jar!) before the job runs.

And that's it!

Conclusion

I always loved homebrew (as a user), but never tried to publish anything. I was pleasantly surprised by the overall architecture and the great documentation. Setting up a personal tap was a breeze. My only reproach is the whole brew-related terminology... For a non-native speaker, cask, tap, keg, cellar, etc are not straightforward without a dictionary close by.

The syncing of the tap was more "experimental", but I am quite happy with the result. If you are a tap maintainer, let me know how you solved this challenge!

I hope this will be useful to some, and remember, bitdowntoc can now be installed with homebrew, so give it a try 😉.

Happy coding!

Top comments (1)

Collapse
 
bernert profile image
BernerT

This is really informative! Could I use the same steps for distributing a non-JVM application via Homebrew?