uv bootstrap, version gate, and per-machine upgrade dispatch¶
How
uv(Astral's Python package manager) is installed on every host this repo touches, why thepython_uv_toolsansible role enforces a minimum version, and how the auto-upgrade picks the right channel —brew upgrade uvvsuv self update— without the user having to think about it.
Why this page exists¶
Different machines in the same fleet end up with uv installed via different channels:
Other Mac (manual brew)
❯ uv --version
uv 0.10.2 (Homebrew 2026-02-10)
❯ which uv
/opt/homebrew/bin/uv
Ubuntu (curl install via run_once_before_00_bootstrap)
❯ uv --version
uv 0.7.18
❯ which uv
/home/daviddwlee84/.local/bin/uv
This Mac (curl install, never refreshed)
❯ uv --version
uv 0.7.4 (6fbcd09b5 2025-05-15)
❯ which uv
/Users/zhouhanru/.local/bin/uv
uv self update works on the curl-installed boxes and silently no-ops on Homebrew uv (it refuses with self-update is disabled for this build). brew upgrade uv works only when uv is in fact a brew formula. Get this wrong and the host stays months behind, then a future chezmoi apply fails because some role uses a flag from a newer uv.
This page documents the contract that fixes the mismatch.
Bootstrap path¶
There is exactly one installer in this repo:
It runs in two places, both gated on command -v uv:
| File | When it runs |
|---|---|
bootstrap.sh lines 52–61 |
First entry point on a fresh box (one-shot curl … \| bash) |
run_once_before_00_bootstrap.sh.tmpl lines 206–213 |
First chezmoi apply on the box |
Result: every host the repo touches has uv at ~/.local/bin/uv unless the user already had brew install uv before the bootstrap ran, in which case the command -v uv guard sees the brew binary and skips the curl install.
The repo never proactively brew installs uv. There's no brew "uv" line in any Brewfile (grep -rn '"uv"' dot_config/homebrew/). No mise plugin, no asdf, no apt package. Curl-or-brew is the entire matrix.
Why a minimum version is enforced¶
dot_ansible/roles/python_uv_tools/defaults/main.yml uses the with_executables_from: key on the jupyterlab entry to expose jupyter and jupyter-notebook shims from the jupyter-core and notebook packages. The full meta-CLI dispatch model (why jupyter is just sugar for jupyter-<sub>, what each of the 8 exposed binaries does, and a "command not found" troubleshooting recipe) lives in docs/tools/notebooks.md. The relevant role excerpt:
- name: jupyterlab
binary: jupyter-lab
with:
- marimo[sandbox]
- marimo-jupyter-extension
- ipykernel
- ipywidgets
with_executables_from:
- notebook # exposes jupyter-notebook (classic UI, Notebook 7)
- jupyter-core # exposes the `jupyter` meta-CLI dispatcher
That maps to uv tool install jupyterlab --with ... --with-executables-from notebook --with-executables-from jupyter-core. The --with-executables-from flag is uv 0.8.5+ (PR astral-sh/uv#14014, released ~2025-08). On older uv, the install bombs with:
— and then ansible retries 3× per tool because of the retries: 3 block. Fail-fast at the start of the role is much cheaper than waiting for the loop to fail twelve times.
Auto-upgrade dispatch¶
When the role finds uv below min_uv_version (currently 0.8.5, set via set_fact at the top of tasks/main.yml), it runs the upgrade automatically by detecting the install style:
# pseudo (the real one is in tasks/main.yml as an `ansible.builtin.shell`)
case "$(command -v uv)" in
*/homebrew/*|*/Cellar/*|*/linuxbrew/*) echo brew ;;
/usr/local/bin/uv)
# Intel-mac brew shares /usr/local/bin with the curl installer; ask brew
brew list --formula uv >/dev/null 2>&1 && echo brew || echo curl
;;
$HOME/.local/bin/uv|$HOME/.cargo/bin/uv) echo curl ;;
*) brew list --formula uv >/dev/null 2>&1 && echo brew || echo curl ;;
esac
then:
| Detected style | Upgrade command (ansible) | Upgrade command (scripts/upgrade_tools.sh) |
|---|---|---|
brew |
community.general.homebrew name=uv state=latest |
brew upgrade uv |
curl |
uv self update |
uv self update |
After the upgrade fires (or doesn't, when uv was already current), the role re-probes the version one more time and fails with a clear message if uv is still below the minimum. That last fail catches three edge cases:
- The detect-style heuristic guessed wrong and the chosen command silently no-opped.
- The brew formula simply hasn't bumped to a recent enough version yet.
- The host is offline / behind a proxy and the upgrade attempt failed.
Per-machine truth table¶
For the three example machines at the top:
| Machine | Initial uv | Style detected | Upgrade fired | Result |
|---|---|---|---|---|
| Other Mac | 0.10.2 (Homebrew 2026-02-10) |
brew |
none — already ≥ 0.8.5 | continues to install loop |
| Ubuntu | 0.7.18 |
curl |
uv self update → e.g. 0.11.13 |
continues to install loop |
| This Mac | 0.7.4 (... 2025-05-15) |
curl |
uv self update → e.g. 0.11.13 |
continues to install loop |
All three converge. No manual intervention needed beyond chezmoi apply.
A hypothetical fourth machine — Ubuntu desktop with brew install uv from Linuxbrew, currently at uv 0.7.x — would be detected as brew, get community.general.homebrew name=uv state=latest, and converge the same way.
A hypothetical fifth — CentOS 7 air-gapped — would fail at the post-upgrade re-probe, because uv self update can't reach astral.sh and there's no brew. The fail message points the user at the manual fix table.
Manual fallback by install style¶
macOS Homebrew → brew upgrade uv
Linuxbrew → brew upgrade uv
curl/standalone → uv self update (or: just upgrade-tools uv)
unknown / other → reinstall via https://docs.astral.sh/uv/getting-started/installation/
just upgrade-tools uv (calling cat_uv() in scripts/upgrade_tools.sh) does the same dispatch; it's what to run when you want to refresh uv outside of the chezmoi-apply path.
Why we don't unify on brew install uv for macOS¶
Tempting — it would shrink the upgrade matrix to "always brew upgrade uv" — but rejected because:
- Bootstrap dependency chain. A fresh Mac would need Homebrew installed before the curl installer runs, and Homebrew install + first
brew updateis much slower thancurl … | sh. The current curl path letsuv tool install ansible-coreget going within seconds. - Linux fleet still needs the curl path. CentOS 7 / Linuxbrew-less Ubuntu / Docker images all rely on the standalone installer. We'd end up with two paths regardless.
- Brew formula lag. Astral ships standalone uv first; the homebrew-core formula bumps a few days to a week later. The
--with-executables-fromflag this repo depends on actually shipped on standalone uv 0.8.5 several days before the brew formula caught up.
So we keep both styles supported, dispatch at upgrade time, and document the matrix here.
When to bump min_uv_version¶
The set_fact: min_uv_version: "0.8.5" lives at the top of dot_ansible/roles/python_uv_tools/tasks/main.yml. Bump it whenever:
- A new entry in
defaults/main.ymluses auv tool installflag added in a newer release. Check the uv changelog for the version it shipped in. - An existing tool's transitive dep starts requiring a uv resolver feature (rare, usually surfaces as a confusing resolution error).
When you bump it, also:
- Update the comment in
tasks/main.ymlexplaining which flag the bump is for. - Update the comment in
defaults/main.yml's top header wherewith_executables_from:is documented. - Update the table on this page.
- Re-test on the oldest-uv machine in the fleet (or temporarily downgrade with
uv self install --version <old>to simulate).
Cross-references¶
AGENTS.md§ Install vs upgrade is split on purpose — the wider invariant this dispatch fits inside.docs/this_repo/upgrades.md— thecat_uv()row in the category matrix; mirrors this page's dispatch.docs/this_repo/bootstrap.md— coversbootstrap.sh+run_once_before_00_bootstrap.sh.tmplmore broadly.pitfalls/uv-self-update-homebrew-noop.md— the trap that motivated the dispatch.pitfalls/ansible-when-regex-replace-backslash-strip.md— companion trap from the same debugging session: the version-comparisonwhen:clause had its own bug (backslashes silently stripped) before this page existed.scripts/upgrade_tools.sh— thecat_uv()implementation.dot_ansible/roles/python_uv_tools/tasks/main.yml— the role that enforces the version gate.