This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 285978e2d7b Breeze: shim fallback outside worktrees + publish-docs 
workflow ref (#68192)
285978e2d7b is described below

commit 285978e2d7b10dfa1605af7c201d0b23aaf8b034
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Jun 8 10:27:21 2026 +0200

    Breeze: shim fallback outside worktrees + publish-docs workflow ref (#68192)
    
    * Breeze: shim fallback outside worktrees + publish-docs workflow ref
    
    - shim falls back to its install-dir dev/breeze when run outside a worktree 
(e.g. the asf-dist SVN tree during a provider release)
    - publish-docs --workflow-branch now defaults to --ref so the dispatched 
workflow version matches the tag being built
    
    * fixup! Breeze: shim fallback outside worktrees + publish-docs workflow ref
---
 dev/breeze/README.md                               |  41 +++-
 ...017-use-uvx-to-run-breeze-from-local-sources.md |  56 ++++--
 .../images/output_workflow-run_publish-docs.svg    |  58 +++---
 .../images/output_workflow-run_publish-docs.txt    |   2 +-
 .../airflow_breeze/commands/workflow_commands.py   |  14 +-
 dev/breeze/src/airflow_breeze/utils/path_utils.py  | 152 ++++++++++++++-
 dev/breeze/tests/test_shim_version_check.py        | 212 +++++++++++++++++++++
 scripts/tools/setup_breeze                         |  45 ++++-
 8 files changed, 521 insertions(+), 59 deletions(-)

diff --git a/dev/breeze/README.md b/dev/breeze/README.md
index d0e7244a3ad..f7cc35c1d59 100644
--- a/dev/breeze/README.md
+++ b/dev/breeze/README.md
@@ -46,6 +46,15 @@ worktree's sources. Because the shim is a real file on 
`PATH`, subprocesses (pre
 hooks, CI scripts, dev tools) see it just like a `uv tool`-installed binary. 
See
 [ADR 0017](doc/adr/0017-use-uvx-to-run-breeze-from-local-sources.md) for the 
rationale.
 
+When invoked from outside any Airflow worktree — for example from an SVN 
release checkout
+(`asf-dist`) during a provider release — the shim falls back to, in order: the 
worktree
+pointed at by `$AIRFLOW_REPO_ROOT` (which the release docs export to the repo 
root, so breeze
+resolves the same way across every release process), then the `dev/breeze` of 
the worktree it
+was installed from (baked in at install time). This keeps release commands 
such as
+`breeze release-management clean-old-provider-artifacts --directory 
<asf-dist>` working
+from the SVN tree. The fallbacks never override a real worktree, so 
per-worktree isolation is
+preserved wherever it matters.
+
 The `scripts/tools/setup_breeze` script installs the shim for you. If you 
previously
 installed breeze globally via `uv tool install -e ./dev/breeze` or `pipx 
install -e ./dev/breeze`,
 remove that install first — both write to `~/.local/bin/breeze` and would 
conflict:
@@ -60,25 +69,37 @@ To install the shim manually, write this file to 
`~/.local/bin/breeze` and `chmo
 #!/usr/bin/env bash
 # Apache Airflow breeze shim — managed by scripts/tools/setup_breeze (ADR 
0017).
 set -e
-repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
-    echo "breeze: not inside a git repository — cd into an Airflow worktree 
first" >&2
-    exit 1
-}
-if [ ! -d "${repo_root}/dev/breeze" ]; then
-    echo "breeze: ${repo_root} is not an Airflow worktree (no dev/breeze)" >&2
+# Install-time fallback: the Airflow sources 'scripts/tools/setup_breeze' was 
run
+# from. Used only when the current directory is not an Airflow worktree.
+fallback_root="/abs/path/to/airflow"   # baked in by setup_breeze (= the 
worktree it ran from)
+repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || repo_root=""
+if [ -n "${repo_root}" ] && [ -d "${repo_root}/dev/breeze" ]; then
+    breeze_root="${repo_root}"
+elif [ -n "${AIRFLOW_REPO_ROOT:-}" ] && [ -d "${AIRFLOW_REPO_ROOT}/dev/breeze" 
]; then
+    breeze_root="${AIRFLOW_REPO_ROOT}"
+elif [ -d "${fallback_root}/dev/breeze" ]; then
+    breeze_root="${fallback_root}"
+else
+    echo "breeze: not inside an Airflow worktree, AIRFLOW_REPO_ROOT is unset 
or not an Airflow worktree, and the install-time fallback 
'${fallback_root}/dev/breeze' is missing — re-run scripts/tools/setup_breeze" 
>&2
     exit 1
 fi
-exec env AIRFLOW_ROOT_PATH="${repo_root}" SKIP_BREEZE_SELF_UPGRADE_CHECK=1 \
-    uvx --from "${repo_root}/dev/breeze" --quiet breeze "$@"
+exec env AIRFLOW_ROOT_PATH="${breeze_root}" SKIP_BREEZE_SELF_UPGRADE_CHECK=1 \
+    uvx --from "${breeze_root}/dev/breeze" --quiet breeze "$@"
 ```
 
-Then `breeze` invoked from any Airflow checkout uses that checkout's source. 
The first call in
-a fresh worktree pays a one-time `uvx` resolve/install; subsequent calls hit 
the cache.
+Then `breeze` invoked from any Airflow checkout uses that checkout's source, 
and from
+anywhere else it uses `$AIRFLOW_REPO_ROOT` or the baked-in fallback. The first 
call in a
+fresh worktree pays a one-time `uvx` resolve/install; subsequent calls hit the 
cache.
 
 The legacy global-install path (`uv tool install -e ./dev/breeze --force` or
 `pipx install -e ./dev/breeze --force`) still works for users who explicitly 
want a single
 shared install, but it is no longer the recommended approach.
 
+The shim carries a `# breeze-shim-version: N` marker. On startup breeze 
compares it with the
+version the current sources would install and, if your installed shim is older 
(or you are still
+on a legacy global install), prints a warning telling you to re-run 
`scripts/tools/setup_breeze`
+(after uninstalling the global install, if any).
+
 You can read more about Breeze in the 
[documentation](https://github.com/apache/airflow/blob/main/dev/breeze/doc/README.rst)
 
 This README file contains automatically generated hash of the `pyproject.toml` 
files that were
diff --git 
a/dev/breeze/doc/adr/0017-use-uvx-to-run-breeze-from-local-sources.md 
b/dev/breeze/doc/adr/0017-use-uvx-to-run-breeze-from-local-sources.md
index de24c41da05..017fd6d1d18 100644
--- a/dev/breeze/doc/adr/0017-use-uvx-to-run-breeze-from-local-sources.md
+++ b/dev/breeze/doc/adr/0017-use-uvx-to-run-breeze-from-local-sources.md
@@ -90,17 +90,32 @@ The recommended way to run breeze is via a small **shim 
script** at
 # Runs breeze from the dev/breeze folder of the current git worktree via 'uvx',
 # so each worktree (e.g. parallel agentic runs) gets its own 
ephemerally-installed
 # breeze tied to that worktree's source.
+#
+# Resolution order for the Airflow sources breeze runs from:
+#   1. the current git worktree (per-worktree isolation — see above);
+#   2. $AIRFLOW_REPO_ROOT, if exported and pointing at an Airflow worktree — 
the
+#      release docs export this, so breeze resolves the same way across every
+#      release process regardless of where the shim was installed from;
+#   3. the install-time fallback baked in below (the worktree setup_breeze ran 
from).
+# Steps 2 and 3 apply only when the current directory is not an Airflow 
worktree,
+# so the fallbacks never override a real worktree and isolation is preserved.
 set -e
-repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
-    echo "breeze: not inside a git repository — cd into an Airflow worktree 
first" >&2
-    exit 1
-}
-if [ ! -d "${repo_root}/dev/breeze" ]; then
-    echo "breeze: ${repo_root} is not an Airflow worktree (no dev/breeze)" >&2
+# Install-time fallback: the Airflow sources 'scripts/tools/setup_breeze' was 
run
+# from. Used only when the current directory is not an Airflow worktree.
+fallback_root="/abs/path/to/airflow"   # baked in by setup_breeze (= 
AIRFLOW_SOURCES)
+repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || repo_root=""
+if [ -n "${repo_root}" ] && [ -d "${repo_root}/dev/breeze" ]; then
+    breeze_root="${repo_root}"
+elif [ -n "${AIRFLOW_REPO_ROOT:-}" ] && [ -d "${AIRFLOW_REPO_ROOT}/dev/breeze" 
]; then
+    breeze_root="${AIRFLOW_REPO_ROOT}"
+elif [ -d "${fallback_root}/dev/breeze" ]; then
+    breeze_root="${fallback_root}"
+else
+    echo "breeze: not inside an Airflow worktree, AIRFLOW_REPO_ROOT is unset 
or not an Airflow worktree, and the install-time fallback 
'${fallback_root}/dev/breeze' is missing — re-run scripts/tools/setup_breeze" 
>&2
     exit 1
 fi
