The first post in this series covers why I wait two weeks before installing any new release. Homebrew has no built-in setting to handle this, so what do we do?
Why Homebrew doesn't have a cooldown setting
This isn't an oversight. Homebrew has turned it down on purpose, more than once. Feature requests for a minimum age on brew install and brew upgrade (#21421) and for a global "minimum-release age" setting (#22659) were both closed as not planned. The reasoning is written up in Homebrew's security and supply chain docs:
A blanket cooldown would trade a small, speculative reduction in supply-chain risk for a real, across-the-board delay in shipping critical fixes.
That is a fair argument for the default. It just doesn't stop me from choosing a different tradeoff on my own machine.
My solution: lag the tap metadata
Brew can only install what its formula and cask definitions describe. Those definitions are git clones on disk. If I keep the clones 14 days behind upstream, everything brew sees (install, upgrade, outdated, deps, brew bundle) is at least 14 days old.
That takes three pieces: stop brew from refreshing itself, a script that moves the clones, and a shell function so brew update uses the script.
1. Create ~/.homebrew/brew.env
HOMEBREW_NO_INSTALL_FROM_API=1
HOMEBREW_NO_AUTO_UPDATE=1
HOMEBREW_NO_INSTALL_UPGRADE=1HOMEBREW_NO_INSTALL_FROM_API is the important one: it makes brew read the local tap clones instead of the live API, which would ignore the lag.
2. Add ~/bin/brew-update-lagged
It resets every tap to the newest commit older than 14 days, and Homebrew itself to the newest release tag older than 14 days.
#!/bin/zsh
set -euo pipefail
BREW=/opt/homebrew/bin/brew
BREW_REPO=/opt/homebrew
CORE=$BREW_REPO/Library/Taps/homebrew/homebrew-core
days=${BREW_LAG_DAYS:-14}
# Colored box on a terminal, plain text in logs and pipes
boxed() {
local color=$1 msg=$2
if [[ -t 2 ]]; then
local line=${(l:${#msg}+2::─:)}
print -u2 "\e[${color}m┌${line}┐\n│ ${msg} │\n└${line}┘\e[0m"
else
print -u2 -- "$msg"
fi
}
banner() {
local when=$(git -C "$CORE" log -1 --format=%ct)
local age=$(( ($(date +%s) - when) / 86400 ))
local ver=$(git -C "$BREW_REPO" describe --tags)
if (( age < days )); then
boxed 31 "brew: COOLDOWN OFF, formulae are only $age days old (want $days). Run 'brew update' to restore."
else
boxed 32 "brew: ${days}-day cooldown, formulae as of $(date -r "$when" +%F) ($age days old), Homebrew $ver"
fi
}
case ${1:-} in
--banner) banner; exit 0 ;;
--days) days=${2:?--days needs a number} ;;
esac
cutoff=$(( $(date +%s) - days * 86400 ))
repos=("$BREW_REPO")
for tap in $($BREW tap); do repos+=("$($BREW --repository "$tap")"); done
for repo in $repos; do
if [[ -n $(git -C "$repo" status --porcelain --untracked-files=no) ]]; then
echo "skip $repo: has local changes"; continue
fi
upstream=$(git -C "$repo" rev-parse --abbrev-ref --symbolic-full-name '@{u}')
if [[ $repo == $BREW_REPO ]]; then
# Homebrew itself: newest release tag older than the cutoff
git -C "$repo" fetch --quiet --tags origin
tag=$(git -C "$repo" for-each-ref --sort=-creatordate \
--format='%(creatordate:unix) %(refname:short)' 'refs/tags/[0-9]*' |
awk -v c="$cutoff" '$1 <= c { print $2; exit }') || true
[[ -n $tag ]] || continue
target=$(git -C "$repo" rev-parse "$tag^{commit}")
else
# Taps: newest mainline commit older than the cutoff
git -C "$repo" fetch --quiet origin
target=$(git -C "$repo" rev-list -1 --first-parent --before="@$cutoff" "$upstream")
fi
[[ -n $target ]] || continue
git -C "$repo" reset --quiet --hard "$target"
echo "$repo -> $(git -C "$repo" log -1 --format=%cs "$target"), $(git -C "$repo" rev-list --count "$target..$upstream") commits held back"
done
banner
$BREW outdated || trueThen make it executable:
chmod +x ~/bin/brew-update-lagged3. Add a brew function to your shell rc
Put it in whichever rc file your terminal actually loads (~/.bashrc, ~/.zshrc, etc) and source the file again.
brew() {
case "$1" in
update|update-reset) shift; "$HOME/bin/brew-update-lagged" "$@" ;;
install|reinstall|upgrade|outdated|bundle) "$HOME/bin/brew-update-lagged" --banner; command brew "$@" ;;
*) command brew "$@" ;;
esac
}Usage
Nothing changes. You use brew exactly the way you already do:
brew update # moves the taps to 14 days ago instead of today
brew upgrade
brew install <pkg>The only difference is a banner before install, upgrade, and outdated, so you know the cooldown is on:
┌─────────────────────────────────────────────────────────────────────────────────┐
│ brew: 14-day cooldown, formulae as of 2026-10-11 (14 days old), Homebrew 7.0.10 │
└─────────────────────────────────────────────────────────────────────────────────┘If the taps are ever less than 14 days old, it turns red and says COOLDOWN OFF.
For an urgent fix, override the cooldown, upgrade the one package, and go back:
~/bin/brew-update-lagged --days 0 # jump to latest, banner goes red
brew upgrade <pkg>
brew update # back to the lagged viewCaveats
- Security fixes wait too. That is exactly the tradeoff the maintainers warn about. Use the override above when a fix matters.
- Casks can break. They download vendor URLs, and some vendors delete old versions or serve a
:latestURL. A 14-day-old cask may 404 or fail its checksum. brew reinstallcan downgrade. Upgrade won't, but reinstalling a package that is newer than the lagged view installs the older version.--HEADskips the cooldown. It builds from upstream git.- Local clones are slower than the API.
- It isn't tamper-proof. A script calling
/opt/homebrew/bin/brew updatedirectly skips the wrapper and moves the taps to latest until my next lagged update. The red banner will at least tell me. It's a default, not a lock.