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.

The Nightly brew upgrade — Craft

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.sh that runs brew update, upgrade --formula, upgrade --cask, and cleanup in sequence.
  • A launchd agent at ~/Library/LaunchAgents/com.user.brewauto.plist that fires it once a day at 10am.
  • A rolling log at ~/Library/Logs/brew-auto.log so you can see what's been happening.
  • A structured error file at ~/Library/Logs/brew-auto-error.log that 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 1

Four non-obvious bits worth naming, because they're the reasons hand-rolled versions silently break:

  • export PATH at the top. launchd starts with an empty-ish environment and does not read your ~/.zshrc. Without this line, brew: command not found fires at 10am every day and the only place you'd notice is a log file you never check. Both Homebrew roots (/opt/homebrew/bin for Apple Silicon, /usr/local/bin for Intel) are included so one script works on both architectures.
  • Dual failure detection. brew upgrade can hit an error on one formula, succeed on the others, and still exit 0. Your if [[ $? -ne 0 ]] check would miss it. The run_step helper treats a step as failed if either the exit code is non-zero or the output contains a line starting with Error:. 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 under set -u referencing ${FAILURES[@]} on an empty array errors out. The happy path crashes under system bash. set -o pipefail alone is enough. -e is 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-notifier is installed we use it (clickable, opens the error file). Otherwise we fall back to osascript display notification (no click action but works without install). One gotcha: the first time terminal-notifier runs, 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.log

RunAtLoad 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.log

Most 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.plist

brew 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 PATH set 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.


💬
Working with a team that wants to adopt AI-native workflows at scale? I help engineering teams build this capability — workflow design, knowledge architecture, team training, and embedded engineering. → AI-Native Engineering Consulting