Rewriting a script for the Homebrew package manager taught me how Go’s design choices align with platform-ready tools.
The problem with brew upgrade
By default, the brew upgrade
command updates every formula (terminal utility or library). It also updates every cask (GUI application) it manages. All are upgraded to the latest version — major, minor, and patch. That’s convenient when you want the newest features, but disruptive when you only want quiet patch-level fixes.
Last week I solved this in Perl with brew-patch-upgrade.pl
, a script that parsed brew upgrade
’s JSON output, compared semantic versions, and upgraded only when the patch number changed. It worked, but it also reminded me how much Perl leans on implicit structures and runtime flexibility.
This week I ported the script to Go, the lingua franca of DevOps. The goal wasn’t feature parity — it was to see how Go’s design choices map onto platform engineering concerns.
Why port to Go?
- Portfolio practice : I’m building a body of work that demonstrates platform engineering skills.
- Operational focus : Go is widely used for tooling in infrastructure and cloud environments.
- Learning by contrast : Rewriting a working Perl script in Go forces me to confront differences in error handling, type safety, and distribution.
The journey
Error handling philosophy
Perl gave me try
/catch
(experimental in the Perl v5.34.1 that ships with macOS, but since accepted into the language in v5.40). Go, famously, does not. Instead, every function returns an error explicitly.
use v5.34;
use warnings;
use experimental qw(try);
use Carp;
use autodie;
…
try {
system ‘brew’, ‘upgrade’, $name;
$result = ‘upgraded’;
}
catch ($e) {
$result = ‘failed’;
carp $e;
}
package main
import (
“os/exec”
“log”
)
…
cmd := exec.Command(“brew”, “upgrade”, name)
if output, err := cmd.CombinedOutput(); err != nil {
log.Printf(“failed to upgrade %s: %v\n%s”,
name,
err,
output)
}
The Go version is noisier, but it forces explicit decisions. That’s a feature in production tooling: no silent failures.
Dependency management
-
Perl :
cpanfile
+ CPAN modules. Distribution means “install Perl (if it’s not already), install modules, run script.” Tools likecarton
and thecpan
orcpanm
commands help automate this. Additionally, one can use further tooling likefatpack
andpp
to build more self-contained packages. But those are neither common nor (except forcpan
) distributed with Perl. -
Go :
go.mod
+go build
. Distribution is a single (platform-specific) binary.
For operational tools, that’s a massive simplification. No runtime interpreter, no dependency dance.
Type safety
Perl let me parse JSON into hashrefs and trust the keys exist. Go required a struct
:
type Formula struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version"`
InstalledVersions []string `json:"installed_versions"`
}
The compiler enforces assumptions that Perl left implicit. That friction is valuable — it surfaces errors early.
Binary distribution
This is where Go shines. Instead of telling colleagues “install Perl v5.34 and CPAN modules,” I can hand them a binary. No need to worry about scripting runtime environments — just grab the right file for your system.
-
homebrew-semver-guard-darwin
(Universal Binary for macOS) -
homebrew-semver-guard-linux-amd64
(Intel/AMD 64-bit binary for Linux) -
homebrew-semver-guard-linux-arm64
(Arm 64-bit binary for Linux)
Available on the release page. Download, run, done.
Semantic versioning logic
In Perl, I manually compared arrays of version numbers. In Go, I imported golang.org/x/mod/semver
:
import (
golang.org/x/mod/semver
)
…
if semver.MajorMinor(toSemver(formula.InstalledVersions[0])) !=
semver.MajorMinor(toSemver(formula.CurrentVersion)) {
log.Printf(“%s is not a patch upgrade”, formula.Name)
results.skipped++
continue
}
Cleaner, more legible, and less error-prone. The library encodes the convention, so I don’t have to.
Deliberate simplification
I didn’t port every feature. Logging adapters, signal handlers, and edge-case diagnostics remained in Perl. The Go version focuses on the core logic: parse JSON, compare versions, run upgrades. That restraint was intentional — I wanted to learn Go’s idioms, not replicate every Perl flourish.
Platform engineering insights
Three lessons stood out:
- Binary distribution matters. Operational tools should be installable with a single copy step. Go makes that trivial.
- Semantic versioning is an operational practice. It’s not just a convention for library authors — it’s a contract that tooling can enforce.
- Go’s design aligns with platform needs. Explicit errors, type safety, and static binaries all reduce surprises in production.
Bringing it home
This isn’t a “Perl vs. Go” story. It’s a story about deliberate simplification, taking a working Perl script and recasting it in Go. The aim is to see how the language’s choices shape a solution to the same problem.
The result is homebrew-semver-guard
v0.1.0, a small but sturdy tool. It’s not feature-finished, but it’s production-ready in the ways that matter.
Next up: I’m considering more Go tools, maybe even Kubernetes for services on my home server. This port was a practice, an artifact demonstrating platform engineering in action.
Links
- Original Perl script:
brew-patch-upgrade.pl
- Go release:
homebrew-semver-guard
v0.1.0 - Last week’s post: Patch-Perfect: Smarter Homebrew Upgrades on macOS
Top comments (1)
Great read @mjgardner
Just out of curiosity, the:
Was not intended to be a link to the Perl source?