Last active
December 20, 2025 15:53
-
-
Save dzogrim/00e3d5dd9e2b14680204fe4d5d58f8d3 to your computer and use it in GitHub Desktop.
This script automates Homebrew maintenance on macOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # SPDX-License-Identifier: MIT | |
| # SPDX-FileCopyrightText: 2019–2025 dzogrim <dzogrim@dzogrim.pw> | |
| # | |
| # ------------------------------------------------------------------------------ | |
| # brew-update.sh — Homebrew Maintenance Tool for macOS | |
| # | |
| # This script automates Homebrew maintenance on macOS: | |
| # - Updates the package index (brew update) | |
| # - Lists and upgrades outdated packages (brew upgrade) | |
| # - Fixes permissions on "/opt/homebrew/Cellar" if needed | |
| # - Cleans up outdated and unlinked versions (brew cleanup -s) | |
| # - Optionally runs 'brew doctor' (with HELP argument) | |
| # | |
| # Features: | |
| # - macOS-only safeguard | |
| # - Avoids root execution | |
| # - Lock mechanism to prevent concurrent runs | |
| # - Minimal, reliable output | |
| # | |
| # Usage: | |
| # ./brew-update.sh # Run normal update & cleanup | |
| # ./brew-update.sh HELP # Run and also perform 'brew doctor' | |
| # | |
| # Requirements: | |
| # - Homebrew installed | |
| # - 'lockfile' utility available | |
| # Optional: | |
| # - brew_conf_export.sh # To run and also perform a brew bundle in Dropbox | |
| # | |
| # License: MIT | |
| VERSION="0.7 (2025-12-20)" | |
| # Author: dzogrim <dzogrim@dzogrim.pw> | |
| # ------------------------------------------------------------------------------ | |
| set -euo pipefail | |
| # ------------------------------------------------------------------------------ | |
| # Configuration | |
| # ------------------------------------------------------------------------------ | |
| NAME="$(basename "$0")" | |
| LOCK="/tmp/${NAME}.lock" | |
| DOCTOR=0 | |
| # ------------------------------------------------------------------------------ | |
| # Colors | |
| # ------------------------------------------------------------------------------ | |
| cRST='\033[0m' | |
| cRed='\033[31;1m' | |
| cGreen='\033[32;1m' | |
| cYellow='\033[33;1m' | |
| # Ensure the script is running on macOS only. | |
| if [[ "$(uname)" != "Darwin" ]]; then | |
| # shellcheck disable=SC2059 | |
| printf "\n${cRed}Error:${cRST} This program is intended for macOS only. Exiting.\n" | |
| exit 1 | |
| fi | |
| log() { printf " ${cGreen}*${cRST} %s\n" "$@"; } | |
| warn() { printf " ${cYellow}!${cRST} %s\n" "$@" >&2; } | |
| error() { printf " ${cRed}✘ [ERROR]${cRST} %s\n" "$@" >&2; exit 1; } | |
| version() { echo "$NAME v$VERSION"; exit 0; } | |
| # ------------------------------------------------------------------------------ | |
| # Lock + Checks | |
| # ------------------------------------------------------------------------------ | |
| [[ -n "${BASH_VERSINFO:-}" && "${BASH_VERSINFO[0]}" -ge 4 ]] \ | |
| || error "Bash >= 4 is required (install with Homebrew: brew install bash)." | |
| BREW_BIN="" | |
| CELLAR_DIR="" | |
| check_requirements() { | |
| command -v lockfile >/dev/null || error "lockfile is required." | |
| command -v brew >/dev/null || error "Homebrew is not installed." | |
| [[ "$(id -u)" -ne 0 ]] || error "Do not run as root." | |
| BREW_BIN="$(command -v brew)" | |
| CELLAR_DIR="$("$BREW_BIN" --cellar)" | |
| } | |
| acquire_lock() { | |
| lockfile -r 0 "$LOCK" || error "Another instance is running." | |
| trap 'rm -f "$LOCK"' EXIT | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # Operations | |
| # ------------------------------------------------------------------------------ | |
| fix_cellar_permissions() { | |
| if [ ! -w "$CELLAR_DIR" ]; then | |
| warn "Cellar is not writable. Attempting fix..." | |
| sudo chown -R "$(whoami)" "$CELLAR_DIR" || error "Failed to fix permissions." | |
| fi | |
| } | |
| update_brew() { | |
| log "Updating Homebrew..." | |
| "$BREW_BIN" update | |
| } | |
| # Upgrade Homebrew packages in a way that is resilient to upstream breakage. | |
| # What it does: | |
| # - Lists outdated formulas and casks (best effort). | |
| # - Upgrades formulas first (`brew upgrade --formula`). | |
| # - Upgrades casks separately (`brew upgrade --cask <list>`), optionally filtering | |
| # out known-broken casks to keep the run non-blocking. | |
| # Error handling policy: | |
| # - Upgrade steps are intentionally non-fatal: failures are logged as warnings and | |
| # execution continues. This script aims to be "best effort" rather than strict. | |
| # Notes: | |
| # - Keep the filter list minimal and temporary; remove entries once upstream is fixed. | |
| upgrade_brew() { | |
| log "Listing outdated formulas..." | |
| "$BREW_BIN" outdated --formula || true | |
| log "Listing outdated casks..." | |
| "$BREW_BIN" outdated --cask || true | |
| log "Upgrading formulas..." | |
| "$BREW_BIN" upgrade --formula || warn "brew upgrade --formula failed (non-blocking)." | |
| log "Upgrading casks..." | |
| mapfile -t casks < <("$BREW_BIN" outdated --cask --quiet 2>/dev/null || true) | |
| if ((${#casks[@]})); then | |
| # Workaround for broken casks that can temporarily exist upstream | |
| filtered=() | |
| for c in "${casks[@]}"; do | |
| # emacs-app: upstream cask currently invalid (conflicts_with formula disabled). | |
| # Skip it to avoid aborting the whole upgrade. | |
| if [[ "$c" == "emacs-app" ]]; then | |
| warn "Skipping cask 'emacs-app' (invalid cask definition upstream)." | |
| continue | |
| fi | |
| filtered+=("$c") | |
| done | |
| if ((${#filtered[@]})); then | |
| "$BREW_BIN" upgrade --cask "${filtered[@]}" || warn "brew upgrade --cask failed (non-blocking)." | |
| else | |
| log "No casks to upgrade (after filtering)." | |
| fi | |
| else | |
| log "No outdated casks." | |
| fi | |
| } | |
| cleanup_brew() { | |
| log "Cleaning up old versions..." | |
| fix_cellar_permissions | |
| "$BREW_BIN" cleanup -s | |
| } | |
| doctor_report() { | |
| if [[ "$DOCTOR" -eq 1 ]]; then | |
| log "Running brew doctor..." | |
| "$BREW_BIN" doctor || warn "brew doctor reported issues." | |
| fi | |
| } | |
| ask_backup_config() { | |
| if command -v brew_conf_export.sh >/dev/null; then | |
| echo "" | |
| read -r -t 30 -p "Do you want to backup the configuration with brew_conf_export.sh? [y/N] " choice || choice="n" | |
| case "$choice" in | |
| [yY][eE][sS]|[yY]) | |
| log "Launching brew_conf_export.sh..." | |
| brew_conf_export.sh | |
| ;; | |
| *) | |
| echo "" | |
| warn "Skipping configuration backup." | |
| ;; | |
| esac | |
| else | |
| warn "brew_conf_export.sh not found in PATH — skipping backup." | |
| fi | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # Main | |
| # ------------------------------------------------------------------------------ | |
| usage() { | |
| echo "Usage: $NAME [APPLY|HELP|-h|-v]" | |
| echo " -h → display usage" | |
| echo " -v → display version" | |
| echo " APPLY → simply runs the program (default)" | |
| echo " HELP → also run 'brew doctor' at the end" | |
| exit 1 | |
| } | |
| main() { | |
| [[ "${1:-}" == "-v" ]] && version | |
| [[ "${1:-}" == "-h" || $# -gt 1 ]] && usage | |
| [[ "${1:-}" == "APPLY" ]] && DOCTOR=0 | |
| [[ "${1:-}" == "HELP" ]] && DOCTOR=1 | |
| check_requirements | |
| acquire_lock | |
| update_brew | |
| upgrade_brew | |
| cleanup_brew | |
| ask_backup_config | |
| doctor_report | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment