The recurring "keep it current" pass, run safely: show before changing, respect self-updating apps and pinned majors, never surprise-restart the machine. Designed to be re-run weekly/monthly — every step is idempotent.
When NOT to use
- First-time setup of anything → the respective setup skill
- Broken brew (
doctorerrors) →homebrew-setup - Project dependency upgrades (package.json, lockfiles) → project work
- macOS major-version migration → manual, deliberate; only point at System Settings
Safety rails
- Timing check: ask/warn if the user is mid-release or pre-demo — upgrade waves before deadlines are self-inflicted outages.
- Show
brew outdatedbeforebrew upgrade; list, then act. softwareupdate --installmay restart — explicit consent, never--restartunattended; prefer--recommended --os-onlyfor scripts.- Never blind-upgrade databases across majors — versioned formulae
(
postgresql@17) stay within their major, but unversioned installs (postgresqlalias,mysql) can jump and strand the data dir (pg_upgrade territory). Neverbrew bundle cleanup --forceordocker system prunewithout showing the removal list first. - Report what changed, what was skipped and why.
Workflow
1. Homebrew core pass
brew update # refresh brew + metadata (not the packages)
brew outdated # SHOW the user this list first
brew services list # data-bearing services in the outdated list?
Before upgrading, scan the outdated list for major-version-sensitive
packages: database services installed via unversioned formulae
(postgresql alias, mysql, mongodb-*) can jump majors and strand
data (versioned postgresql@17 stays within 17.x — that's why we pin).
Exclude those from the pass (brew pin <formula> makes it stick) or
handle them deliberately with the user. Then:
brew upgrade # upgrade unpinned formulae + non-auto-update casks
brew cleanup --dry-run && brew autoremove --dry-run # preview what goes
brew cleanup && brew autoremove # then reclaim
brew doctor # advisory warnings are fine; errors are not
Cask semantics — the perennial confusion: casks marked auto_updates
(Chrome, VS Code, Slack, …) are skipped by design — the apps update
themselves. Forcing brew ownership: brew upgrade --cask --greedy (or the
narrower --greedy-auto-updates) — usually unnecessary; default to
letting self-updaters work.
2. Brewfile re-capture (if the user keeps one)
Ask where the tracked Brewfile lives (~/Brewfile or
~/dotfiles/Brewfile), then update it via candidate + diff — a blind
dump --force flattens curated comments and captures accidental installs:
BF=~/Brewfile # or ~/dotfiles/Brewfile - the user's tracked copy
brew bundle check --file="$BF" || echo "drift"
brew bundle dump --describe --file=/tmp/Brewfile.candidate
diff -u "$BF" /tmp/Brewfile.candidate # user approves, then replace $BF
3. App Store + macOS
mas outdated && mas update # App Store apps (signed-in App Store required)
softwareupdate --list # read-only
# with consent + restart warning:
sudo softwareupdate --install --recommended --os-only
4. Shell & runtime layer (detect, then update what exists)
[ -d ~/.oh-my-zsh ] && zsh -ic 'omz update' # omz is a shell function - needs interactive zsh
command -v mise >/dev/null && { brew upgrade mise 2>/dev/null; mise outdated; mise upgrade; }
[ -d ~/.nvm ] && echo "nvm: on request - nvm install 'lts/*' --reinstall-packages-from=default (new version, migrates globals; old versions stay until nvm uninstall)"
command -v pyenv >/dev/null && echo "pyenv: new Python versions are installed, not upgraded - pyenv install <ver> on request"
command -v uv >/dev/null && uv tool upgrade --all # uv itself updates via brew
command -v pipx >/dev/null && pipx upgrade-all
command -v rustup >/dev/null && rustup update # toolchains; rustup itself via brew
command -v gh >/dev/null && gh extension upgrade --all
npm outdated -g 2>/dev/null; echo "npm -g: review the list above (outdated exits non-zero when items exist), then npm update -g"
Rules: managers update within configured ranges (mise respects
mise.toml; pnpm respects packageManager pins) — don't bump project pins
here. gcloud installed via brew cask updates via brew, not components update (dual-ownership drift).
5. Disk reclaim (on request)
Read-only first: docker system df, brew cleanup --dry-run,
du -sh ~/Library/Developer/Xcode/DerivedData 2>/dev/null. Then safe
reclaims: brew cleanup -s, npm cache verify. Destructive tier —
show the list, then confirm: docker system prune (volumes excluded by
default; --volumes deletes data — treat as data loss); xcrun simctl list devices unavailable then xcrun simctl delete unavailable
(irreversibly deletes those simulators and their app data);
DerivedData deletion (rebuild cost).
6. One-command habit (optional)
brew install topgrade runs ~everything (brew, mas, npm, rustup, mise,
uv, omz, gh, …). Ship it with a reviewed config, not defaults:
# ~/.config/topgrade.toml
[misc]
disable = ["system", "mas"] # OS + App Store stay manual/consented
[brew]
autoremove = true
topgrade --dry-run first. Prefer the manual routine above when the user
dislikes meta-tools.
7. Report
Summarize: upgraded (with notable version jumps), skipped (auto-update casks, pinned majors, deferred OS update), disk reclaimed, and anything that now needs a new shell/restart.
Output spec
Done means: the pass ran in the order above with consents honored,
brew doctor is clean-or-advisory, Brewfile matches reality (if kept),
and the user got the change report. Nothing pinned or major-version
-sensitive moved without explicit approval.
Gotchas
brew updatevsupgradenaming trips apt users — update = metadata, upgrade = packages.- brew auto-updates itself before installs (24h cadence) — a "quick install" sometimes triggers a long metadata refresh; that's normal.
mas updateneeds the App Store signed in; it can't purchase.nvm install --ltsinstalls a new version — old ones remain untilnvm uninstall; global packages migrate only with--reinstall-packages-from.uv self updatefails on brew installs by design (standalone-installer feature) —brew upgrade uv.- Post-upgrade breakage checklist:
xcode-select -pafter macOS updates (CLT pointer), reshim after runtime-manager changes, restart brew services if a database minor bumped.