Porting from Perl to Go: Simplifying for Platform Engineering

Rewriting a script for the Homebrew pack­age man­ag­er taught me how the Go pro­gram­ming lan­guage’s design choic­es align with platform-​ready tools.

The problem with brew upgrade

By default, the brew upgrade com­mand updates every for­mu­la (ter­mi­nal util­i­ty or library). It also updates every cask (GUI appli­ca­tion) it man­ages. All are upgrad­ed to the lat­est ver­sion — major, minor, and patch. That’s con­ve­nient when you want the newest fea­tures, but dis­rup­tive when you only want qui­et patch-​level fixes.

Last week I solved this in Perl with brew-patch-upgrade.pl, a script that parsed brew upgrade​’s JSON out­put, com­pared seman­tic ver­sions, and upgrad­ed only when the patch num­ber changed. It worked, but it also remind­ed me how much Perl leans on implic­it struc­tures and run­time flexibility.

This week I port­ed the script to Go, the lin­gua fran­ca of DevOps. The goal was­n’t fea­ture par­i­ty — it was to see how Go’s design choic­es map onto plat­form engi­neer­ing concerns.

Why port to Go?

  • Portfolio prac­tice: I’m build­ing a body of work that demon­strates plat­form engi­neer­ing skills.
  • Operational focus: Go is wide­ly used for tool­ing in infra­struc­ture and cloud environments.
  • Learning by con­trast: Rewriting a work­ing Perl script in Go forces me to con­front dif­fer­ences in error han­dling, type safe­ty, and distribution.

The journey

Error handling philosophy

Perl gave me try/​catch (exper­i­men­tal in the Perl v5.34.1 that ships with macOS, but since accept­ed into the lan­guage in v5.40). Go, famous­ly, does not. Instead, every func­tion returns an error explicitly.

Perl:

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;
}

Go:

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 ver­sion is nois­i­er, but it forces explic­it deci­sions. That’s a fea­ture in pro­duc­tion tool­ing: no silent failures.

Dependency management

  • Perl: cpanfile + CPAN mod­ules. Distribution means ​“install Perl (if it’s not already), install mod­ules, run script.” Tools like carton and the cpan or cpanm com­mands help auto­mate this. Additionally, one can use fur­ther tool­ing like fatpack and pp to build more self-​contained pack­ages. But those are nei­ther com­mon nor (except for cpan) dis­trib­uted with Perl.
  • Go: go.mod + go build. Distribution is a sin­gle (platform-​specific) binary.

For oper­a­tional tools, that’s a mas­sive sim­pli­fi­ca­tion. No run­time inter­preter, no depen­den­cy 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 com­pil­er enforces assump­tions that Perl left implic­it. That fric­tion is valu­able — it sur­faces errors early.

Binary distribution

This is where Go shines. Instead of telling col­leagues ​“install Perl v5.34 and CPAN mod­ules,” I can hand them a bina­ry. No need to wor­ry about script­ing run­time envi­ron­ments — just grab the right file for your system.

Available on the release page. Download, run, done.

Semantic versioning logic

In Perl, I man­u­al­ly com­pared arrays of ver­sion num­bers. In Go, I import­ed 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 leg­i­ble, and less error-​prone. The library encodes the con­ven­tion, so I don’t have to.

Deliberate simplification

I did­n’t port every fea­ture. Logging adapters, sig­nal han­dlers, and edge-​case diag­nos­tics remained in Perl. The Go ver­sion focus­es on the core log­ic: parse JSON, com­pare ver­sions, run upgrades. That restraint was inten­tion­al — I want­ed to learn Go’s idioms, not repli­cate every Perl flourish.

Platform engineering insights

Three lessons stood out:

  1. Binary dis­tri­b­u­tion mat­ters. Operational tools should be instal­lable with a sin­gle copy step. Go makes that trivial.
  2. Semantic ver­sion­ing is an oper­a­tional prac­tice. It’s not just a con­ven­tion for library authors — it’s a con­tract that tool­ing can enforce.
  3. Go’s design aligns with plat­form needs. Explicit errors, type safe­ty, and sta­t­ic bina­ries all reduce sur­pris­es in production.

Bringing it home

This isn’t a ​“Perl vs. Go” sto­ry. It’s a sto­ry about delib­er­ate sim­pli­fi­ca­tion, tak­ing a work­ing Perl script and recast­ing it in Go. The aim is to see how the lan­guage’s choic­es shape a solu­tion to the same problem.

The result is homebrew-semver-guard v0.1.0, a small but stur­dy tool. It’s not feature-​finished, but it’s production-​ready in the ways that matter.

Next up: I’m con­sid­er­ing more Go tools, maybe even Kubernetes for ser­vices on my home serv­er. This port was prac­tice, an arti­fact demon­strat­ing plat­form engi­neer­ing in action.


Links


Discover more from The Phoenix Trap

Subscribe to get the latest posts sent to your email.

Mark Gardner Avatar

Hi, I’m Mark.

Hi, I’m Mark Gard­ner, and this is my personal blog. I show software developers how to level up by building production-ready things that work. Clear code, real projects, lessons learned.

Your post must include a link to this post.

How does this work?
Write a response on your own website and include a link to this post. Then submit your response’s URL here. Your response may appear here after moderation. To update or remove it, edit or delete your original post and submit the same URL again. Learn more about Webmentions.