This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch fix-breeze-uv-lock-mount-lowest-deps in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 93d9b9a00d500e7ab2ae2c8d630d281fc5268b7e Author: Jarek Potiuk <[email protected]> AuthorDate: Fri Sep 18 06:51:52 2026 +0200 Stop breeze rewriting uv.lock when forcing lowest dependencies Running breeze with --force-lowest-dependencies re-resolves the whole dependency graph inside the container. With uv.lock bind-mounted from the worktree, that resolution was written straight back over the contributor's lock - thousands of lines, no warning, and plausible enough to be mistaken for their own change. Generated-by: Claude Opus 5 --- .../src/airflow_breeze/params/shell_params.py | 11 +++++++++ .../airflow_breeze/utils/docker_command_utils.py | 5 +++- dev/breeze/src/airflow_breeze/utils/path_utils.py | 1 + dev/breeze/tests/test_shell_params.py | 27 +++++++++++++++++++++- scripts/ci/docker-compose/local.yml | 3 --- scripts/ci/docker-compose/mount-uv-lock.yml | 27 ++++++++++++++++++++++ 6 files changed, 69 insertions(+), 5 deletions(-) diff --git a/dev/breeze/src/airflow_breeze/params/shell_params.py b/dev/breeze/src/airflow_breeze/params/shell_params.py index 9bcac54db23..af5c044417a 100644 --- a/dev/breeze/src/airflow_breeze/params/shell_params.py +++ b/dev/breeze/src/airflow_breeze/params/shell_params.py @@ -101,6 +101,7 @@ from airflow_breeze.utils.path_utils import ( SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_ALL_SOURCES_PATH, SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH, SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UI_DIST_PATH, + SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UV_LOCK_PATH, SCRIPTS_CI_DOCKER_COMPOSE_MYPY_PATH, SCRIPTS_CI_DOCKER_COMPOSE_PATH, SCRIPTS_CI_DOCKER_COMPOSE_PROVIDERS_AND_TESTS_SOURCES_PATH, @@ -419,8 +420,18 @@ class ShellParams: compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_DEBUG_PORTS_PATH) if self.mount_sources == MOUNT_SELECTED: compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH) + if not self.force_lowest_dependencies: + compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UV_LOCK_PATH) elif self.mount_sources == MOUNT_ALL: compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_ALL_SOURCES_PATH) + if self.force_lowest_dependencies: + # The whole worktree is bound here, so uv.lock cannot be left out of the mount. + console_print( + "\n[warning]--force-lowest-dependencies with --mount-sources all will rewrite " + "your uv.lock: the lowest-direct `uv sync` writes its re-resolved lock through " + "the mounted worktree. Use the default --mount-sources selected to keep it " + "untouched, or restore it afterwards with `git checkout -- uv.lock`.[/]\n" + ) elif self.mount_sources == MOUNT_TESTS: compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_TESTS_SOURCES_PATH) elif self.mount_sources == MOUNT_PROVIDERS_AND_TESTS: diff --git a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py index da6a9e32626..21604a24145 100644 --- a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py +++ b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py @@ -114,13 +114,16 @@ VOLUMES_FOR_SELECTED_MOUNTS = [ ("registry", "/opt/airflow/registry"), ("pyproject.toml", "/opt/airflow/pyproject.toml"), ("scripts", "/opt/airflow/scripts"), - ("uv.lock", "/opt/airflow/uv.lock"), ("scripts/docker/entrypoint_ci.sh", "/entrypoint"), ("shared", "/opt/airflow/shared"), ("task-sdk", "/opt/airflow/task-sdk"), ("ts-sdk", "/opt/airflow/ts-sdk"), ] +# ``uv.lock`` is deliberately absent above: it is mounted from ``mount-uv-lock.yml``, which +# ShellParams skips for ``--force-lowest-dependencies`` so that the lowest-direct ``uv sync`` +# run in the container cannot write its re-resolved lock back over the host's. + DOCKER_INFO_TIMEOUT = 30 diff --git a/dev/breeze/src/airflow_breeze/utils/path_utils.py b/dev/breeze/src/airflow_breeze/utils/path_utils.py index 414fca44116..49bea72395e 100644 --- a/dev/breeze/src/airflow_breeze/utils/path_utils.py +++ b/dev/breeze/src/airflow_breeze/utils/path_utils.py @@ -523,6 +523,7 @@ SCRIPTS_CI_DOCKER_COMPOSE_INTEGRATION_KERBEROS_PATH = ( SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_ALL_SOURCES_PATH = SCRIPTS_CI_DOCKER_COMPOSE_PATH / "local-all-sources.yml" SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH = SCRIPTS_CI_DOCKER_COMPOSE_PATH / "local.yml" SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UI_DIST_PATH = SCRIPTS_CI_DOCKER_COMPOSE_PATH / "mount-ui-dist.yml" +SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UV_LOCK_PATH = SCRIPTS_CI_DOCKER_COMPOSE_PATH / "mount-uv-lock.yml" SCRIPTS_CI_DOCKER_COMPOSE_MYPY_PATH = SCRIPTS_CI_DOCKER_COMPOSE_PATH / "mypy.yml" SCRIPTS_CI_DOCKER_COMPOSE_PYCACHE_PATH = SCRIPTS_CI_DOCKER_COMPOSE_PATH / "pycache.yml" SCRIPTS_CI_DOCKER_COMPOSE_PROVIDERS_AND_TESTS_SOURCES_PATH = ( diff --git a/dev/breeze/tests/test_shell_params.py b/dev/breeze/tests/test_shell_params.py index 67980076b5c..cf12d38d19e 100644 --- a/dev/breeze/tests/test_shell_params.py +++ b/dev/breeze/tests/test_shell_params.py @@ -25,10 +25,12 @@ import yaml from rich.console import Console from airflow_breeze.branch_defaults import AIRFLOW_BRANCH -from airflow_breeze.global_constants import PYCACHE_PREFIX_IN_CONTAINER +from airflow_breeze.global_constants import MOUNT_SELECTED, PYCACHE_PREFIX_IN_CONTAINER from airflow_breeze.params.shell_params import ShellParams from airflow_breeze.utils.path_utils import ( SCRIPTS_CI_DOCKER_COMPOSE_BASE_PATH, + SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH, + SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UV_LOCK_PATH, SCRIPTS_CI_DOCKER_COMPOSE_PATH, SCRIPTS_CI_DOCKER_COMPOSE_PYCACHE_PATH, ) @@ -285,3 +287,26 @@ def test_pycache_volume_compose_file_is_included_only_when_requested( def test_include_mypy_volume_adds_mypy_compose_file(): compose_files = ShellParams(include_mypy_volume=True).compose_file.split(":") assert str(SCRIPTS_CI_DOCKER_COMPOSE_PATH / "mypy.yml") in compose_files + + [email protected](("force_lowest_dependencies", "expected_count"), [(True, 0), (False, 1)]) +def test_uv_lock_is_not_mounted_for_lowest_dependencies(force_lowest_dependencies: bool, expected_count: int): + """The lowest-direct ``uv sync`` rewrites uv.lock, so its mount is dropped for that run.""" + compose_files = ShellParams( + mount_sources=MOUNT_SELECTED, force_lowest_dependencies=force_lowest_dependencies + ).compose_file.split(os.pathsep) + assert compose_files.count(str(SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UV_LOCK_PATH)) == expected_count + + +def test_uv_lock_is_mounted_only_by_its_own_compose_file(): + """Guards the split: a stray uv.lock bind in local.yml would defeat the skip above.""" + local_compose_file = yaml.safe_load(SCRIPTS_CI_DOCKER_COMPOSE_LOCAL_YAML_PATH.read_text()) + # local.yml mixes named volumes (plain "source:target" strings) with bind mappings. + local_volumes = local_compose_file["services"]["airflow"]["volumes"] + targets = [volume["target"] if isinstance(volume, dict) else volume for volume in local_volumes] + assert not any("uv.lock" in target for target in targets) + + uv_lock_compose_file = yaml.safe_load(SCRIPTS_CI_DOCKER_COMPOSE_MOUNT_UV_LOCK_PATH.read_text()) + assert uv_lock_compose_file["services"]["airflow"]["volumes"] == [ + {"type": "bind", "source": "../../../uv.lock", "target": "/opt/airflow/uv.lock"} + ] diff --git a/scripts/ci/docker-compose/local.yml b/scripts/ci/docker-compose/local.yml index 4f389106f6e..54b19a2affa 100644 --- a/scripts/ci/docker-compose/local.yml +++ b/scripts/ci/docker-compose/local.yml @@ -120,9 +120,6 @@ services: - type: bind source: ../../../scripts target: /opt/airflow/scripts - - type: bind - source: ../../../uv.lock - target: /opt/airflow/uv.lock - type: bind source: ../../../scripts/docker/entrypoint_ci.sh target: /entrypoint diff --git a/scripts/ci/docker-compose/mount-uv-lock.yml b/scripts/ci/docker-compose/mount-uv-lock.yml new file mode 100644 index 00000000000..35ac1c7ea0c --- /dev/null +++ b/scripts/ci/docker-compose/mount-uv-lock.yml @@ -0,0 +1,27 @@ +# 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. +--- +services: + airflow: + # Kept out of local.yml so that it can be skipped: `uv sync` rewrites uv.lock in place, and + # with --force-lowest-dependencies it re-resolves the whole graph, so mounting the host file + # would silently replace the contributor's lock with a lowest-direct resolution. Without the + # mount the container falls back to the uv.lock baked into the CI image and writes there. + volumes: + - type: bind + source: ../../../uv.lock + target: /opt/airflow/uv.lock
