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:
how to create a homebrew tap to distribute a jar with brew?
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
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
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
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
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" "$@"
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:
create a workflow on the homebrew tap repository able to check for new versions of the app and update the formula accordingly.
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:
check the latest version available upstream (the main repository),
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
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":{}}'
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!