Last active
March 12, 2026 11:03
-
-
Save whomwah/6c303d8a139db782b89ea5eeb6234818 to your computer and use it in GitHub Desktop.
Upgrade only patch-level Homebrew packages (e.g. 1.2.3 → 1.2.4). Scans brew outdated for packages where only the patch version changed, then offers to upgrade them — so you can apply low-risk security/bug-fix updates without pulling in breaking minor or major changes.
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
| #!/bin/bash | |
| # | |
| # brew-security.sh — Upgrade only patch-level Homebrew packages | |
| # | |
| # Scans `brew outdated` for packages where only the patch version has changed | |
| # (e.g. 0.9.8 -> 0.9.9) and offers to upgrade them. Skips minor/major bumps | |
| # so you can apply low-risk security and bug-fix updates without accidentally | |
| # pulling in breaking changes. | |
| # | |
| # Before each upgrade, performs a dry run to detect dependent packages that | |
| # would also be upgraded. If any dependent would receive a non-patch (minor or | |
| # major) version bump, the user is warned and asked to confirm before that | |
| # package is upgraded. | |
| # | |
| # Usage: brew-security.sh [-h] | |
| # | |
| # Options: | |
| # -h Show this help message and exit | |
| # --- Help ------------------------------------------------------------------- | |
| if [[ "$1" == "-h" ]]; then | |
| sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' | |
| exit 0 | |
| fi | |
| # --- Fetch outdated packages ------------------------------------------------ | |
| echo "Checking for Homebrew patch updates (e.g., 0.9.8 -> 0.9.9)..." | |
| # --verbose includes version details: "zoxide (0.9.8) < 0.9.9" | |
| # Without --verbose, brew outdated only prints package names. | |
| outdated_info=$(brew outdated --verbose) | |
| if [ -z "$outdated_info" ]; then | |
| echo "No outdated packages found." | |
| exit 0 | |
| fi | |
| # --- Parse and filter ------------------------------------------------------- | |
| patch_list=() | |
| while read -r line; do | |
| # Expected line format: name (installed_ver) < available_ver | |
| # Packages with multiple installed versions look like: | |
| # sqlite (3.51.0, 3.51.1, 3.51.2) < 3.51.2_1 | |
| name=$(echo "$line" | awk '{print $1}') | |
| # Extract all versions from inside the parentheses, then take the last | |
| # one — that's the most recent installed version. | |
| old_ver=$(echo "$line" | grep -oE '\([^)]+\)' | tr -d '()' | awk -F', ' '{print $NF}' | tr -d ' ') | |
| # The available (new) version is always the last field on the line. | |
| new_ver=$(echo "$line" | awk '{print $NF}') | |
| # A patch update has the same MAJOR.MINOR but a different full version. | |
| old_major_minor=$(echo "$old_ver" | cut -d. -f1,2) | |
| new_major_minor=$(echo "$new_ver" | cut -d. -f1,2) | |
| if [[ "$old_major_minor" == "$new_major_minor" && "$old_ver" != "$new_ver" ]]; then | |
| echo " Found patch: $name ($old_ver -> $new_ver)" | |
| patch_list+=("$name") | |
| fi | |
| done <<< "$outdated_info" | |
| # --- Results ---------------------------------------------------------------- | |
| if [ ${#patch_list[@]} -eq 0 ]; then | |
| echo "No patch-level updates available." | |
| exit 0 | |
| fi | |
| echo "" | |
| echo "The following packages have patch updates available:" | |
| for pkg in "${patch_list[@]}"; do | |
| echo " - $pkg" | |
| done | |
| echo "" | |
| read -p "Would you like to upgrade these ${#patch_list[@]} packages? (y/N): " confirm | |
| if [[ ! "$confirm" =~ ^[Yy]$ ]]; then | |
| echo "Upgrade skipped." | |
| exit 0 | |
| fi | |
| # --- Upgrade with dependent check ------------------------------------------- | |
| for pkg in "${patch_list[@]}"; do | |
| echo "" | |
| echo "Checking $pkg..." | |
| # Dry run to discover everything that would actually be upgraded, | |
| # including any dependent packages brew would pull along. | |
| dry_run=$(brew upgrade --dry-run "$pkg" 2>&1) | |
| # Parse lines of the form: "name old_ver -> new_ver" | |
| patch_deps=() | |
| non_patch_deps=() | |
| while read -r dep_line; do | |
| dep_name=$(echo "$dep_line" | awk '{print $1}') | |
| dep_old=$(echo "$dep_line" | awk '{print $2}') | |
| dep_new=$(echo "$dep_line" | awk '{print $4}') # field 3 is "->" | |
| # Skip the package we're intentionally upgrading. | |
| [[ "$dep_name" == "$pkg" ]] && continue | |
| dep_old_mm=$(echo "$dep_old" | cut -d. -f1,2) | |
| dep_new_mm=$(echo "$dep_new" | cut -d. -f1,2) | |
| if [[ "$dep_old_mm" != "$dep_new_mm" ]]; then | |
| non_patch_deps+=("$dep_name ($dep_old -> $dep_new)") | |
| else | |
| patch_deps+=("$dep_name ($dep_old -> $dep_new)") | |
| fi | |
| done < <(echo "$dry_run" | grep -E '^[a-zA-Z@][^ ]* [0-9].*->') | |
| if [ ${#patch_deps[@]} -gt 0 ] || [ ${#non_patch_deps[@]} -gt 0 ]; then | |
| echo " Upgrading $pkg will also upgrade these dependent packages:" | |
| for d in "${patch_deps[@]}"; do | |
| echo " [patch] $d" | |
| done | |
| for d in "${non_patch_deps[@]}"; do | |
| echo " [NON-PATCH] $d <-- minor/major version bump" | |
| done | |
| fi | |
| if [ ${#non_patch_deps[@]} -gt 0 ]; then | |
| echo "" | |
| read -p " $pkg has non-patch dependent upgrades. Proceed anyway? (y/N): " pkg_confirm | |
| if [[ ! "$pkg_confirm" =~ ^[Yy]$ ]]; then | |
| echo " Skipping $pkg." | |
| continue | |
| fi | |
| fi | |
| echo " Upgrading $pkg..." | |
| brew upgrade "$pkg" | |
| done | |
| echo "" | |
| echo "Done." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment