A Homebrew wrapper that delays homebrew/core, homebrew/cask, and
third-party tap updates for a soak window. This gives security researchers
time to discover and yank a compromised package before you install it.
Every other brew subcommand passes through unchanged. Packages and taps you
list under NO_SOAK skip soaking and end in the same state as brew upgrade.
There is no soak bypass flag: run brew directly if you need HEAD now.
brew install ericfitz/tap/brewsoakInstalls a prebuilt, code-signed and notarized universal (Apple Silicon +
Intel) binary from the GitHub release.
Upgrade later with brew upgrade brewsoak. Add ericfitz/tap to NO_SOAK
(below) so brewsoak upgrade keeps brewsoak itself current without a soak
delay.
From crates.io:
cargo install brewsoakFrom this GitHub repo:
cargo install --git https://github.com/ericfitz/brewsoakrFrom a local checkout:
git clone git@github.com:ericfitz/brewsoakr.git
cd brewsoakr
cargo install --path .Or run target/debug/brewsoak after cargo build.
Soak duration is hours, integer ≥ 1, default 24.
| Source | Name |
|---|---|
| CLI | --soak-hours N |
| Environment | BREWSOAK_SOAK_HOURS |
| File | ~/.config/brewsoak/config.toml key SOAK_HOURS |
Precedence: CLI > environment > file > 24.
--soak-hours is persisted only when used with a soaked command
(update, upgrade, install, reinstall, outdated, info).
N == 24 removes the SOAK_HOURS key; the file is deleted only if nothing
else remains.
# Top-level keys go first. TOML assigns any key after a [[TAP]] header to
# that table.
SOAK_HOURS = 48 # default for every package, core and cask included
NO_SOAK = ["ericfitz/tap", "wget", "hashicorp/tap/terraform"]
[[TAP]]
name = "hashicorp/tap"
soak_hours = 72 # optional; applies to every package in this tap
[[TAP]]
name = "cyclonedx/cyclonedx" # no soak_hours: uses SOAK_HOURSEffective soak hours for a package: a NO_SOAK match means no soak at all;
else the origin tap's [[TAP]] soak_hours; else SOAK_HOURS. [[TAP]] and
NO_SOAK have no flag and no environment variable: edit the file, or use
brewsoak settings below. homebrew/core and homebrew/cask are valid tap
names in both.
NO_SOAK entries:
| Entry | Matches |
|---|---|
wget |
that formula or cask from any tap |
ericfitz/tap |
every package in that tap |
hashicorp/tap/terraform |
that one package from that tap |
Matching is case-insensitive. Invalid entries are skipped and reported under
-v; brewsoak settings repair removes them. A NO_SOAK key inside a
[[TAP]] table is ignored, with a warning on stderr every run.
--soak-hours N edits only the SOAK_HOURS key; [[TAP]], NO_SOAK, and
comments are kept, and the previous file is backed up as described under
brewsoak settings. A config file that is not valid TOML is left alone with
a warning.
brewsoak settings [show]
brewsoak settings soak-hours N|--clear
brewsoak settings no-soak add|remove TOKEN...
brewsoak settings tap-hours USER/REPO N|--clear
brewsoak settings repair
show prints the effective soak hours and their source (BREWSOAK_SOAK_HOURS,
the file, or the default 24), NO_SOAK as written, every [[TAP]] with its
effective hours, and every parse note and warning. It never writes.
Edits keep comments, key order, and unknown keys. New top-level keys go above
the first [[TAP]]; a new [[TAP]] is appended after the last one.
soak-hours 24 removes SOAK_HOURS (24 is the default). no-soak tokens
are validated like the file's entries, compared case-insensitively, and
written lowercased; adding a present token or removing an absent one is
reported and is not an error. no-soak add and remove refuse a NO_SOAK
list that holds a non-string entry; repair fixes it. tap-hours USER/REPO --clear removes the
entry when only name would remain. When nothing is left in the file, the
file is removed.
repair makes the file what brewsoak reads, so show and -v stop noting
problems. A lone NO_SOAK = "wget" becomes ["wget"]. Elements of NO_SOAK
that are not valid entries (or not strings) are removed. A NO_SOAK written
inside a [[TAP]] table has its valid entries appended to the top-level list
(created above the first [[TAP]] if missing), and the misplaced key is
deleted. [[TAP]] entries with a missing or bad name are removed, as is a
soak_hours that is not an integer >= 1 (the entry goes too if only name
is left) and every earlier duplicate (the last one wins, as when reading).
Valid entries keep their text, order, comments, and layout. Every removal is
printed. A NO_SOAK that is neither an array nor a valid entry string (an
invalid string, a number, a table), or a TAP that is not an array of
tables, is refused and nothing is written. With nothing to fix it prints
nothing to repair and writes nothing.
Every write goes to a temp file first, the previous config.toml is kept as
config.toml.<YYYYMMDDTHHMMSSZ>.bak next to it (the 2 newest backups are
kept), and the temp file is renamed into place, so another brewsoak never sees a
missing or partial file. An unchanged edit writes nothing. A file that is not
valid TOML is refused, with the parse error, and left untouched.
settings soak-hours warns on stderr when BREWSOAK_SOAK_HOURS is set,
because the environment overrides the file; the edit is still written.
settings never runs brew or git and does not check whether a tap is
installed. --soak-hours is not accepted with settings.
Every installed tap with an HTTPS remote is soaked like core and cask, from a
blobless clone under brewsoak's cache. brew's own tap checkouts are never
touched. A tap with no remote, or a non-HTTPS remote, is not soakable:
brewsoak notes its packages and leaves them to brew, unless they are in
NO_SOAK.
No-soak packages are installed by brew itself from their real tap after all
soaked work: one brew update, then one brew upgrade (or install /
reinstall) naming every no-soak package. brew upgrade also upgrades a
no-soak package's outdated dependencies to brew's latest, so no-soak extends
to its dependencies. Soaked dependents of a no-soak package are not upgraded
by that step.
| Command | What it does |
|---|---|
update |
Refresh cutoff/HEAD snapshots. Runs brew update once when any installed package is no-soak. |
outdated |
What upgrade would change, plus held / ahead / pinned. |
upgrade |
Install soaked cutoff artifacts for eligible installed packages. |
install |
Install the soaked cutoff artifact if eligible. |
reinstall |
True repair via brew when installed == HEAD; otherwise cutoff. |
info |
Installed / cutoff / HEAD and the action brewsoak would take. |
--version / -V |
Print brewsoak <version>. |
--help / -h |
brewsoak help. help install is soak-aware; help services is brew help. |
Other flags (--formula, --cask, --debug, …) are forwarded to brew.
upgrade and outdated honor brew's --greedy (-g),
--greedy-auto-updates and --greedy-latest themselves (see below); the
no-soak brew upgrade also receives them.
-v / --verbose prints the soak window, cutoff SHAs and times, and a
line for every package evaluated (what happened and why).
--raw turns off output summarizing and forwards brew's output byte for
byte.
brewsoak summarizes brew's install output: one line announcing each package
it changes, then a short line per download and install step. Bottle manifests,
plan previews, cleanup, ==> markers, emoji, and already installed and up-to-date notices are dropped from the terminal.
Nothing is lost. Every byte brew writes is appended to a per-run log under
$TMPDIR, and the path is printed at the end of the run.
A run prints headed sections (Fetching Homebrew history, Fetching tap history, Upgrading soaked packages, Updating Homebrew, Upgrading no-soak packages (brew decides)) with their lines indented beneath. A section with
nothing in it is omitted, except the soaked section of a bare upgrade,
which always prints with (N of M). A dry run reads Would upgrade ….
Holds, skips, and deprecation warnings are collected into a Notes section
after the Summary table, grouped by kind with one package per line, so they
are not buried in the install scroll, and a Caveats section follows with the
caveats worth reading — brew's "shell completions
have been installed to ..." notices are dropped, anything telling you to run
something is kept.
Deprecations more than a year away are not reported. A package that is deprecated or disabled upstream is never installed or upgraded, but one already at its cutoff (or ahead of soak) is left alone with a warning, not held.
brewsoak update
brewsoak outdated
brewsoak upgrade
brewsoak info wget
brewsoak upgrade -vA typical upgrade with nothing to do (a dry run here, taken from a real
run and trimmed) prints:
Fetching Homebrew history
Homebrew/homebrew-core
Homebrew/homebrew-cask
Fetching tap history
anthropics/tap
hashicorp/tap
Would upgrade soaked packages (0 of 177)
Updating Homebrew
Already up-to-date.
Would upgrade no-soak packages (brew decides)
ericfitz/tap/brewsoak 1.5.2
Already up-to-date.
ericfitz/tap/sqdist 0.6.0
Already up-to-date.
Summary
would upgrade 0
already soaked 162
soaking 10
held 0
ahead 1
pinned 0
skipped 0
no-soak 2 (0 would upgrade, 2 already up-to-date)
auto-updates 2
installed 0B
freed 0B
Notes
Still soaking (left at the cutoff):
awscli 2.38.3
cats 15.0.3
Ahead of soak (installed artifact left unchanged):
uv
Full brew log: /var/folders/…/T/brewsoak-12345.log
A package already at its cutoff while a newer version is still inside the
soak window is counted as soaking N instead of already soaked. A bare
upgrade lists them under Still soaking (left at the cutoff) in Notes. A
named one (brewsoak upgrade cats) says which version is soaking and for how
long. outdated lists them under ==> Soaking, and info shows
up to date (soaked); 15.0.0 soaking. brew's own "Outdated Formulae" list from
brew update is dropped, because it counts versions that are still soaking,
and so are its New and Deleted package lists. All of them stay in --raw
output and the brew log.
Casks that update themselves (auto_updates true) are left to the app on a
bare upgrade, as brew upgrade leaves them without --greedy; they are
counted as auto-updates N and outdated lists them under their own
heading. Name one (brewsoak upgrade alt-tab) to upgrade it anyway, or pass
--greedy (or --greedy-auto-updates) to upgrade all of them to their
soaked cutoff like any other cask. A version :latest cask is different:
its contents change without the cask changing, so brewsoak reinstalls one
under --greedy / --greedy-latest only when its cutoff definition differs
from the installed one, and otherwise leaves it with a note that the
contents of a :latest cask cannot be soaked (brew would reinstall it every
time). Since Homebrew 4 an installed cask carries only a version (the
Caskroom usually holds no cask source), so a cask is compared to its cutoff
by version alone.
An upgrade with work to do looks like:
Upgrading soaked packages (26 of 153)
[1/26] upgrading aws-c-auth 0.10.5 -> 1.0.0
downloading aws-c-common 1.0.0
installing aws-c-common 1.0.0
installed to /opt/homebrew/Cellar/aws-c-common/1.0.0 (109 files, 1MB)
...
Updating Homebrew
Already up-to-date.
Upgrading no-soak packages (brew decides)
upgrading ericfitz/tap/agentbus 1.15.0 -> 1.16.0
installing agentbus 1.16.0
ericfitz/tap/brewsoak 1.5.2
Already up-to-date.
Summary
upgraded 26
already soaked 124
soaking 0
held 0
ahead 0
pinned 0
skipped 1
no-soak 2 (1 upgraded, 1 already up-to-date)
auto-updates 0
installed 989.3MB
freed 1.2GB
Notes
bash: unparseable identity; skipping
Caveats
git-lfs:
Update your git config to finish installation:
$ git lfs install
Full brew log: /var/folders/…/T/brewsoak-53417.log
Refusals tell you to run brew upgrade <name> (or install/reinstall) to
bypass brewsoak.
Maintainer notes. release/ holds the scripts that build a universal binary,
sign and notarize it, attach it to a GitHub release, and render the Homebrew
formula for ericfitz/homebrew-tap. See release/README.md
for the step-by-step; the crates.io release is a separate cargo publish.