From 04da0cf4535501313a4f96039397f681650ffb49 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Thu, 1 Oct 2026 15:08:24 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=A9=BA=20update=20run?= =?UTF-8?q?s=20machine-local=20update.check=20commands=20after=20the=20upg?= =?UTF-8?q?rades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brew upgrade installed a softnet release that serves DHCP itself, so tart's default resolver lost every CI VM on the work Mac runner, and macOS CI was down for about two hours before anyone connected it to the upgrade. update() is what ran that upgrade, and the moment right after it is when a check would have pointed straight at it. So update() runs every `update.check` command from git config after the no-sudo upgrades. The list is machine-local and overlay-supplied: what a given machine needs checked (here, a CI runner's own doctor) is identity the public repo must not carry. A failing check never stops the update, and failures are repeated in a red banner at the very end, past the softwareupdate and gem output that would otherwise bury them. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 10 ++++++++++ dot_functions | 17 ++++++++++++++++- tests/update.bats | 22 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e1f3b37b1..9b52fc9cfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,16 @@ nearest `.tool-versions` above the cwd, so a run typed inside a project would update that project's toolchain instead of the global one pinned in `~/.tool-versions`. `tests/update.bats` pins this. +**`update()` runs machine-local checks after the upgrades.** Every +`update.check` command in git config (repeatable, overlay-supplied — the +public repo names none) runs right after the no-sudo upgrades, while an +upgrade is still the obvious suspect for whatever it breaks: a service whose +networking a formula update changed, a cask a daemon reads secrets through. +A failing check never stops the update; each failure is repeated in a red +banner at the very end, past the softwareupdate and gem output that would +otherwise bury it. A value containing `;` must be quoted in the config file +(git reads a bare `;` as a comment) — `git config --add` does that for you. + **A half-installed cask is invisible to all of this.** A `.pkg` cask whose installer never ran still leaves a Caskroom entry, so `brew bundle` counts it as satisfied and never retries while the app is missing from `/Applications`. diff --git a/dot_functions b/dot_functions index 0f2f5db332..ce22d7d81a 100644 --- a/dot_functions +++ b/dot_functions @@ -2915,9 +2915,17 @@ function doctor() { # Get macOS Software Updates, and update installed Ruby gems, Homebrew, npm, uv, # and their installed packages. Runs from $HOME whatever directory it was typed # in, and puts the shell back afterwards. +# +# After the no-sudo upgrades it runs every `update.check` command from git +# config (repeatable, machine-local, overlay-supplied — this repo names none): +# whatever on this machine an upgrade can silently break gets to say so while +# the upgrade is the obvious suspect. A failing check never stops the update; +# failures are repeated in a red banner at the very end, past the +# softwareupdate and gem output that would otherwise bury them. function update() { setopt localoptions nomonitor nonotify; - local here=${PWD:-} keepalive_pid=; + local here=${PWD:-} keepalive_pid= check; + local -a failed; # Two reasons this never runs where it was typed. A directory that no # longer exists (a removed worktree is the usual way) is inherited by @@ -2964,6 +2972,10 @@ function update() { command -v npm > /dev/null 2>&1 && npm install npm -g; command -v npm > /dev/null 2>&1 && npm update -g; command -v uv > /dev/null 2>&1 && uv tool upgrade --all; + for check in ${(f)"$(command git config --get-all update.check 2>/dev/null)"}; do + print -r -- "== update.check: $check"; + sh -c "$check" || failed+=("$check"); + done; # One password prompt for the rest; keep the sudo timestamp alive # until the updates finish (same pattern as ~/.macos). Disowned (&!) @@ -2989,6 +3001,9 @@ function update() { # and does not fire on an interrupt at all, so a ^C'd update left the # keepalive polling sudo for the life of the shell. [[ -n $keepalive_pid ]] && kill "$keepalive_pid" 2>/dev/null; + for check in $failed; do + print -Pru2 -- "%F{red}%B✘ update.check failed:%b%f ${check//\%/%%}"; + done; cd -q -- "$here" 2>/dev/null || print -ru2 -- "update: $here vanished during the update; shell left in $PWD"; }; } diff --git a/tests/update.bats b/tests/update.bats index 4bcc1522a8..31e39b20b8 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -101,3 +101,25 @@ run_update() { ! grep -qF "sudo -n true" calls.log || { echo "keepalive started after sudo -v failed"; false; } ! grep -qF "sudo gem" calls.log || { echo "gem ran after sudo -v failed"; false; } } + +@test "update.check commands run after the upgrades; a failure is repeated at the end and never stops the update" { + export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" GIT_CONFIG_NOSYSTEM=1 + git config --file "$GIT_CONFIG_GLOBAL" --add update.check 'echo first-check-ran' + git config --file "$GIT_CONFIG_GLOBAL" --add update.check 'echo 50% broken >&2; exit 3' + start="$BATS_TEST_TMPDIR/start"; mkdir -p "$start" + run_update "cd -q -- '$start'" + grep -q "^RC=0 PWD=$(cd "$start" && pwd -P)\$" out + grep -qF "first-check-ran" out + grep -qF "== update.check: echo 50% broken >&2; exit 3" out + # The banner comes last, after gem, and names the failing command verbatim. + [ "$(tail -n 1 err | sed $'s/\e\\[[0-9;]*m//g')" = "✘ update.check failed: echo 50% broken >&2; exit 3" ] + ! grep -q "update.check failed: echo first" err + grep -qF "sudo gem cleanup" calls.log +} + +@test "with no update.check configured, nothing extra is printed" { + export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" GIT_CONFIG_NOSYSTEM=1 + : > "$GIT_CONFIG_GLOBAL" + run_update ":" + ! grep -q "update.check" out err +}