This is a story about patches.
At first, I just wanted Homebrewto behave a little more politely. Formulaeshould upgrade only on patch changes, without being dragged through minor and major bumps. That itch became a small(ish) Perl script, brew-patch-upgrade.pl.
Along the way, I discovered another patch was needed. My own logging adapter, Log::Any::Adapter::MacOS::OSLog, wasn’t building and installing its bundle correctly. Before the script can shine in the Mac Console app, I had to fix the adapter itself.
What follows is how those two threads came together. One is a tool that keeps Homebrew upgrades patch-perfect. The other is a logging adapter that finally behaves as a first-class Perl module.
What is Homebrew?
Not to be confused with perlbrew, Homebrewis a free and open-source package manager for macOS (and Linux). It makes it easy to install and update software from the command line. You don’t have to hunt down installers on websites. Instead, you can type commands like brew install wget. Homebrew will then fetch, build, and link the tool into place.

Everything is organized under /opt/homebrew(on Apple Silicon-based Macs) or /usr/local(on Intel). Homebrew can even manage both command‑line utilities (“formulae”) and desktop apps (“casks”).
In short: it’s the missing package manager Apple never shipped, and it’s become an essential part of many developers’ workflows.
Homebrew makes it easy to stay up to date — sometimes tooeasy. By default, brew upgradejumps to the latest version of everything, even across minor and major releases.
That’s fine when you want the newest features. Yet, it can be disruptive if all you really need are the quiet, patch-level fixes.
Scripted patch-only upgrades
My brew‑patch‑upgrade.pltakes a narrower view: it parses brew outdatedcommand, compares semantic versions, and upgrades only when the patch number changes.
That means:
- “Cooperation”=>“Coöperation”,.“cooperation”=>“coöperation”,.“Cooperate”=>“Coöperate”,→ “Cooperation”=>“Coöperation”,.“cooperation”=>“coöperation”,.“cooperative”=>“coöperative”,will be upgraded.
- “Cooperation”=>“Coöperation”,.“cooperation”=>“coöperation”,.“Cooperate”=>“Coöperate”,→ “Cooperation”=>“Coöperation”,.“Cooperation”=>“Coöperation”,.0will be skipped.
- “Cooperation”=>“Coöperation”,.“cooperation”=>“coöperation”,.“Cooperate”=>“Coöperate”,→ “cooperative”=>“coöperative”,.0.0will be skipped.
Normally, the script’s output is comparable to brew upgrade, streaming the familiar Homebrew output to your terminal for any patch-level updates while noting any skipped versions:
% brew-patch-upgrade.pl
Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0x7c075dc90)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0x7c0c1a180)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
==> Upgrading 1 outdated package:
zstd 1.5.6 -> 1.5.7
==> Fetching downloads for: zstd
==> Fetching zstd
==> Downloading https://ghcr.io/v2/homebrew/core/zstd/blobs/sha256:ddb0c145060bc2366ce5d58d95aa205bb15cb4c66948f20bb85e23fdb5eba7e9
Already downloaded: /Users/mjg/Library/Caches/Homebrew/downloads/eb865576547e163ef6908f1e5762f4dc4d7fb548940f7b896ae12c0d5b202362--zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
==> Upgrading zstd
1.5.6 -> 1.5.7
==> Pouring zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
🍺 /opt/homebrew/Cellar/zstd/1.5.7: 32 files, 2.2MB
==> Running `brew cleanup zstd`...
Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`.
Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).
Removing: /opt/homebrew/Cellar/zstd/1.5.6... (32 files, 2.1MB)
Removing: /Users/mjg/Library/Caches/Homebrew/zstd_bottle_manifest--1.5.6... (11.9KB)
Removing: /Users/mjg/Library/Caches/Homebrew/zstd--1.5.6... (753.9KB)
==> No outdated dependents to upgrade!
But since I intended this to run unattended on a schedule, you can also tell it to log to a different destination using the environment variable LOG_ANY_DEFAULT_ADAPTER:
% LOG_ANY_DEFAULT_ADAPTER=Stderr brew-patch-upgrade.pl
using log adapter Log::Any::Adapter::Stderr
outdated formulae: {casks => [],formulae => [{current_version => "12.0.0",installed_versions => ["11.5.1"],name => "harfbuzz",pinned => bless( do{\(my $o = 0)}, 'JSON::PP::Boolean' ),pinned_version => undef},{current_version => "3.10.0",installed_versions => ["3.9.0"],name => "woodpecker-cli",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef},{current_version => "1.5.7",installed_versions => ["1.5.6","1.5.7"],name => "zstd",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef}]}
Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0xa1a01f510)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0xa1acbcf30)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
Starting brew upgrade for zstd...
==> Upgrading 1 outdated package:
zstd 1.5.6 -> 1.5.7
Finished brew upgrade for zstd
Summary: upgraded 1, skipped 2, failed 0
That’s useful for debugging. But, the real payoff comes when you send logs to a more sophisticated adapter. A good example is my Log::Any::Adapter::MacOS::OSLogpackage. It tags different levels of log messages in macOS’ unified logging system. These messages can then be filtered and searched in the Console utility app.
The command is like the Stderrexample above:
% LOG_ANY_DEFAULT_ADAPTER=MacOS::OSLog brew-patch-upgrade.pl
And produces results that can be viewed in Console:

Or using the macOS log showcommand:

At the end, the script logs a summary of upgraded, skipped, and failed formulae. On failure, it exits non-zero to make it easy to use in automation.
Highlights from the script source code
For those curious about the internals, here are a few highlights from the source.
Skipping major and minor version bumps
use version;
...
sub process_formula ($formula_ref) {
my $name = $formula_ref->{name};
my ( $current, $installed ) = map { defined and qv($_) } (
$formula_ref->{current_version},
$formula_ref->{installed_versions}->[0] );
unless ( $current and $installed ) {
$logger->debug(
'no current and/or installed version detected for',
$name );
return 'skipped';
}
my $result = 'failed'; # default
my @current = $current->{version}->@*;
my @installed = $installed->{version}->@*;
#<<<
unless (@current >= 3
and @installed >= 3
and $installed[0] == $current[0]
and $installed[1] == $current[1]
and $installed[2] < $current[2] )
#>>>
{
carp "Skipping $name, needs manual review before upgrade";
return 'skipped';
}
...
At the start, process_formulachecks whether a formula from brew outdatedhas a valid semantic version. Only then does it compare patch numbers.
To that end, it uses the core Perl versionmodule’s qvfunction. This parses the current and latest installed version, avoiding a complicated regular expression. The resulting version objects can be treated as hash references with a versionkey. This key is itself a reference to an array containing the major, minor, and patch version numbers. If the major or minor version numbers differ between installed and current, the script calls for a manual review. Then it returns a “skipped” status.
Setting up the loggers
use Log::Any qw($logger);
use Log::Any::Adapter;
...
use constant {
MAC_LOG_ADAPTER_CLASS => 'Log::Any::Adapter::MacOS::OSLog',
MAC_LOG_SUBSYSTEM => 'com.phoenixtrap.brew-patch-upgrade',
};
my %brew_log;
if ( $ENV{LOG_ANY_DEFAULT_ADAPTER} ) {
my $adapter = Log::Any::Adapter->get(__PACKAGE__);
Log::Any::Adapter->set( q(+) . ref $adapter,
subsystem => MAC_LOG_SUBSYSTEM )
if $adapter->isa(MAC_LOG_ADAPTER_CLASS);
$logger->debug( 'using log adapter', ref $adapter );
$SIG{__WARN__} = sub ($message) { $logger->alert($message) };
$SIG{__DIE__} = sub ($message) { $logger->fatal($message) };
foreach my $stdio (qw(stdout stderr)) {
Log::Any::Adapter->set(
{ category => "brew.$stdio" },
q(+) . ref $adapter,
( subsystem => MAC_LOG_SUBSYSTEM,
os_category => "brew-$stdio",
)x!!$adapter->isa(MAC_LOG_ADAPTER_CLASS),
);
$brew_log{$stdio}
= Log::Any->get_logger( category => "brew.$stdio" );
}
}
If LOG_ANY_DEFAULT_ADAPTERis set, the script creates three loggers: one for the program itself, one for brew.stdout, and one for brew.stderr. All three have logic to check if the default logger class is Log::Any::Adapter::MacOS::OSLog. If it is, an appropriate OS-level logging category is set. A unique subsystem name for the script, “com.phoenixtrap.brew-patch-upgrade”, is also assigned.
Unfortunately, while working on this macOS-specific logging code, I made a discovery. I realized that I needed to fix my log adapter class with a patch of its own.
OSLog revisited
I was proud of my earlier workon the MacOS::OSLoglog adapter — a clever wrapper around macOS’ logging functions using Perl foreign function interface (FFI). I even took a victory lap, converting the CPAN distribution to use Dist::Zilla for pluggable installer creation and documentation.
But outside the packaged maclogscript, the module didn’t work. Oops.
Once I tried using the MacOS::OSLogadapter in brew-patch-upgrade.pl, the cracks became obvious. The bundle wasn’t built or installed correctly. My _find_my_bundlehackwas brittle — not a foundation to build on.
The solution was to stop faking it and let the FFI::Platypustoolchain do its job. Using Dist::Zilla::Plugin::MakeMaker::Awesome in my dist.inifile, I rewrote the build process to use FFI::Build::MMin the generated Makefile.PLinstaller. This meant that the bundle would compile and install in the right place instead of relying on my _find_my_bundlehack:
[MakeMaker::Awesome]
delimiter = |
...
header = |use FFI::Build::MM;
header = |my $fbmm = FFI::Build::MM->new();
header = |my %fbmm_args = $fbmm->mm_args(
header = | DISTNAME => 'Log-Any-Adapter-MacOS-OSLog',
header = | NAME => 'Log::Any::Adapter::MacOS::OSLog',
header = | VERSION_FROM => 'lib/Log/Any/Adapter/MacOS/OSLog.pm',
header = |);
header = |$fbmm_args{CCFLAGS} .= ' -mmacosx-version-min=10.12';
footer = |sub MY::postamble { $fbmm->mm_postamble }
WriteMakefile_arg = %fbmm_args
The location of the C source code as well as compilation flags moved to a separate ffi/OSLog.fbxfile, which FFI::Build::MMwould infer the name of based on the arguments passed to its mm_argsmethod.
{
source => [ 'ffi/OSLog.c' ],
cflags => [ '-mmacosx-version-min=10.12' ],
libs => [ '-framework', 'OSLog' ],
}
I also updated the C source codewith an #include <ffi_platypus_bundle.h>line, giving the resulting code bundle a proper entry point that FFI::Platypuscould recognize.
Finally, I set a default subsystem name (“com.example.perl”). Now users can make it their default Log::Anyadapter and start logging immediately.
The fix is in
With those fixes, the adapter now works as a native-grade component. No hacks. No guessing. Just clean, structured logs flowing into the unified logging system.
Each run of brew-patch-upgrade.plnow shows a clear subsystem, with categories for STDOUT, STDERR, and the script itself. This makes it easy to filter and review. And because the script exits with a summary line and proper exit code, it slots neatly into automation as well.
In other words, the logging side of brew-patch-upgrade.plis now as patch-perfect as the upgrade logic it supports.
Looking back, I realized that this project wasn’t just about taming Homebrew’s upgrades or fixing a stubborn Perl module. It was about creating a stronger connection between the tools I rely on and their expected behavior. I want them to be predictable, legible, and resilient. Both packages work together, keeping my upgrades quiet and intentional while surfacing them in the Console app.
Both the scriptand log adapterare on Codeberg, with the adapter also on CPAN. If you’d like to try them out, file issues, or suggest improvements, the repos are waiting.


Comments
2 responses to “Patch-Perfect: Smarter Homebrew Upgrades on macOS”
it seems the first two paragraphs contain the same sentences.
Thanks for the tip. I’ve corrected the error. It may take a little while for the changes to propagate through the page caches.