This is a story about patches.
At first, I just wanted Homebrew to behave a little more politely. Formulae should 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, Homebrew is 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 too easy. By default, brew upgrade jumps 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.pl takes a narrower view: it parses brew outdated command, compares semantic versions, and upgrades only when the patch number changes.
That means:
- 3.2.1 → 3.2.4 will be upgraded.
- 3.2.1 → 3.3.0 will be skipped.
- 3.2.1 → 4.0.0 will 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::OSLog package. 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 Stderr example 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 show command:

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_formula checks whether a formula from brew outdated has a valid semantic version. Only then does it compare patch numbers.
To that end, it uses the core Perl version module’s qv function. 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 version key. 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_ADAPTER is 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 work on the MacOS::OSLog log 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 maclog script, the module didn’t work. Oops.
Once I tried using the MacOS::OSLog adapter in brew-patch-upgrade.pl, the cracks became obvious. The bundle wasn’t built or installed correctly. My _find_my_bundle hack was brittle — not a foundation to build on.
The solution was to stop faking it and let the FFI::Platypus toolchain do its job. Using Dist::Zilla::Plugin::MakeMaker::Awesome in my dist.ini file, I rewrote the build process to use FFI::Build::MM in the generated Makefile.PL installer. This meant that the bundle would compile and install in the right place instead of relying on my _find_my_bundle hack:
[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.fbx file, which FFI::Build::MM would infer the name of based on the arguments passed to its mm_args method.
{
source => [ 'ffi/OSLog.c' ],
cflags => [ '-mmacosx-version-min=10.12' ],
libs => [ '-framework', 'OSLog' ],
}
I also updated the C source code with an #include <ffi_platypus_bundle.h> line, giving the resulting code bundle a proper entry point that FFI::Platypus could recognize.
Finally, I set a default subsystem name (“com.example.perl”). Now users can make it their default Log::Any adapter 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.pl now 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.pl is 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 script and log adapter are 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.