From 99e6dc0b3d34e4435cb68e5e71847126a3ada8f9 Mon Sep 17 00:00:00 2001 From: Max Novak Date: Sat, 10 Oct 2026 20:34:07 +0000 Subject: [PATCH 1/5] Keep going when brew bundle fails, and run setup.sh in CI A failed cask used to stop setup.sh under set -e, skipping the shell, Ghostty, herdr, git and Docker steps that don't depend on it. Now the script carries on, lists the missing Brewfile entries at the end and exits non-zero. The Brewfile sets cask_args adopt: true, so apps installed by hand (like Chrome) are taken over instead of failing with "It seems there is already an App". CI gains a macOS job that runs setup.sh twice, checks the second run changes nothing and that an interactive zsh starts without errors. The Brewfile check now also fails on deprecated or disabled packages, which brew info accepts. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++++++++++++++++--- Brewfile | 4 ++++ README.md | 4 ++-- setup.sh | 12 ++++++++++- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66faf30..c08a746 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,45 @@ jobs: HOMEBREW_NO_AUTO_UPDATE: 1 steps: - uses: actions/checkout@v7 - - name: Every Brewfile entry still exists in Homebrew + - name: Every Brewfile entry still exists in Homebrew and isn't deprecated run: | - brew info --formula $(brew bundle list --file=Brewfile --formula) > /dev/null - brew info --cask $(brew bundle list --file=Brewfile --cask) > /dev/null + brew info --json=v2 --formula $(brew bundle list --file=Brewfile --formula) > formulae.json + brew info --json=v2 --cask $(brew bundle list --file=Brewfile --cask) > casks.json + # brew info still succeeds for deprecated and disabled packages, which are about to go away. + retiring="$(jq -r '(.formulae[], .casks[]) | select(.deprecated or .disabled) + | "\(.token // .name): \(if .disabled then "disabled" else "deprecated" end) (\(.disable_reason // .deprecation_reason // "no reason given"))"' \ + formulae.json casks.json)" + if [[ -n "$retiring" ]]; then + echo "$retiring" + exit 1 + fi + + setup: + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - name: First run + run: ./setup.sh + - name: Second run changes nothing + run: | + snapshot() { + brew list --formula --versions + brew list --cask --versions + for file in ~/.zprofile ~/.zshrc ~/.gitconfig ~/.config/ghostty/config \ + ~/.config/herdr/config.toml ~/.docker/config.json; do + echo "== $file" + cat "$file" 2>/dev/null || echo "(missing)" + done + } + snapshot > first-run.txt + ./setup.sh + snapshot > second-run.txt + diff -u first-run.txt second-run.txt + - name: zsh starts without errors + run: | + output="$(zsh -i -c exit 2>&1)" + if [[ -n "$output" ]]; then + echo "$output" + exit 1 + fi diff --git a/Brewfile b/Brewfile index 31f9db2..c9f5666 100644 --- a/Brewfile +++ b/Brewfile @@ -1,6 +1,10 @@ # Everything setup.sh installs. Run on its own with: brew bundle --file=Brewfile # Comment out anything you don't want before running. +# Take over apps you already installed by hand (like Chrome) instead of failing +# with "It seems there is already an App at ...". +cask_args adopt: true + # --- Shell & terminal --- cask "ghostty" # fast, native, GPU-accelerated terminal brew "starship" # fast, minimal prompt (replaces oh-my-zsh themes) diff --git a/README.md b/README.md index b825c33..b021ed9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Want a different selection? Edit the [`Brewfile`](Brewfile) before running `./se ### What `setup.sh` does - Installs [Homebrew](https://brew.sh/) if it's missing and adds it to your `PATH` in `~/.zprofile` -- Installs everything in the [`Brewfile`](Brewfile) with `brew bundle` +- Installs everything in the [`Brewfile`](Brewfile) with `brew bundle`. Apps you already installed yourself, like Chrome, are handed over to Homebrew rather than installed again - Adds one `source` line to the end of `~/.zshrc` that loads [`dotfiles/zshrc`](dotfiles/zshrc) - Adds a `config-file` line to `~/.config/ghostty/config` that loads [`dotfiles/ghostty`](dotfiles/ghostty), which makes **Cmd+K** clear the screen inside herdr and tmux too, and makes the left Option key work as Alt - Creates `~/.config/herdr/config.toml` from [`dotfiles/herdr.toml`](dotfiles/herdr.toml) if you don't have one, or adds the tab shortcut to the one you have @@ -41,7 +41,7 @@ Want a different selection? Edit the [`Brewfile`](Brewfile) before running `./se - Turns on commit signing with your SSH key, if you have one and haven't set up signing already - Lets the Homebrew `docker` CLI find the `compose` and `buildx` plugins -It never overwrites your existing files, and it's safe to run again: that's also how you install anything you add to the `Brewfile` later. Your own settings in `~/.zshrc` and `~/.gitconfig` still apply, and those in `~/.gitconfig` take precedence. The config is loaded from the cloned repo, so keep it where you cloned it. +If something in the `Brewfile` fails to install, the other steps still run and the failures are listed at the end. It never overwrites your existing files, and it's safe to run again: that's also how you install anything you add to the `Brewfile` later. Your own settings in `~/.zshrc` and `~/.gitconfig` still apply, and those in `~/.gitconfig` take precedence. The config is loaded from the cloned repo, so keep it where you cloned it. ## What gets installed diff --git a/setup.sh b/setup.sh index 2570a1e..724bcea 100755 --- a/setup.sh +++ b/setup.sh @@ -55,7 +55,10 @@ if ! grep -q 'brew shellenv' "$HOME/.zprofile"; then fi step "Apps and tools from the Brewfile" -brew bundle --file="$REPO_DIR/Brewfile" +# A failed install doesn't stop the setup: the steps below don't need it. +# The failures are listed at the end. +BUNDLE_FAILED="" +brew bundle --file="$REPO_DIR/Brewfile" || BUNDLE_FAILED=1 step "Shell config" ZSHRC_LINE="source \"$REPO_DIR/dotfiles/zshrc\"" @@ -151,3 +154,10 @@ if [[ -z "$(git config --global user.email || true)" ]]; then note "- Set your git identity: git config --global user.name \"Your Name\"" note " git config --global user.email \"you@example.com\"" fi + +if [[ -n "$BUNDLE_FAILED" ]]; then + step "Some Brewfile entries didn't install" + brew bundle check --file="$REPO_DIR/Brewfile" --no-upgrade --verbose 2>&1 | sed 's/^/ /' || true + note "Look for \"Error:\" in the brew bundle output above, fix it, then re-run ./setup.sh" + exit 1 +fi From 3b4f358bccbd5839ba14f96e9cfb2d3f2e8bb765 Mon Sep 17 00:00:00 2001 From: Max Novak Date: Sat, 10 Oct 2026 20:47:46 +0000 Subject: [PATCH 2/5] Give the CI zsh check a pty zsh can't turn its line editor on without a terminal, so `zsh -i -c exit` in CI printed "can't change option: zle" no matter what the config does. Run it under a pty via script(1), and strip escape sequences so the prompt itself doesn't count as output. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c08a746..271478c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,14 @@ jobs: ./setup.sh snapshot > second-run.txt diff -u first-run.txt second-run.txt - - name: zsh starts without errors + - name: An interactive zsh starts without errors run: | - output="$(zsh -i -c exit 2>&1)" + # Under a pty: zsh can't switch its line editor on without one and says + # "can't change option: zle" whatever the config. Escape sequences are + # stripped so the prompt doesn't count as output. + output="$(script -q /dev/null zsh -i -c exit 2>&1 | tr -d '\r' \ + | perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e\[[0-9;?]*[a-zA-Z]//g; s/\e[=>]//g' \ + | sed '/^[[:space:]]*$/d')" if [[ -n "$output" ]]; then echo "$output" exit 1 From e402be898478290b9a3660fdd8764657baefa230 Mon Sep 17 00:00:00 2001 From: Max Novak Date: Sat, 10 Oct 2026 20:51:18 +0000 Subject: [PATCH 3/5] Ignore the pty's own echo in the CI zsh check Under script(1) the pty echoes the EOF character when stdin closes, so the check saw "^D^H^H" and failed. Strip control characters after the escape sequences, leaving only text a human would read as a message. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 271478c..726669a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,10 +75,12 @@ jobs: - name: An interactive zsh starts without errors run: | # Under a pty: zsh can't switch its line editor on without one and says - # "can't change option: zle" whatever the config. Escape sequences are - # stripped so the prompt doesn't count as output. - output="$(script -q /dev/null zsh -i -c exit 2>&1 | tr -d '\r' \ + # "can't change option: zle" whatever the config. Escape sequences and + # control characters are stripped, so neither the prompt nor the pty's + # own echo (^D on EOF) counts as output. What's left is real messages. + output="$(script -q /dev/null zsh -i -c exit 2>&1 \ | perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e\[[0-9;?]*[a-zA-Z]//g; s/\e[=>]//g' \ + | tr -d '\000-\010\013-\037\177' \ | sed '/^[[:space:]]*$/d')" if [[ -n "$output" ]]; then echo "$output" From 5917bd560e065f9b3ad7068bf3f01d9c229094bc Mon Sep 17 00:00:00 2001 From: Max Novak Date: Sat, 10 Oct 2026 20:55:12 +0000 Subject: [PATCH 4/5] Ignore the pty's caret-notation echo too The EOF that ends the script(1) session is echoed as printable caret notation ("^D"), not as the control character, so stripping control characters left it behind. Drop lines made up of nothing but caret notation. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 726669a..62964d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,12 +76,13 @@ jobs: run: | # Under a pty: zsh can't switch its line editor on without one and says # "can't change option: zle" whatever the config. Escape sequences and - # control characters are stripped, so neither the prompt nor the pty's - # own echo (^D on EOF) counts as output. What's left is real messages. + # control characters are stripped, so the prompt doesn't count as output, + # and so are lines of nothing but caret notation (^D), which is how the + # pty echoes the EOF that ends the session. What's left is real messages. output="$(script -q /dev/null zsh -i -c exit 2>&1 \ | perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e\[[0-9;?]*[a-zA-Z]//g; s/\e[=>]//g' \ | tr -d '\000-\010\013-\037\177' \ - | sed '/^[[:space:]]*$/d')" + | sed -E '/^(\^[@-_?])+$/d; /^[[:space:]]*$/d')" if [[ -n "$output" ]]; then echo "$output" exit 1 From 1dd16f2b3d6cb8a141e6671150eb1f4e3dbbde0f Mon Sep 17 00:00:00 2001 From: Max Novak Date: Sat, 10 Oct 2026 20:58:49 +0000 Subject: [PATCH 5/5] Filter the CI zsh output with one portable perl pass The previous filter used a GNU-only character range, so BSD sed on the macOS runner rejected it ("RE error: invalid character range") and the step failed on its own regex rather than on anything zsh printed. Do the whole job in perl, which behaves the same on both platforms: strip escape sequences, control characters and the pty's caret-notation echo, and treat whatever is left as a real message. Non-ASCII text in a message survives. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62964d4..0a198ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,15 +74,18 @@ jobs: diff -u first-run.txt second-run.txt - name: An interactive zsh starts without errors run: | - # Under a pty: zsh can't switch its line editor on without one and says - # "can't change option: zle" whatever the config. Escape sequences and - # control characters are stripped, so the prompt doesn't count as output, - # and so are lines of nothing but caret notation (^D), which is how the - # pty echoes the EOF that ends the session. What's left is real messages. - output="$(script -q /dev/null zsh -i -c exit 2>&1 \ - | perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e\[[0-9;?]*[a-zA-Z]//g; s/\e[=>]//g' \ - | tr -d '\000-\010\013-\037\177' \ - | sed -E '/^(\^[@-_?])+$/d; /^[[:space:]]*$/d')" + # Under a pty, because zsh can't switch its line editor on without one + # and says "can't change option: zle" whatever the config. One perl pass + # drops everything a terminal adds by itself: escape sequences (the + # prompt), control characters, and the caret notation the pty echoes for + # the EOF that ends the session. Whatever is left is a real message. + output="$(script -q /dev/null zsh -i -c exit 2>&1 | perl -ne ' + s/\e\][^\a\e]*(?:\a|\e\\)//g; # window title + s/\e\[[0-9;?]*[a-zA-Z]//g; # colors, cursor moves + s/\e[=>]//g; # keypad mode + s/[\x00-\x08\x0b-\x1f\x7f]//g; # other control characters + next if /^(?:\^[\x3f\x40-\x5f])*\s*$/; # ^D and friends, blank lines + print')" if [[ -n "$output" ]]; then echo "$output" exit 1