-exec env AIRFLOW_ROOT_PATH="${repo_root}" SKIP_BREEZE_SELF_UPGRADE_CHECK=1 \
-    uvx --from "${repo_root}/dev/breeze" --quiet breeze "$@"
+exec env AIRFLOW_ROOT_PATH="${breeze_root}" SKIP_BREEZE_SELF_UPGRADE_CHECK=1 \
+    uvx --from "${breeze_root}/dev/breeze" --quiet breeze "$@"
 ```
 
 ``scripts/tools/setup_breeze`` writes this file (replacing any previous
@@ -150,6 +165,13 @@ behaviour, but they are no longer the recommended path.
 * **Subprocess-safe.** The shim is a real binary on ``PATH``, so anything that
   shells out to ``breeze`` — pre-commit hooks, CI helpers, dev scripts —
   resolves it exactly like a ``uv tool`` install did.
+* **Self-detecting staleness.** The shim carries a ``# breeze-shim-version: N``
+  marker that ``setup_breeze`` bumps whenever the shim body changes. On startup
+  breeze compares the installed shim's version against the version the current
+  sources would install and warns the user to re-run ``setup_breeze`` if the
+  installed shim is older (or predates versioning). The same startup check also
+  detects a leftover legacy global ``uv tool`` / ``pipx`` install and nudges 
the
+  user to migrate to the shim.
 
 **Costs**
 
@@ -160,11 +182,19 @@ behaviour, but they are no longer the recommended path.
   runs ``git rev-parse`` and ``uvx`` for every invocation. Negligible at the
   command line, but noticeable inside tight loops or shell completion that
   re-invokes ``breeze`` many times.
-* **Requires a git checkout.** ``breeze`` invoked outside a git tree errors
-  out with a clear message rather than running. This matches actual usage —
-  breeze is meaningless outside an Airflow source tree — but is a behavioural
-  change from the global install, which would silently run against whatever
-  tree it was last installed from.
+* **Resolution is current-worktree-first, with two fallbacks.** ``breeze``
+  invoked from inside an Airflow worktree runs that worktree's breeze. Invoked
+  from anywhere else (a non-Airflow git tree, or no git tree at all — e.g. an
+  ``asf-dist`` SVN release checkout), it falls back to, in order: the worktree
+  pointed at by ``$AIRFLOW_REPO_ROOT`` (which the release docs export to the
+  repo root, so breeze resolves the same way across every release process), 
then
+  the ``dev/breeze`` of the worktree ``setup_breeze`` was last run from, baked
+  into the shim at install time. This keeps release commands such as
+  ``breeze release-management clean-old-provider-artifacts --directory 
<asf-dist>``
+  working from the SVN tree. Only if the current worktree, 
``$AIRFLOW_REPO_ROOT``,
+  and the baked-in fallback are all missing ``dev/breeze`` does the shim error
+  out with a clear message. The fallbacks never override a real worktree, so
+  per-worktree isolation is preserved wherever it matters.
 * **One-time migration.** Users who previously installed breeze with
   ``uv tool install`` need to ``uv tool uninstall apache-airflow-breeze``
   before installing the shim, otherwise both write to ``~/.local/bin/breeze``
diff --git a/dev/breeze/doc/images/output_workflow-run_publish-docs.svg 
b/dev/breeze/doc/images/output_workflow-run_publish-docs.svg
index 6a8e55584df..48033b138d1 100644
--- a/dev/breeze/doc/images/output_workflow-run_publish-docs.svg
+++ b/dev/breeze/doc/images/output_workflow-run_publish-docs.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 1148.0" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 1221.1999999999998" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -45,7 +45,7 @@
 
     <defs>
     <clipPath id="breeze-workflow-run-publish-docs-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="1097.0" />
+      <rect x="0" y="0" width="1463.0" height="1170.1999999999998" />
     </clipPath>
     <clipPath id="breeze-workflow-run-publish-docs-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -179,9 +179,18 @@
 <clipPath id="breeze-workflow-run-publish-docs-line-43">
     <rect x="0" y="1050.7" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-workflow-run-publish-docs-line-44">
+    <rect x="0" y="1075.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-publish-docs-line-45">
+    <rect x="0" y="1099.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-workflow-run-publish-docs-line-46">
+    <rect x="0" y="1123.9" width="1464" height="24.65"/>
+            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="1146" rx="8"/><text 
class="breeze-workflow-run-publish-docs-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;workflow-run&#160;publish-docs</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="1219.2" rx="8"/><text 
class="breeze-workflow-run-publish-docs-title" fill="#c5c8c6" 
text-anchor="middle" x="740" 
y="27">Command:&#160;workflow-run&#160;publish-docs</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -214,28 +223,31 @@
 </text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="508" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-20)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="61" y="508" textLength="256.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-20)">--skip-tag-validation</text><text
 class="breeze-workflow-run-publish-docs-r1" x="341.6" y="508" 
textLength="1000.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-20)">Skip&#160;validati 
[...]
 </text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="532.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-21)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="61" y="532.4" textLength="256.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-21)">--apply-commits&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="341.6" y="532.4" 
textLength="1098" clip-path="url(#breeze-workflow-run-publish [...]
 </text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="556.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-22)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="341.6" y="556.8" 
textLength="341.6" 
clip-path="url(#breeze-workflow-run-publish-docs-line-22)">separated&#160;list&#160;of&#160;commits).&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="683.2" y="556.8" 
textLength="73.2" clip-path="url(#breeze-workflow-run-publish [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="581.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-23)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="61" y="581.2" textLength="256.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-23)">--workflow-branch&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="341.6" y="581.2" 
textLength="622.2" clip-path="url(#breeze-workflow-run-publish-docs-lin [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="605.6" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-24)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="605.6" 
textLength="12.2" clip-path="url(#breeze-workflow-run-publish-docs-line-24)">
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="630" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-25)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="630" textLength="451.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-25)">&#160;Optional&#160;airflow&#160;versions&#160;to&#160;build.&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="475.8" y="630" 
textLength="963.8" clip-path="url(#breeze-workfl [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="654.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-26)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="654.4" 
textLength="268.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-26)">--airflow-version&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="317.2" y="654.4" 
textLength="1122.4" clip-path="url(#breeze-workflow-run-publish [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="678.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-27)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="678.8" 
textLength="1122.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-27)">from&#160;the&#160;ref.&#160;If&#160;only&#160;base&#160;version&#160;is&#160;provided,&#160;it&#160;will&#160;be&#160;set&#160;to&#160;the&#160;same&#160;as&#160;the&#160;base&#160;&#1
 [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="703.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-28)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="703.2" 
textLength="109.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-28)">version.&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="427" y="703.2" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-28)">(TEXT)</text><text c 
[...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="727.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-29)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="727.6" 
textLength="268.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-29)">--airflow-base-version</text><text
 class="breeze-workflow-run-publish-docs-r1" x="317.2" y="727.6" 
textLength="1122.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-29)">Override& [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="752" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-30)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="752" 
textLength="1122.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-30)">extracted&#160;from&#160;the&#160;ref.&#160;If&#160;airflow-version&#160;is&#160;provided,&#160;the&#160;base&#160;version&#160;of&#160;the&#160;version&#160;&#160;&#160;&#160;&#160;</text>
 [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="776.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-31)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="776.4" 
textLength="622.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-31)">provided&#160;(i.e.&#160;stripped&#160;pre-/post-/dev-&#160;suffixes).&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="939.4" y="776.4" 
textLength="73.2" clip-path="url( [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="800.8" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-32)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="800.8" 
textLength="12.2" clip-path="url(#breeze-workflow-run-publish-docs-line-32)">
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="825.2" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-33)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="825.2" textLength="488" 
clip-path="url(#breeze-workflow-run-publish-docs-line-33)">&#160;Select&#160;docs&#160;to&#160;exclude&#160;and&#160;destination&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="512.4" y="825.2" 
textLength="927.2" clip-path="url(#b [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="849.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-34)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="849.6" 
textLength="353.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-34)">--exclude-docs&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="402.6" y="849.6" textLe [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="874" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-35)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="402.6" y="874" 
textLength="378.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-35)">(example:&#160;apache.druid,google)&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="780.8" y="874" 
textLength="73.2" clip-path="url(#breeze-workflow-run-publish-docs-line-35 [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="898.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-36)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="898.4" 
textLength="353.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-36)">--site-env&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="402 [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="922.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-37)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="922.8" 
textLength="353.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-37)">--skip-write-to-stable-folder</text><text
 class="breeze-workflow-run-publish-docs-r1" x="402.6" y="922.8" 
textLength="366" 
clip-path="url(#breeze-workflow-run-publish-docs-line-37)">Skip& [...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="947.2" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-38)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="947.2" 
textLength="12.2" clip-path="url(#breeze-workflow-run-publish-docs-line-38)">
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="971.6" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-39)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="971.6" textLength="244" 
clip-path="url(#breeze-workflow-run-publish-docs-line-39)">&#160;Inventory&#160;handling&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="268.4" y="971.6" 
textLength="1171.2" clip-path="url(#breeze-workflow-run-publish-docs-line-39 
[...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="996" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-40)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="996" textLength="341.6" 
clip-path="url(#breeze-workflow-run-publish-docs-line-40)">--ignore-missing-inventories</text><text
 class="breeze-workflow-run-publish-docs-r1" x="390.4" y="996" 
textLength="695.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-40)">Do&#160;no [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="581.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-23)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="61" y="581.2" textLength="256.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-23)">--workflow-branch&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="341.6" y="581.2" 
textLength="829.6" clip-path="url(#breeze-workflow-run-publish-docs-lin [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="605.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-24)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="341.6" y="605.6" 
textLength="1098" 
clip-path="url(#breeze-workflow-run-publish-docs-line-24)">version&#160;matches&#160;the&#160;content&#160;being&#160;built&#160;(running&#160;main&#x27;s&#160;workflow&#160;against&#160;an&#160;older&#160;tag&#160;&#160;&#160;&#160;&#160;</text>
 [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="630" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-25)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="341.6" y="630" textLength="1098" 
clip-path="url(#breeze-workflow-run-publish-docs-line-25)">breaks&#160;when&#160;inputs/jobs&#160;have&#160;since&#160;changed).&#160;Pass&#160;an&#160;explicit&#160;ref&#160;to&#160;override&#160;(e.g.&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text
 [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="654.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-26)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="341.6" y="654.4" 
textLength="109.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-26)">&#x27;main&#x27;).&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="451.4" y="654.4" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-26)">(TEXT)</ [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="678.8" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-27)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="678.8" 
textLength="12.2" clip-path="url(#breeze-workflow-run-publish-docs-line-27)">
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="703.2" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-28)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="703.2" 
textLength="451.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-28)">&#160;Optional&#160;airflow&#160;versions&#160;to&#160;build.&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="475.8" y="703.2" 
textLength="963.8" clip-path="url(#breeze- [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="727.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-29)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="727.6" 
textLength="268.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-29)">--airflow-version&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="317.2" y="727.6" 
textLength="1122.4" clip-path="url(#breeze-workflow-run-publish [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="752" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-30)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="752" 
textLength="1122.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-30)">from&#160;the&#160;ref.&#160;If&#160;only&#160;base&#160;version&#160;is&#160;provided,&#160;it&#160;will&#160;be&#160;set&#160;to&#160;the&#160;same&#160;as&#160;the&#160;base&#160;&#160;&
 [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="776.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-31)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="776.4" 
textLength="109.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-31)">version.&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="427" y="776.4" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-31)">(TEXT)</text><text c 
[...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="800.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-32)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="800.8" 
textLength="268.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-32)">--airflow-base-version</text><text
 class="breeze-workflow-run-publish-docs-r1" x="317.2" y="800.8" 
textLength="1122.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-32)">Override& [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="825.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-33)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="825.2" 
textLength="1122.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-33)">extracted&#160;from&#160;the&#160;ref.&#160;If&#160;airflow-version&#160;is&#160;provided,&#160;the&#160;base&#160;version&#160;of&#160;the&#160;version&#160;&#160;&#160;&#160;&#160;</t
 [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="849.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-34)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="317.2" y="849.6" 
textLength="622.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-34)">provided&#160;(i.e.&#160;stripped&#160;pre-/post-/dev-&#160;suffixes).&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="939.4" y="849.6" 
textLength="73.2" clip-path="url( [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="874" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-35)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="874" textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-35)">
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="898.4" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-36)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="898.4" textLength="488" 
clip-path="url(#breeze-workflow-run-publish-docs-line-36)">&#160;Select&#160;docs&#160;to&#160;exclude&#160;and&#160;destination&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="512.4" y="898.4" 
textLength="927.2" clip-path="url(#b [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="922.8" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-37)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="922.8" 
textLength="353.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-37)">--exclude-docs&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="402.6" y="922.8" textLe [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="947.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-38)">│</text><text 
class="breeze-workflow-run-publish-docs-r1" x="402.6" y="947.2" 
textLength="378.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-38)">(example:&#160;apache.druid,google)&#160;</text><text
 class="breeze-workflow-run-publish-docs-r8" x="780.8" y="947.2" 
textLength="73.2" clip-path="url(#breeze-workflow-run-publish-docs-l [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="971.6" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-39)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="971.6" 
textLength="353.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-39)">--site-env&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-workflow-run-publish-docs-r1" x="402 [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="996" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-40)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="996" textLength="353.8" 
clip-path="url(#breeze-workflow-run-publish-docs-line-40)">--skip-write-to-stable-folder</text><text
 class="breeze-workflow-run-publish-docs-r1" x="402.6" y="996" textLength="366" 
clip-path="url(#breeze-workflow-run-publish-docs-line-40)">Skip&#160;w [...]
 </text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1020.4" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-41)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="1020.4" 
textLength="12.2" clip-path="url(#breeze-workflow-run-publish-docs-line-41)">
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1044.8" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-42)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="1044.8" 
textLength="195.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-42)">&#160;Common&#160;options&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="219.6" y="1044.8" 
textLength="1220" clip-path="url(#breeze-workflow-run-publish-docs-line-42) 
[...]
-</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1069.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-43)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="1069.2" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-43)">--help</text><text 
class="breeze-workflow-run-publish-docs-r9" x="122" y="1069.2" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-43)">-h</text><text 
class="breez [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1044.8" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-42)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="1044.8" 
textLength="244" 
clip-path="url(#breeze-workflow-run-publish-docs-line-42)">&#160;Inventory&#160;handling&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="268.4" y="1044.8" 
textLength="1171.2" clip-path="url(#breeze-workflow-run-publish-docs-line [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1069.2" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-43)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="1069.2" 
textLength="341.6" 
clip-path="url(#breeze-workflow-run-publish-docs-line-43)">--ignore-missing-inventories</text><text
 class="breeze-workflow-run-publish-docs-r1" x="390.4" y="1069.2" 
textLength="695.4" clip-path="url(#breeze-workflow-run-publish-docs-line-43)">D 
[...]
 </text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1093.6" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-44)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="1093.6" 
textLength="12.2" clip-path="url(#breeze-workflow-run-publish-docs-line-44)">
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1118" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-45)">╭─</text><text 
class="breeze-workflow-run-publish-docs-r5" x="24.4" y="1118" 
textLength="195.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-45)">&#160;Common&#160;options&#160;</text><text
 class="breeze-workflow-run-publish-docs-r5" x="219.6" y="1118" 
textLength="1220" 
clip-path="url(#breeze-workflow-run-publish-docs-line-45)">──── [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1142.4" 
textLength="12.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-46)">│</text><text 
class="breeze-workflow-run-publish-docs-r4" x="24.4" y="1142.4" 
textLength="73.2" 
clip-path="url(#breeze-workflow-run-publish-docs-line-46)">--help</text><text 
class="breeze-workflow-run-publish-docs-r9" x="122" y="1142.4" 
textLength="24.4" 
clip-path="url(#breeze-workflow-run-publish-docs-line-46)">-h</text><text 
class="breez [...]
+</text><text class="breeze-workflow-run-publish-docs-r5" x="0" y="1166.8" 
textLength="1464" 
clip-path="url(#breeze-workflow-run-publish-docs-line-47)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-workflow-run-publish-docs-r1" x="1464" y="1166.8" 
textLength="12.2" clip-path="url(#breeze-workflow-run-publish-docs-line-47)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_workflow-run_publish-docs.txt 
b/dev/breeze/doc/images/output_workflow-run_publish-docs.txt
index 1b59a845f02..bcdbd541efa 100644
--- a/dev/breeze/doc/images/output_workflow-run_publish-docs.txt
+++ b/dev/breeze/doc/images/output_workflow-run_publish-docs.txt
@@ -1 +1 @@
-8c78dbba18e40bb21c4e40543d4c3108
+01f2b301d8965951b49e2c8cd114700a
diff --git a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py 
b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
index 1620a09c90b..2330edfcba6 100644
--- a/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/workflow_commands.py
@@ -98,8 +98,11 @@ def workflow_run_group():
 )
 @click.option(
     "--workflow-branch",
-    help="Branch to run the workflow on. Defaults to 'main'.",
-    default="main",
+    help="Git ref the workflow DEFINITION runs from. Defaults to the value of 
--ref, so the "
+    "workflow version matches the content being built (running main's workflow 
against an "
+    "older tag breaks when inputs/jobs have since changed). Pass an explicit 
ref to override "
+    "(e.g. 'main').",
+    default=None,
     type=str,
 )
 @click.option(
@@ -118,9 +121,14 @@ def workflow_run_publish(
     airflow_version: str | None = None,
     airflow_base_version: str | None = None,
     apply_commits: str | None = None,
-    workflow_branch: str = "main",
+    workflow_branch: str | None = None,
     ignore_missing_inventories: bool = False,
 ):
+    # Default the workflow-definition ref to the ref being built, so the 
workflow version
+    # matches the content. Running main's workflow against an older tag breaks 
when the
+    # workflow's inputs/jobs have changed since that tag (e.g. a newly 
required input).
+    if workflow_branch is None:
+        workflow_branch = ref
     if len(doc_packages) == 0:
         console_print(
             "[red]Error: No doc packages provided. Please provide at least one 
doc package.[/red]",
diff --git a/dev/breeze/src/airflow_breeze/utils/path_utils.py 
b/dev/breeze/src/airflow_breeze/utils/path_utils.py
index b703575fc02..533e741c61e 100644
--- a/dev/breeze/src/airflow_breeze/utils/path_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/path_utils.py
@@ -37,6 +37,14 @@ from airflow_breeze.utils.shared_options import get_verbose, 
set_forced_answer
 
 PYPROJECT_TOML_FILE = "pyproject.toml"
 
+# Location and markers of the breeze shim installed by 
scripts/tools/setup_breeze
+# (ADR 0017). Used to detect a stale installed shim and tell the user to re-run
+# the setup script.
+BREEZE_SHIM_PATH = Path.home() / ".local" / "bin" / "breeze"
+BREEZE_SHIM_MARKER = "Apache Airflow breeze shim — managed by 
scripts/tools/setup_breeze"
+BREEZE_SHIM_VERSION_PREFIX = "# breeze-shim-version:"
+SETUP_BREEZE_SHIM_VERSION_PREFIX = "SHIM_VERSION="
+
 
 def search_upwards_for_airflow_root_path(start_from: Path) -> Path | None:
     root = Path(start_from.root)
@@ -183,6 +191,139 @@ def get_used_airflow_sources() -> Path:
     return current_sources
 
 
+def _parse_shim_version(shim_text: str) -> int | None:
+    """Read the ``# breeze-shim-version: N`` marker from an installed shim, if 
present."""
+    for line in shim_text.splitlines():
+        stripped = line.strip()
+        if stripped.startswith(BREEZE_SHIM_VERSION_PREFIX):
+            value = stripped[len(BREEZE_SHIM_VERSION_PREFIX) :].strip()
+            try:
+                return int(value)
+            except ValueError:
+                return None
+    return None
+
+
+def get_expected_shim_version(airflow_sources: Path) -> int | None:
+    """Read ``SHIM_VERSION`` that the current sources' ``setup_breeze`` would 
install."""
+    setup_script = airflow_sources / "scripts" / "tools" / "setup_breeze"
+    try:
+        for line in setup_script.read_text().splitlines():
+            stripped = line.strip()
+            if stripped.startswith(SETUP_BREEZE_SHIM_VERSION_PREFIX):
+                value = stripped[len(SETUP_BREEZE_SHIM_VERSION_PREFIX) 
:].strip().strip("\"'")
+                try:
+                    return int(value)
+                except ValueError:
+                    return None
+    except OSError:
+        return None
+    return None
+
+
+def warn_if_shim_outdated(airflow_sources: Path, shim_text: str | None = None) 
-> bool:
+    """
+    Warn if the installed breeze shim is older than the one the current 
sources would install.
+
+    The shim at ``~/.local/bin/breeze`` (see ADR 0017) carries a ``# 
breeze-shim-version: N``
+    marker. When the shim body changes, ``scripts/tools/setup_breeze`` bumps 
that version, so an
+    installed shim with a lower (or missing) version means the user is running 
a stale shim and
+    should re-run the setup script. Only our own managed shim is inspected — a 
``uv tool`` / ``pipx``
+    install or a user's own script at that path is left alone.
+
+    :param airflow_sources: Airflow sources breeze is operating on.
+    :param shim_text: contents of the installed shim, if already read by the 
caller.
+    :return: True if a warning was printed.
+    """
+    if shim_text is None:
+        try:
+            shim_text = BREEZE_SHIM_PATH.read_text() if 
BREEZE_SHIM_PATH.is_file() else ""
+        except OSError:
+            return False
+    if BREEZE_SHIM_MARKER not in shim_text:
+        # Not our managed shim (uv tool / pipx install, or the user's own 
script).
+        return False
+    expected_version = get_expected_shim_version(airflow_sources)
+    if expected_version is None:
+        return False
+    installed_version = _parse_shim_version(shim_text)
+    if installed_version is not None and installed_version >= expected_version:
+        return False
+    setup_script = airflow_sources / "scripts" / "tools" / "setup_breeze"
+    installed_text = installed_version if installed_version is not None else 
"unknown (pre-versioning)"
+    console_print(
+        f"\n[warning]Your breeze shim at {BREEZE_SHIM_PATH} is out of date "
+        f"(installed: {installed_text}, current: {expected_version}).[/]\n"
+        "[warning]Re-run the setup script to refresh it:[/]\n\n"
+        f"     {setup_script}\n"
+    )
+    return True
+
+
+def detect_legacy_global_breeze_install() -> str | None:
+    """
+    Detect a legacy global breeze install superseded by the shim (ADR 0017).
+
+    :return: ``"uv"`` or ``"pipx"`` if breeze is installed as a global ``uv 
tool`` / ``pipx``
+        install, otherwise ``None``.
+    """
+    try:
+        result = subprocess.run(["uv", "tool", "list"], text=True, 
capture_output=True, check=False)
+        if result.returncode == 0 and "apache-airflow-breeze" in result.stdout:
+            return "uv"
+    except FileNotFoundError:
+        pass
+    try:
+        result = subprocess.run(["pipx", "list", "--short"], text=True, 
capture_output=True, check=False)
+        if result.returncode == 0 and "apache-airflow-breeze" in result.stdout:
+            return "pipx"
+    except FileNotFoundError:
+        pass
+    return None
+
+
+def warn_if_breeze_launcher_outdated(airflow_sources: Path) -> bool:
+    """
+    Warn when the breeze launcher in use is not the current recommended shim 
(ADR 0017).
+
+    Covers both ways an installation can be out of date:
+
+    * the managed shim at ``~/.local/bin/breeze`` exists but is older than the 
one the current
+      sources would install (e.g. ``$AIRFLOW_REPO_ROOT`` support was added) — 
re-run the setup
+      script to refresh it;
+    * breeze is still installed as a legacy global ``uv tool`` / ``pipx`` 
install (no shim) — that
+      install keeps working but is no longer recommended; point the user at 
migrating to the shim.
+
+    :param airflow_sources: Airflow sources breeze is operating on.
+    :return: True if a warning was printed.
+    """
+    try:
+        shim_text = BREEZE_SHIM_PATH.read_text() if BREEZE_SHIM_PATH.is_file() 
else ""
+    except OSError:
+        shim_text = ""
+    if BREEZE_SHIM_MARKER in shim_text:
+        # Our managed shim is installed — only check that it is current.
+        return warn_if_shim_outdated(airflow_sources, shim_text=shim_text)
+    legacy = detect_legacy_global_breeze_install()
+    if legacy is None:
+        return False
+    uninstall_cmd = (
+        "uv tool uninstall apache-airflow-breeze"
+        if legacy == "uv"
+        else "pipx uninstall apache-airflow-breeze"
+    )
+    setup_script = airflow_sources / "scripts" / "tools" / "setup_breeze"
+    console_print(
+        f"\n[warning]Breeze is installed as a legacy global '{legacy}' 
install, which still works "
+        "but is no longer the recommended setup (see ADR 0017).[/]\n"
+        "[warning]Migrate to the per-worktree uvx shim by uninstalling the 
global install and "
+        "running the setup script:[/]\n\n"
+        f"     {uninstall_cmd}\n"
+        f"     {setup_script}\n"
+    )
+    return True
+
+
 @clearable_cache
 def find_airflow_root_path_to_operate_on() -> Path:
     """
@@ -209,7 +350,13 @@ def find_airflow_root_path_to_operate_on() -> Path:
     """
     sources_root_from_env = os.getenv("AIRFLOW_ROOT_PATH", None)
     if sources_root_from_env:
-        return Path(sources_root_from_env)
+        # Set by the shim (ADR 0017) — the global self-upgrade checks below 
are skipped in this
+        # path (the shim also exports SKIP_BREEZE_SELF_UPGRADE_CHECK), so 
check here that the
+        # installed shim itself is not stale. Ignore SKIP here on purpose: the 
shim always sets it.
+        airflow_sources_from_env = Path(sources_root_from_env)
+        if not in_autocomplete() and not in_help() and not hasattr(sys, 
"_called_from_test"):
+            warn_if_breeze_launcher_outdated(airflow_sources_from_env)
+        return airflow_sources_from_env
     installation_airflow_sources = get_installation_airflow_sources()
     if installation_airflow_sources is None and not 
skip_breeze_self_upgrade_check():
         console_print(
@@ -226,6 +373,9 @@ def find_airflow_root_path_to_operate_on() -> Path:
         # only print warning and sleep if not producing complete results
         reinstall_if_different_sources(airflow_sources)
         reinstall_if_setup_changed()
+        # Not invoked via the shim (no AIRFLOW_ROOT_PATH). If breeze is still 
on a legacy global
+        # uv tool / pipx install, nudge the user to migrate to the recommended 
shim (ADR 0017).
+        warn_if_breeze_launcher_outdated(airflow_sources)
     os.chdir(airflow_sources.as_posix())
     airflow_home_dir = Path(os.environ.get("AIRFLOW_HOME", (Path.home() / 
"airflow").resolve().as_posix()))
     if airflow_sources.resolve() == airflow_home_dir.resolve():
diff --git a/dev/breeze/tests/test_shim_version_check.py 
b/dev/breeze/tests/test_shim_version_check.py
new file mode 100644
index 00000000000..b027955a1d4
--- /dev/null
+++ b/dev/breeze/tests/test_shim_version_check.py
@@ -0,0 +1,212 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+from airflow_breeze.utils import path_utils
+from airflow_breeze.utils.path_utils import (
+    BREEZE_SHIM_MARKER,
+    _parse_shim_version,
+    detect_legacy_global_breeze_install,
+    get_expected_shim_version,
+    warn_if_breeze_launcher_outdated,
+    warn_if_shim_outdated,
+)
+
+ACTUAL_AIRFLOW_SOURCES = Path(__file__).parents[3].resolve()
+
+
+def _managed_shim(version_line: str | None) -> str:
+    lines = ["#!/usr/bin/env bash", BREEZE_SHIM_MARKER]
+    if version_line is not None:
+        lines.append(version_line)
+    lines.append("set -e")
+    return "\n".join(lines) + "\n"
+
+
[email protected](
+    ("version_line", "expected"),
+    [
+        ("# breeze-shim-version: 1", 1),
+        ("# breeze-shim-version: 42", 42),
+        ("   # breeze-shim-version: 3   ", 3),
+        ("# breeze-shim-version: not-a-number", None),
+        ("# something-else: 1", None),
+        (None, None),
+    ],
+)
+def test_parse_shim_version(version_line, expected):
+    assert _parse_shim_version(_managed_shim(version_line)) == expected
+
+
+def test_get_expected_shim_version_reads_real_setup_script():
+    # The real setup_breeze in the sources is the source of truth — keep this 
test in sync
+    # with the SHIM_VERSION it declares.
+    assert get_expected_shim_version(ACTUAL_AIRFLOW_SOURCES) == 1
+
+
+def test_get_expected_shim_version_from_fake_sources(tmp_path):
+    setup_script = tmp_path / "scripts" / "tools" / "setup_breeze"
+    setup_script.parent.mkdir(parents=True)
+    setup_script.write_text('SHIM_DIR="x"\nSHIM_VERSION="7"\n')
+    assert get_expected_shim_version(tmp_path) == 7
+
+
+def test_get_expected_shim_version_missing_script(tmp_path):
+    assert get_expected_shim_version(tmp_path) is None
+
+
+def _fake_sources_with_version(tmp_path: Path, version: int) -> Path:
+    setup_script = tmp_path / "scripts" / "tools" / "setup_breeze"
+    setup_script.parent.mkdir(parents=True)
+    setup_script.write_text(f'SHIM_VERSION="{version}"\n')
+    return tmp_path
+
+
+def test_warn_if_shim_outdated_no_shim_file(tmp_path, monkeypatch, capsys):
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", tmp_path / "breeze")
+    sources = _fake_sources_with_version(tmp_path, 1)
+    assert warn_if_shim_outdated(sources) is False
+    assert capsys.readouterr().out == ""
+
+
+def test_warn_if_shim_outdated_foreign_script(tmp_path, monkeypatch, capsys):
+    shim = tmp_path / "breeze"
+    shim.write_text("#!/usr/bin/env bash\n# some user's own script\n")
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", shim)
+    sources = _fake_sources_with_version(tmp_path, 1)
+    assert warn_if_shim_outdated(sources) is False
+    assert capsys.readouterr().out == ""
+
+
+def test_warn_if_shim_outdated_up_to_date(tmp_path, monkeypatch, capsys):
+    shim = tmp_path / "breeze"
+    shim.write_text(_managed_shim("# breeze-shim-version: 1"))
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", shim)
+    sources = _fake_sources_with_version(tmp_path, 1)
+    assert warn_if_shim_outdated(sources) is False
+    assert capsys.readouterr().out == ""
+
+
+def test_warn_if_shim_outdated_newer_installed(tmp_path, monkeypatch, capsys):
+    shim = tmp_path / "breeze"
+    shim.write_text(_managed_shim("# breeze-shim-version: 5"))
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", shim)
+    sources = _fake_sources_with_version(tmp_path, 2)
+    assert warn_if_shim_outdated(sources) is False
+    assert capsys.readouterr().out == ""
+
+
+def test_warn_if_shim_outdated_older_installed(tmp_path, monkeypatch, capsys):
+    shim = tmp_path / "breeze"
+    shim.write_text(_managed_shim("# breeze-shim-version: 1"))
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", shim)
+    sources = _fake_sources_with_version(tmp_path, 2)
+    assert warn_if_shim_outdated(sources) is True
+    output = capsys.readouterr().out
+    assert "out of date" in output
+    assert "setup_breeze" in output
+
+
+def test_warn_if_shim_outdated_pre_versioning_shim(tmp_path, monkeypatch, 
capsys):
+    # A managed shim with no version marker predates versioning — treat it as 
outdated.
+    shim = tmp_path / "breeze"
+    shim.write_text(_managed_shim(None))
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", shim)
+    sources = _fake_sources_with_version(tmp_path, 1)
+    assert warn_if_shim_outdated(sources) is True
+    output = capsys.readouterr().out
+    assert "out of date" in output
+    assert "pre-versioning" in output
+
+
+def _run_result(stdout: str, returncode: int = 0):
+    return subprocess.CompletedProcess(args=[], returncode=returncode, 
stdout=stdout, stderr="")
+
+
+def test_detect_legacy_global_breeze_install_uv():
+    with mock.patch("airflow_breeze.utils.path_utils.subprocess.run") as run:
+        run.return_value = _run_result("apache-airflow-breeze v3.0.0\n")
+        assert detect_legacy_global_breeze_install() == "uv"
+
+
+def test_detect_legacy_global_breeze_install_pipx():
+    def fake_run(cmd, *args, **kwargs):
+        if cmd[:2] == ["uv", "tool"]:
+            return _run_result("some-other-tool\n")
+        return _run_result("apache-airflow-breeze\n")
+
+    with mock.patch("airflow_breeze.utils.path_utils.subprocess.run", 
side_effect=fake_run):
+        assert detect_legacy_global_breeze_install() == "pipx"
+
+
+def test_detect_legacy_global_breeze_install_none():
+    with mock.patch("airflow_breeze.utils.path_utils.subprocess.run") as run:
+        run.return_value = _run_result("")
+        assert detect_legacy_global_breeze_install() is None
+
+
+def test_detect_legacy_global_breeze_install_tools_missing():
+    with mock.patch("airflow_breeze.utils.path_utils.subprocess.run", 
side_effect=FileNotFoundError):
+        assert detect_legacy_global_breeze_install() is None
+
+
+def test_launcher_check_prefers_shim_version(tmp_path, monkeypatch, capsys):
+    # When our managed shim is installed, the launcher check does the version 
check and never
+    # shells out to detect a legacy install.
+    shim = tmp_path / "breeze"
+    shim.write_text(_managed_shim("# breeze-shim-version: 1"))
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", shim)
+    sources = _fake_sources_with_version(tmp_path, 2)
+    with 
mock.patch("airflow_breeze.utils.path_utils.detect_legacy_global_breeze_install")
 as detect:
+        assert warn_if_breeze_launcher_outdated(sources) is True
+        detect.assert_not_called()
+    output = capsys.readouterr().out
+    assert "out of date" in output
+
+
[email protected](
+    ("legacy", "uninstall_cmd"),
+    [
+        ("uv", "uv tool uninstall apache-airflow-breeze"),
+        ("pipx", "pipx uninstall apache-airflow-breeze"),
+    ],
+)
+def test_launcher_check_warns_on_legacy_install(tmp_path, monkeypatch, capsys, 
legacy, uninstall_cmd):
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", tmp_path / "breeze")  
# no shim file
+    sources = _fake_sources_with_version(tmp_path, 1)
+    with mock.patch(
+        "airflow_breeze.utils.path_utils.detect_legacy_global_breeze_install", 
return_value=legacy
+    ):
+        assert warn_if_breeze_launcher_outdated(sources) is True
+    output = capsys.readouterr().out
+    assert "legacy global" in output
+    assert uninstall_cmd in output
+    assert "setup_breeze" in output
+
+
+def test_launcher_check_silent_without_shim_or_legacy(tmp_path, monkeypatch, 
capsys):
+    monkeypatch.setattr(path_utils, "BREEZE_SHIM_PATH", tmp_path / "breeze")  
# no shim file
+    sources = _fake_sources_with_version(tmp_path, 1)
+    with 
mock.patch("airflow_breeze.utils.path_utils.detect_legacy_global_breeze_install",
 return_value=None):
+        assert warn_if_breeze_launcher_outdated(sources) is False
+    assert capsys.readouterr().out == ""
diff --git a/scripts/tools/setup_breeze b/scripts/tools/setup_breeze
index f47ccfb905a..4ae4ab2c25e 100755
--- a/scripts/tools/setup_breeze
+++ b/scripts/tools/setup_breeze
@@ -35,26 +35,55 @@ SHIM_PATH="${SHIM_DIR}/breeze"
 # foreign breeze binary (e.g. left over from `uv tool install`).
 SHIM_MARKER="# Apache Airflow breeze shim — managed by 
scripts/tools/setup_breeze (ADR 0017)."
 
+# Shim body version, stamped into the shim as a "# breeze-shim-version: N" 
line.
+# Bump this whenever the shim body below changes. On startup breeze reads this
+# value from the sources it operates on and compares it with the version baked
+# into the installed shim; if the installed shim is older it warns the user to
+# re-run this script. See warn_if_shim_outdated() in
+# dev/breeze/src/airflow_breeze/utils/path_utils.py.
+SHIM_VERSION="1"
+
 # The shim itself. Runs breeze via 'uvx' against the dev/breeze folder of the
 # *current* git worktree, so multiple checkouts / agentic worktrees never
 # share a single global install. See ADR 0017.
+#
+# When invoked outside any Airflow worktree (e.g. from an SVN release checkout
+# such as asf-dist during a provider release), the shim falls back to, in 
order:
+# $AIRFLOW_REPO_ROOT (exported by the release docs) if it points at a worktree,
+# then the worktree this shim was installed from (AIRFLOW_SOURCES, baked in 
below).
 read -r -d '' BREEZE_SHIM_BODY <<BREEZE_SHIM || true
 #!/usr/bin/env bash
 ${SHIM_MARKER}
+# breeze-shim-version: ${SHIM_VERSION}
 # Runs breeze from the dev/breeze folder of the current git worktree via 'uvx',
 # so each worktree (e.g. parallel agentic runs) gets its own 
ephemerally-installed
 # breeze tied to that worktree's source.
+#
+# Resolution order for the Airflow sources breeze runs from:
+#   1. the current git worktree (per-worktree isolation — see ADR 0017);
+#   2. \$AIRFLOW_REPO_ROOT, if exported and pointing at an Airflow worktree — 
the
+#      release docs export this, so breeze resolves the same way across every
+#      release process regardless of where the shim was installed from;
+#   3. the install-time fallback baked in below (the worktree setup_breeze ran 
from).
+# Steps 2 and 3 apply only when the current directory is not an Airflow 
worktree,
+# so the fallbacks never override a real worktree and isolation is preserved.
 set -e
-repo_root=\$(git rev-parse --show-toplevel 2>/dev/null) || {
-    echo "breeze: not inside a git repository — cd into an Airflow worktree 
first" >&2
-    exit 1
-}
-if [ ! -d "\${repo_root}/dev/breeze" ]; then
-    echo "breeze: \${repo_root} is not an Airflow worktree (no dev/breeze)" >&2
+# Install-time fallback: the Airflow sources 'scripts/tools/setup_breeze' was 
run
+# from. Used only when the current directory is not an Airflow worktree.
+fallback_root="${AIRFLOW_SOURCES}"
+repo_root=\$(git rev-parse --show-toplevel 2>/dev/null) || repo_root=""
+if [ -n "\${repo_root}" ] && [ -d "\${repo_root}/dev/breeze" ]; then
+    breeze_root="\${repo_root}"
+elif [ -n "\${AIRFLOW_REPO_ROOT:-}" ] && [ -d 
"\${AIRFLOW_REPO_ROOT}/dev/breeze" ]; then
+    breeze_root="\${AIRFLOW_REPO_ROOT}"
+elif [ -d "\${fallback_root}/dev/breeze" ]; then
+    breeze_root="\${fallback_root}"
+else
+    echo "breeze: not inside an Airflow worktree, AIRFLOW_REPO_ROOT is unset 
or not an Airflow worktree, and the install-time fallback 
'\${fallback_root}/dev/breeze' is missing — re-run scripts/tools/setup_breeze" 
>&2
     exit 1
 fi
-exec env AIRFLOW_ROOT_PATH="\${repo_root}" SKIP_BREEZE_SELF_UPGRADE_CHECK=1 \\
-    uvx --from "\${repo_root}/dev/breeze" --quiet breeze "\$@"
+exec env AIRFLOW_ROOT_PATH="\${breeze_root}" SKIP_BREEZE_SELF_UPGRADE_CHECK=1 
\\
+    uvx --from "\${breeze_root}/dev/breeze" --quiet breeze "\$@"
 BREEZE_SHIM
 
 function manual_instructions() {

Reply via email to