Creating Homebrew Formulas with GoReleaser
Starting Out
We chose to use GoReleaser with our distro of the OpenTelemetry Collector to simplify how we build and support many operating systems and architectures. It allows us to build a matrix of GOOS
and GOARCH
targets and automate the creation of a wide range of deliverables. The ones we have utilized are building tarballs, nfpm packages, docker images, and the Homebrew formula.
Our goal is to make it easy for users to install our software on macOS so that they can easily try it out. We went with Homebrew as it’s familiar to many macOS users and would allow the user to try out our software and then remove it just as quickly when they were finished.
As we started setting up Homebrew in GoReleaser, we found that documentation about creating a Homebrew formula was lacking. Also, it wasn’t easy to search for solutions when we encountered a problem. Homebrew provides a Formula Cookbook but it can be confusing if you aren’t already familiar with building formulas.
We went through several iterations of our Homebrew formula as we learned more and more about the correct way to do things.
First, we created a public repo to be our Homebrew formula. We would specify this as the place where GoReleaser would send formula updates. We created https://github.com/observIQ/homebrew-observiq-otel-collector.
As we started setting up GoReleaser, we initially used the caveats
, install
, and plist
blocks of the brews
section in GoReleaser to create our formula.
Caveats Block
The caveats
block can relay textual information to the user after a homebrew installation. We use it to
- Give info on how to start/stop/restart the homebrew service that is installed
- How to properly uninstall the entire formula
- And give info on where specific configuration files live
Install Block
Inside the install
block, you can use the same brew shortcuts for installation used in the formula file. This ultimately will copy these same lines to the ruby formula file’s install block. For example, we use
prefix.install
to copy files and directories to homebrew’s versioned formula directoryprefix.install_symlink
to create symlinks in homebrew’s versioned formula directoryetc.install
to copy files and directories to homebrew’s shared etc directorybin.install
to copy binary executable files to homebrew’s versioned formula’s “bin” directorylib.install
to copy library files to homebrew’s versioned formula’s “lib” directory
Related Content: Embed React in Golang
Service Blocks
The plist
block was where we defined a plist file to allow our software to be run as a launched service. The service
block wasn’t supported in GoReleaser when we started; once it was, we shifted to using that as it was easier to define for us and allowed a more brew-native way of managing our service.
Our original plist
block looked like the XML below:
1plist: |
2 <?xml version="1.0" encoding="UTF-8"?>
3 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4 <plist version="1.0">
5 <dict>
6 <key>Label</key>
7 <string>com.observiq.collector</string>
8 <key>ProgramArguments</key>
9 <array>
10 <string>#{bin}/observiq-otel-collector</string>
11 <string>--config</string>
12 <string>#{prefix}/config.yaml</string>
13 </array>
14 <key>RunAtLoad</key>
15 <true/>
16 <key>StandardErrorPath</key>
17 <string>/tmp/observiq-otel-collector.log</string>
18 <key>StandardOutPath</key>
19 <string>/tmp/observiq-otel-collector.log</string>
20 </dict>
21 </plist>
Once we saw GoReleaser supported the service
block, we were able to simplify it to the following:
1service: |
2 run [opt_bin/"observiq-otel-collector", "--config", opt_prefix/"config.yaml"]
3 environment_variables OIQ_OTEL_COLLECTOR_HOME: opt_prefix
4 keep_alive true
We originally had some trouble creating the service as some “magic” words correspond to special directories in a brew installation. The cookbook documentation used these magic words in examples but did not list them as it does in the install section. We had to search the brew source code for a list of the support “magic” words.
Here are a few of the common ones we used:
Initial Config
Here is the brews
block we initially generated that created a working formula for us.
1brews: - name: observiq-otel-collector tap: owner: observIQ name: homebrew-observiq-otel-collector branch: main download_strategy: CurlDownloadStrategy folder: Formula url_template: https://github.com/observIQ/observiq-otel-collector/releases/download/{{ .Tag }}/{{ .ArtifactName }} commit_author: name: observiq email: support@observiq.com homepage: "https://github.com/observIQ/observiq-otel-collector" license: "Apache 2.0" caveats: | **************************************************************** The configuration file that is run by the service is located at #{prefix}/config.yaml. If you are configuring a logreceiver the plugin directory is located at #{prefix}/plugins. **************************************************************** **************************************************************** Below are services commands to run to manage the collector service. If you wish to run the collector at boot prefix these commands with sudo otherwise the service will run at login. To start the collector service run: brew services start observiq/observiq-otel-collector/observiq-otel-collector To stop the collector service run: brew services stop observiq/observiq-otel-collector/observiq-otel-collector To restart the collector service run: brew services restart observiq/observiq-otel-collector/observiq-otel-collector **************************************************************** **************************************************************** To uninstall the collector and its dependencies run the following commands: brew services stop observiq/observiq-otel-collector/observiq-otel-collector brew uninstall observiq/observiq-otel-collector/observiq-otel-collector launchctl remove com.observiq.collector # If you moved the opentelemetry-java-contrib-jmx-metrics.jar sudo rm /opt/opentelemetry-java-contrib-jmx-metrics.jar **************************************************************** install: | bin.install "observiq-otel-collector" prefix.install "LICENSE", "config.yaml" prefix.install Dir["plugins"] lib.install "opentelemetry-java-contrib-jmx-metrics.jar" service: | run [opt_bin/"observiq-otel-collector", "--config", opt_prefix/"config.yaml"] environment_variables OIQ_OTEL_COLLECTOR_HOME: opt_prefix keep_alive true
Versioning Brew Formulas
One issue we eventually stumbled upon was versioning our software releases with Homebrew. We found after every release GoReleaser would update the Formula repo by overwriting the previous formula. A user could easily update the formula and run brew upgrade to get the latest version. The issue we ran into was, what if you wanted a specific version of the Collector with a specific brew formula? You would have to know which commit in the Formula corresponds to the release you wish to. It is not very user-friendly.
This also made it hard for us to test pre-releases as we wanted GoReleaser to generate formulas for release candidates but not to overwrite the production one.
It wasn’t easy to find out how to version Homebrew Formulas. We looked at the homebrew-core repo for examples of how other formulas do it.
There are a few unique things to do when versioning a formula. The formula name needs to be of the format formula-name@major.minor.patch.rb
. The added @major.minor.patch
lets Homebrew know which formula to get when specified in the brew command. Inside the formula, the class name must have special formatting, too. It must be of the format FormulaNameAT{Major}{Minor}{Patch}
. So an example filename and corresponding class name for our Collector is observiq-otel-collector@0.6.0.rb
and ObserviqOtelCollectorAT060
respectively.
That formula file will exist in the Formula
directory of your repo next to the current main formula, the formula you get if you just run brew install X
. You can also add a version to the main formula so users can get it by version or by the basic brew command. To do this, create an Aliases
directory on the same level as your Formula
directory. Inside that directory, create a symlink to the main formula with a versioned name.
If that’s confusing, here’s the command we run to create the symlink:
1cd Aliases && ln -s ../Formula/observiq-otel-collector.rb observiq-otel-collector@0.6.0
Now that we know how to create a versioned formula, we need to update our GoReleaser config to generate versioned formulas for us. This should be simple since the formula and class names are taken from the name
field under the brews
block. We changed our name
to observiq-otel-collector@{{ .Major }}.{{ .Minor }}.{{ .Patch }}
. When we ran a test release with GoReleaser, though, we saw the class for the formula wasn’t quite right. GoReleaser was generating the class name as ObserviqOtelCollectorAT0_6_0
. One quick pull request to GoReleaser, and we’ve fixed that.
Here’s what our brews
block of our GoReleaser config now looks like to support versions.
We also made some changes to the configuration of the Collector, so there are additional flags and files in the install
and service
blocks.
Persisting Configuration Files
Initially, in our install block of the GoReleaser config, we used the prefix.install
to place our configuration file in the main install directory of our formula.
1install: |
2 …
3 prefix.install "LICENSE", "VERSION.txt", "config.yaml"
4 …
After reinstalling this formula, we found that our configuration file would be replaced with fresh defaults, and any user changes would be lost. This wasn’t ideal, so we had to figure out how to ensure this file persisted between installations.
Ultimately, the solution was to make use of Homebrew’s etc
directory. This is a shared directory amongst all formulas, so we had to make an extra effort so that our configuration file would be uniquely named. Now our GoReleaser install
block looks something like this:
1install: |
2 …
3 prefix.install "LICENSE", "VERSION.txt"
4 etc.install "config.yaml" => "observiq_config.yaml"
5 …
The problem was almost solved, but we still preferred to have this configuration file “exist” in the base formula install directory. We also preferred to have this configuration file have its original name without the “observiq_” prefix. Luckily, using a symlink was a simple solution. Our final install
block related to the configuration looked similar to this:
1install: |
2 …
3 prefix.install "LICENSE", "VERSION.txt"
4 etc.install "config.yaml" => "observiq_config.yaml"
5 prefix.install_symlink etc/"observiq_config.yaml" => "config.yaml"
With this solution, our configuration lived safely in Homebrew’s etc
directory with a special prefix where it would never be automatically overwritten. At the same time, it would appear to also exist in the base installation directory without any naming prefix.
There is one more thing to note here. When there is a new installation on top of our formula, homebrew automatically adds a new version of our configuration file to its etc
directory. In our case, the file is named something like observiq_config.yaml.default
. This will contain a clean config with default settings. This is a built-in behavior by Homebrew, and we haven’t found any way to change this.
Conclusion
GoReleaser provides a great way to distribute your Go program via Homebrew. It allows you to focus on the installation part of your application while taking care of all the formatting and setup of your formula file. Hopefully, we’ve given some good insight into the pitfalls we encountered when simplifying GoReleaser and Homebrew.