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

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


9 min read

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!


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

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:


brew tap-new $GH_USERNAME/$APP_NAME
cd $(brew --repository)/Library/Taps/$GH_USERNAME/homebrew-$APP_NAME

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 and a Formula directory. Cd into it using:

cd $(brew --repository)/Library/Taps/derlin/homebrew-bitdowntoc

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 ""
  url ""
  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"

  ## โ†“โ†“ โ‘ฃ
  test do
    assert_match "BitDownToc Version: #{version}", shell_output("#{bin}/bitdowntoc --version")

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
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" "$@"

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

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

brew test bitdowntoc
brew audit --strict bitdowntoc

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

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

brew install derlin/bitdowntoc/bitdowntoc

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

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

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

      # โ†“โ†“ โ‘ 
      - name: get upstream version
        id: upstream
        run: |
          res=$(curl -s
          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: |
          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
            echo "๐ŸŸ  Updated $formula" | tee -a $GITHUB_STEP_SUMMARY
            git add $formula
            cat $formula
            echo "changed=true" >> $GITHUB_OUTPUT

      ## โ†“โ†“ โ‘ข
      - name: commit changes
        if: steps.update.outputs.changed == 'true'
        run: |
          git config --global 'Github Workflow'
          git config --global ''
          git commit -am "chore: update bitdowntoc to ${{ steps.upstream.outputs.version }}"
          git push

          echo "โœ… Changes committed" | tee -a $GITHUB_STEP_SUMMARY

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:

    runs-on: ubuntu-latest
    needs: [release-please, upload-jar]
      - 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" \
  ${{ secrets.HOMEBREW_WORKFLOW_ID }}/dispatches \
            -d '{"ref":"main","inputs":{}}'

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!


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!