The Nightly brew upgrade
How I automated brew upgrade so I never think about it again. A 100-line bash script, a launchd agent that fires at 10am daily, and a structured error log that writes its own recovery playbook. Quiet on success. Loud and useful on failure.
I got tired of running brew upgrade by hand. Here's how I stopped thinking about it.
A small bash script, a launchd agent that fires once a day, and a trick with the error log that I'm pleased with and would recommend stealing. Quiet on success. Loud and useful on failure. Pinnable around fragile packages. Works the same on Apple Silicon and Intel. About 100 lines total. Runs at 10am, every day, forever.
This is the full setup, end to end, shorter than you'd expect.
The shape of the thing
Five pieces:
- A script at
~/bin/brew-auto.shthat runsbrew update,upgrade --formula,upgrade --cask, andcleanupin sequence. - A launchd agent at
~/Library/LaunchAgents/com.user.brewauto.plistthat fires it once a day at 10am. - A rolling log at
~/Library/Logs/brew-auto.logso you can see what's been happening. - A structured error file at
~/Library/Logs/brew-auto-error.logthat gets overwritten each failure — with the error output AND pre-written recovery commands inside it. - A macOS notification that only fires on failure. Clicking it opens the error file directly in your editor.
Launchd instead of cron because the laptop sleeps and cron just skips, while launchd runs the job on next wake. Same reason I use it for the QMD daemon and auto-updater in The Exomind — it's the macOS-native pattern for personal infrastructure.
The script
Save as ~/bin/brew-auto.sh, then chmod +x.
#!/usr/bin/env bash
# brew-auto.sh — daily automatic brew upgrade with failure notifications
set -o pipefail
LOG_DIR="$HOME/Library/Logs"
LOG_FILE="$LOG_DIR/brew-auto.log"
ERR_FILE="$LOG_DIR/brew-auto-error.log"
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
HOST="$(hostname -s)"
# launchd doesn't inherit shell PATH — set it explicitly (works on Apple Silicon + Intel)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
# Quieter, non-interactive brew
export HOMEBREW_NO_AUTO_UPDATE=1 # we call update explicitly below
export HOMEBREW_NO_ENV_HINTS=1
export HOMEBREW_NO_ANALYTICS=1
mkdir -p "$LOG_DIR"
if ! command -v brew >/dev/null 2>&1; then
echo "[$TIMESTAMP] FATAL: brew not found in PATH" >> "$LOG_FILE"
exit 1
fi
BREW="$(command -v brew)"
NOTIFIER="$(command -v terminal-notifier 2>/dev/null || true)"
FAILURES=()
FAILURE_OUTPUT=""
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; }
notify() {
local title="$1" subtitle="$2" message="$3"
if [ -n "$NOTIFIER" ]; then
"$NOTIFIER" -title "$title" -subtitle "$subtitle" -message "$message" \
-execute "open -e $ERR_FILE" -sound Basso >/dev/null 2>&1 || true
else
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"$subtitle\"" >/dev/null 2>&1 || true
fi
}
# Run one step, capture output, flag as failed on non-zero exit OR on an ^Error: line
run_step() {
local label="$1"; shift
log "----- $label -----"
local out code
out=$("$@" 2>&1); code=$?
printf '%s\n' "$out" >> "$LOG_FILE"
if [ $code -ne 0 ] || printf '%s\n' "$out" | grep -qE "^Error:"; then
FAILURES+=("$label")
FAILURE_OUTPUT="${FAILURE_OUTPUT}
--- $label ---
$out"
fi
return 0
}
log "===== brew auto-upgrade run ====="
run_step "brew update" "$BREW" update
run_step "brew upgrade --formula" "$BREW" upgrade --formula
run_step "brew upgrade --cask" "$BREW" upgrade --cask
run_step "brew cleanup" "$BREW" cleanup
if [ ${#FAILURES[@]} -eq 0 ]; then
log "===== completed ok ====="
exit 0
fi
DOCTOR_OUT="$("$BREW" doctor 2>&1 || true)"
{
echo "=== Brew Auto-Upgrade Failure ==="
echo "Time: $TIMESTAMP"
echo "Host: $HOST"
echo
echo "--- WHAT FAILED ---"
for f in "${FAILURES[@]}"; do echo " x $f"; done
echo
echo "--- ERROR OUTPUT ---"
echo "$FAILURE_OUTPUT"
echo
echo "--- BREW DOCTOR SAYS ---"
echo "$DOCTOR_OUT"
echo
echo "--- SUGGESTED FIXES (try in order) ---"
echo " 1. brew doctor # full diagnostic"
echo " 2. brew reinstall <package> # for any failed package"
echo " 3. brew link --overwrite <package> # linking errors"
echo " 4. brew uninstall <pkg> && brew install <pkg>"
echo
echo "--- ROLLBACK + PIN ---"
echo " brew install <pkg>@<version> # if a versioned formula exists"
echo " brew pin <pkg> # stop auto-upgrades until unpinned"
echo
echo "--- PAUSE AUTO-UPGRADES ---"
echo " launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.brewauto.plist"
echo
echo "--- FULL HISTORY ---"
echo " tail -100 ~/Library/Logs/brew-auto.log"
} > "$ERR_FILE"
log "===== finished with failures: ${FAILURES[*]} ====="
notify "Brew upgrade failed" "${#FAILURES[@]} step(s) had errors" "Click for details + fixes"
exit 1Four non-obvious bits worth naming, because they're the reasons hand-rolled versions silently break:
export PATHat the top. launchd starts with an empty-ish environment and does not read your~/.zshrc. Without this line,brew: command not foundfires at 10am every day and the only place you'd notice is a log file you never check. Both Homebrew roots (/opt/homebrew/binfor Apple Silicon,/usr/local/binfor Intel) are included so one script works on both architectures.- Dual failure detection.
brew upgradecan hit an error on one formula, succeed on the others, and still exit 0. Yourif [[ $? -ne 0 ]]check would miss it. Therun_stephelper treats a step as failed if either the exit code is non-zero or the output contains a line starting withError:. Catches the silent partial-failure case. - No
set -eu. The traditional bash strict mode breaks on macOS because Apple ships bash 3.2 from 2007 (GPLv2 reasons), and underset -ureferencing${FAILURES[@]}on an empty array errors out. The happy path crashes under system bash.set -o pipefailalone is enough.-eis also wrong here — we want all four brew steps to run even if one fails, and collect failures at the end. - Fallback notification chain. If
terminal-notifieris installed we use it (clickable, opens the error file). Otherwise we fall back toosascript display notification(no click action but works without install). One gotcha: the first timeterminal-notifierruns, macOS silently queues a permission prompt. Fire one manual test notification immediately after install, and click Allow when System Settings asks — otherwise the script will look broken forever for a reason that isn't the script's fault.
The launchd agent
Save as ~/Library/LaunchAgents/com.user.brewauto.plist. Replace /Users/you/ with your actual home.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.brewauto</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/bin/brew-auto.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>10</integer>
<key>Minute</key> <integer>0</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>/Users/you/Library/Logs/brew-auto.launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Library/Logs/brew-auto.launchd.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>Load it with the modern bootstrap command (the older load/unload still work but Apple has been quietly deprecating them):
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.brewauto.plist
launchctl list | grep brewauto
# Run it manually once to confirm the happy path
~/bin/brew-auto.sh && tail -20 ~/Library/Logs/brew-auto.logRunAtLoad is false on purpose — the job fires once a day, not every time you reboot or reload the agent. StartCalendarInterval at 10am is sleep-aware: if the laptop is asleep at 10am, the job runs on next wake. You don't miss a day unless you're gone for several.
The trick: the error log is the recovery playbook
This is the part I'm most pleased with.
When a step fails, the script writes a structured error file. Not just what broke — the commands you should run to fix it, pre-formatted, already scoped to this failure. When the notification fires and you click it, that file opens directly in your editor:
=== Brew Auto-Upgrade Failure ===
Time: 2026-04-12 10:00:03
Host: your-mac
--- WHAT FAILED ---
x brew upgrade --formula
x brew cleanup
--- ERROR OUTPUT ---
--- brew upgrade --formula ---
==> Upgrading node 21.6.1 -> 21.7.0
Error: An exception occurred within a child process:
Errno::ENOENT: No such file or directory @ rb_sysopen
...
--- BREW DOCTOR SAYS ---
Warning: You have unlinked kegs in your Cellar.
...
--- SUGGESTED FIXES (try in order) ---
1. brew doctor # full diagnostic
2. brew reinstall <package> # for any failed package
3. brew link --overwrite <package> # linking errors
4. brew uninstall <pkg> && brew install <pkg>
--- ROLLBACK + PIN ---
brew install <pkg>@<version>
brew pin <pkg>
--- PAUSE AUTO-UPGRADES ---
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.brewauto.plist
--- FULL HISTORY ---
tail -100 ~/Library/Logs/brew-auto.logMost automation scripts, when they fail, log a line saying ERROR: brew upgrade failed and leave you to figure out the rest. You open a log, scan raw output, paste an error into a search engine, land on a three-year-old GitHub issue, try brew reinstall, try brew link --overwrite, and twenty minutes later you've context-switched out of whatever you were doing that morning.
The pre-filled error file flips that around. Recovery becomes a 60-second operation: open the file, scan the failed steps, run the commands already typed out. The script already knows more about the context than you do in the moment — put that knowledge into the file while it's fresh.
The pattern generalizes. Any automation script that can fail in known ways should write a recovery playbook as part of its failure output. Your future self, pulled out of flow by a notification, will thank your current self.
Daily control surface
Once it's running, here's everything you'll reach for:
# Recent history
tail -50 ~/Library/Logs/brew-auto.log
# Last failure (opens in default editor)
open -e ~/Library/Logs/brew-auto-error.log
# Run it manually right now
~/bin/brew-auto.sh
# Pin a fragile package so it never auto-upgrades
brew pin postgresql@16
# What's pinned
brew list --pinned
# Pause / resume
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.brewauto.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.brewauto.plistbrew pin is the one I use most. When a formula breaks my build twice in a row I pin it at the last known good version and let the rest of the ecosystem keep moving forward. The script respects pins natively — no special-case logic needed.
The takeaway
Strip the brew-specific pieces away and you're left with a reusable skeleton for any nightly automation you want to trust:
- launchd agent on a calendar schedule with
PATHset explicitly - script that runs steps, captures output, detects failures on exit code and content, and accumulates failures instead of aborting
- failure path that writes a structured error file with the error context and pre-formatted recovery commands
- notification chain that opens the error file on click
- no retries, no escalation, no success spam — daily runs, daily signal only when there's something to say
Same shape as the QMD auto-updater I described in The Exomind. Once this scaffolding is in muscle memory, adding the next automated job takes about 20 minutes.
Steal the script, pin the packages you don't trust, wire up the notification permission once, and forget it exists. Which is the whole point.
Related Reading
- The Exomind — the broader pattern this slots into: launchd agents as a personal-infrastructure layer
- Things 3: The Complete System — companion piece on building personal systems you can trust
- KISS Your AI Workflow — same simplicity principle, different domain
- How to Install Claude Code the Right Way in 2026 — related genre: set it up right the first time so you stop thinking about it