This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new a75957c9885 [v3-3-test] Reuse CI image built from the same sources in
another checkout (#71886) (#71910)
a75957c9885 is described below
commit a75957c98852de6e3d4b217ac8d7fb554e92b282
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Aug 20 20:34:18 2026 +0200
[v3-3-test] Reuse CI image built from the same sources in another checkout
(#71886) (#71910)
Breeze keeps the record of whether the CI image was built in the
per-checkout .build directory, while the image itself is shared by all
checkouts (e.g. git worktrees) through the single Docker daemon. A fresh
worktree therefore always force-rebuilt an image that had already been
built from identical sources in another checkout.
The CI image now carries a label with the aggregated hash of the files
that trigger rebuilds, so any checkout sharing the Docker daemon can
recognize that the existing image matches its sources and reuse it
instead of rebuilding.
(cherry picked from commit 9899393f0b672c8b971b43fa32ba9e4b2b2605ec)
Co-authored-by: Andrew Chang <[email protected]>
---
dev/breeze/doc/ci/02_images.md | 8 +
.../airflow_breeze/commands/ci_image_commands.py | 57 ++++++-
dev/breeze/src/airflow_breeze/global_constants.py | 3 +
.../airflow_breeze/utils/docker_command_utils.py | 4 +
.../utils/mark_image_as_refreshed.py | 6 +-
.../src/airflow_breeze/utils/md5_build_check.py | 13 ++
dev/breeze/tests/test_ci_image_commands.py | 188 +++++++++++++++++++++
dev/breeze/tests/test_docker_command_utils.py | 30 +++-
8 files changed, 305 insertions(+), 4 deletions(-)
diff --git a/dev/breeze/doc/ci/02_images.md b/dev/breeze/doc/ci/02_images.md
index ba1711fe5ac..f06bee064d7 100644
--- a/dev/breeze/doc/ci/02_images.md
+++ b/dev/breeze/doc/ci/02_images.md
@@ -151,6 +151,14 @@ steps are executed to rebuild parts of the image (for
example, PIP
dependencies) and will give you an image consistent with the one used
during Continuous Integration.
+Locally built CI images are labelled with the aggregated hash of the
+files that trigger image rebuild when changed
+(`org.apache.airflow.ci.sources-hash` label). When another checkout of
+the same sources - for example a git worktree - shares the same Docker
+daemon, Breeze recognizes via that label that the image present in the
+daemon was built from identical sources and reuses it instead of
+rebuilding it, even though that checkout never built the image itself.
+
The command that builds the production image is optimised for size of
the image.
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_image_commands.py
b/dev/breeze/src/airflow_breeze/commands/ci_image_commands.py
index e9b34bfd164..069f618fd16 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_image_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_image_commands.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import contextlib
+import json
import os
import signal
import subprocess
@@ -80,7 +81,7 @@ from
airflow_breeze.commands.common_package_installation_options import (
option_airflow_constraints_location,
option_airflow_constraints_mode_ci,
)
-from airflow_breeze.global_constants import UV_VERSION
+from airflow_breeze.global_constants import CI_IMAGE_SOURCES_HASH_LABEL,
UV_VERSION
from airflow_breeze.params.build_ci_params import BuildCiParams
from airflow_breeze.utils.ci_group import ci_group
from airflow_breeze.utils.click_utils import BreezeGroup
@@ -98,7 +99,7 @@ from airflow_breeze.utils.docker_command_utils import (
from airflow_breeze.utils.github import download_artifact_from_pr,
download_artifact_from_run_id
from airflow_breeze.utils.image import run_pull_image, run_pull_in_parallel
from airflow_breeze.utils.mark_image_as_refreshed import mark_image_as_rebuilt
-from airflow_breeze.utils.md5_build_check import
md5sum_check_if_build_is_needed
+from airflow_breeze.utils.md5_build_check import calculate_ci_sources_hash,
md5sum_check_if_build_is_needed
from airflow_breeze.utils.parallel import (
DockerBuildxProgressMatcher,
ShowLastLineProgressMatcher,
@@ -712,11 +713,49 @@ def verify(
sys.exit(return_code)
+def get_ci_image_sources_hash_label(airflow_image_name: str) -> str | None:
+ """
+ Reads the sources-hash label from the CI image - None if the image or the
label is missing.
+
+ :param airflow_image_name: name of the image to inspect
+ """
+ inspect_result = run_command(
+ ["docker", "inspect", airflow_image_name, "-f", "{{json
.Config.Labels}}"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if inspect_result.returncode != 0 or not inspect_result.stdout:
+ return None
+ try:
+ labels = json.loads(inspect_result.stdout.strip())
+ except json.JSONDecodeError:
+ return None
+ if not labels:
+ return None
+ return labels.get(CI_IMAGE_SOURCES_HASH_LABEL)
+
+
+def is_ci_image_built_from_current_sources(ci_image_params: BuildCiParams) ->
bool:
+ """
+ Check if the CI image present in the Docker daemon was built from sources
identical to the
+ current checkout - possibly by another checkout (e.g. a git worktree)
sharing the same daemon.
+
+ :param ci_image_params: parameters of the image to check
+ """
+ image_sources_hash =
get_ci_image_sources_hash_label(ci_image_params.airflow_image_name)
+ if not image_sources_hash:
+ return False
+ return image_sources_hash == calculate_ci_sources_hash()
+
+
def should_we_run_the_build(build_ci_params: BuildCiParams) -> bool:
"""
Check if we should run the build based on what files have been modified
since last build and answer from
the user.
+ * If the image already matches current sources (e.g. it was built in
another git worktree
+ sharing the same Docker daemon), the local build cache is refreshed and
no build is needed
* If build is needed, the user is asked for confirmation
* If the branch is not rebased it warns the user to rebase (to make sure
latest remote cache is useful)
* Builds Image/Skips/Quits depending on the answer
@@ -726,6 +765,13 @@ def should_we_run_the_build(build_ci_params:
BuildCiParams) -> bool:
# We import those locally so that click autocomplete works
from inputimeout import TimeoutOccurred
+ if is_ci_image_built_from_current_sources(build_ci_params):
+ console_print(
+ f"[info]Docker image {build_ci_params.airflow_image_name} was
built from the same "
+ "important sources - no rebuild is needed.[/]"
+ )
+ mark_image_as_rebuilt(ci_image_params=build_ci_params)
+ return False
if not md5sum_check_if_build_is_needed(
build_ci_params=build_ci_params,
md5sum_cache_dir=build_ci_params.md5sum_cache_dir,
@@ -891,6 +937,13 @@ def rebuild_or_pull_ci_image_if_needed(command_params:
ShellParams | BuildCiPara
if build_ci_image_check_cache.exists():
if get_verbose():
console_print(f"[info]{command_params.image_type} image already
built locally.[/]")
+ elif not ci_image_params.force_build and
is_ci_image_built_from_current_sources(ci_image_params):
+ console_print(
+ f"[info]{command_params.image_type} image for Python
{command_params.python} was built "
+ "from the same important sources in another checkout (e.g. a git
worktree). Reusing it.[/]"
+ )
+ mark_image_as_rebuilt(ci_image_params=ci_image_params)
+ return
else:
console_print(
f"[warning]{command_params.image_type} image for Python
{command_params.python} "
diff --git a/dev/breeze/src/airflow_breeze/global_constants.py
b/dev/breeze/src/airflow_breeze/global_constants.py
index 3dd5016823f..7f0de4353bc 100644
--- a/dev/breeze/src/airflow_breeze/global_constants.py
+++ b/dev/breeze/src/airflow_breeze/global_constants.py
@@ -760,6 +760,9 @@ FILES_FOR_REBUILD_CHECK = [
"scripts/docker/install_mysql.sh",
]
+# Hash of FILES_FOR_REBUILD_CHECK contents, set on CI images so other
checkouts can detect identical sources
+CI_IMAGE_SOURCES_HASH_LABEL = "org.apache.airflow.ci.sources-hash"
+
CURRENT_KUBERNETES_VERSIONS = ALLOWED_KUBERNETES_VERSIONS
CURRENT_EXECUTORS = [KUBERNETES_EXECUTOR]
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 5470547196a..013f7a8e265 100644
--- a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
@@ -53,6 +53,7 @@ except ImportError:
from airflow_breeze.global_constants import (
ALLOWED_CELERY_BROKERS,
ALLOWED_DEBIAN_VERSIONS,
+ CI_IMAGE_SOURCES_HASH_LABEL,
CURRENT_POSTGRES_VERSIONS,
DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
DOCKER_DEFAULT_PLATFORM,
@@ -63,6 +64,7 @@ from airflow_breeze.global_constants import (
)
from airflow_breeze.utils.console import Output, console_print, get_console
from airflow_breeze.utils.environment_check import check_uv_version
+from airflow_breeze.utils.md5_build_check import calculate_ci_sources_hash
from airflow_breeze.utils.run_utils import (
RunCommandResult,
check_if_buildx_plugin_installed,
@@ -472,6 +474,8 @@ def prepare_docker_build_command(
["-f", "Dockerfile" if isinstance(image_params, BuildProdParams) else
"Dockerfile.ci"]
)
final_command.extend(["--platform", image_params.platform])
+ if not isinstance(image_params, BuildProdParams):
+ final_command.extend(["--label",
f"{CI_IMAGE_SOURCES_HASH_LABEL}={calculate_ci_sources_hash()}"])
return final_command
diff --git a/dev/breeze/src/airflow_breeze/utils/mark_image_as_refreshed.py
b/dev/breeze/src/airflow_breeze/utils/mark_image_as_refreshed.py
index 5f4a4866e1b..cb86fc0046d 100644
--- a/dev/breeze/src/airflow_breeze/utils/mark_image_as_refreshed.py
+++ b/dev/breeze/src/airflow_breeze/utils/mark_image_as_refreshed.py
@@ -30,4 +30,8 @@ def mark_image_as_rebuilt(ci_image_params: BuildCiParams):
ci_image_cache_dir = BUILD_CACHE_PATH / ci_image_params.airflow_branch
ci_image_cache_dir.mkdir(parents=True, exist_ok=True)
touch_cache_file(f"built_{ci_image_params.python}",
root_dir=ci_image_cache_dir)
- calculate_md5_checksum_for_files(ci_image_params.md5sum_cache_dir,
update=True)
+ calculate_md5_checksum_for_files(
+ ci_image_params.md5sum_cache_dir,
+ update=True,
+
skip_provider_dependencies_check=ci_image_params.skip_provider_dependencies_check,
+ )
diff --git a/dev/breeze/src/airflow_breeze/utils/md5_build_check.py
b/dev/breeze/src/airflow_breeze/utils/md5_build_check.py
index a90238c9f4b..0283aa41f1a 100644
--- a/dev/breeze/src/airflow_breeze/utils/md5_build_check.py
+++ b/dev/breeze/src/airflow_breeze/utils/md5_build_check.py
@@ -78,6 +78,19 @@ def check_md5_sum_for_file(file_to_check: str,
md5sum_cache_dir: Path, update: b
return is_modified
+def calculate_ci_sources_hash() -> str:
+ """
+ Calculates aggregated hash of all the files that trigger CI image rebuild
when changed.
+
+ Only relative paths and file contents are hashed, so the result is stable
across
+ checkouts/worktrees of the same sources.
+ """
+ hash_md5 = hashlib.md5()
+ for file in FILES_FOR_REBUILD_CHECK:
+ hash_md5.update(f"{file}:{generate_md5(AIRFLOW_ROOT_PATH /
file)}\n".encode())
+ return hash_md5.hexdigest()
+
+
def calculate_md5_checksum_for_files(
md5sum_cache_dir: Path, update: bool = False,
skip_provider_dependencies_check: bool = False
) -> tuple[list[str], list[str]]:
diff --git a/dev/breeze/tests/test_ci_image_commands.py
b/dev/breeze/tests/test_ci_image_commands.py
new file mode 100644
index 00000000000..b32ef02919f
--- /dev/null
+++ b/dev/breeze/tests/test_ci_image_commands.py
@@ -0,0 +1,188 @@
+# 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 json
+from unittest import mock
+
+import pytest
+
+from airflow_breeze.commands.ci_image_commands import (
+ get_ci_image_sources_hash_label,
+ is_ci_image_built_from_current_sources,
+ rebuild_or_pull_ci_image_if_needed,
+ should_we_run_the_build,
+)
+from airflow_breeze.global_constants import CI_IMAGE_SOURCES_HASH_LABEL
+from airflow_breeze.params.build_ci_params import BuildCiParams
+from airflow_breeze.utils.md5_build_check import calculate_ci_sources_hash
+
+CI_IMAGE = "ghcr.io/apache/airflow/main/ci/python3.10"
+
+
+def test_calculate_ci_sources_hash_is_stable_across_checkouts(tmp_path,
monkeypatch):
+ watched_files = ["Dockerfile.ci", "scripts/docker/common.sh"]
+ for checkout in ("worktree-a", "worktree-b"):
+ root = tmp_path / checkout
+ (root / "scripts" / "docker").mkdir(parents=True)
+ (root / "Dockerfile.ci").write_text("FROM base")
+ (root / "scripts" / "docker" / "common.sh").write_text("echo common")
+
monkeypatch.setattr("airflow_breeze.utils.md5_build_check.FILES_FOR_REBUILD_CHECK",
watched_files)
+
monkeypatch.setattr("airflow_breeze.utils.md5_build_check.AIRFLOW_ROOT_PATH",
tmp_path / "worktree-a")
+ hash_of_first_checkout = calculate_ci_sources_hash()
+
monkeypatch.setattr("airflow_breeze.utils.md5_build_check.AIRFLOW_ROOT_PATH",
tmp_path / "worktree-b")
+ assert calculate_ci_sources_hash() == hash_of_first_checkout
+ (tmp_path / "worktree-b" / "Dockerfile.ci").write_text("FROM other")
+ assert calculate_ci_sources_hash() != hash_of_first_checkout
+
+
[email protected](
+ ("returncode", "stdout", "expected"),
+ [
+ pytest.param(0, json.dumps({CI_IMAGE_SOURCES_HASH_LABEL: "abc"}),
"abc", id="label-present"),
+ pytest.param(0, json.dumps({"other-label": "abc"}), None,
id="label-absent"),
+ pytest.param(0, "null", None, id="no-labels-at-all"),
+ pytest.param(0, "", None, id="empty-output"),
+ pytest.param(0, "not-json", None, id="invalid-json"),
+ pytest.param(1, "", None, id="image-missing"),
+ ],
+)
[email protected]("airflow_breeze.commands.ci_image_commands.run_command")
+def test_get_ci_image_sources_hash_label(mock_run_command, returncode, stdout,
expected):
+ mock_run_command.return_value = mock.MagicMock(returncode=returncode,
stdout=stdout)
+ assert get_ci_image_sources_hash_label(CI_IMAGE) == expected
+
+
[email protected](
+ ("image_hash", "current_hash", "expected"),
+ [
+ pytest.param("abc", "abc", True, id="match"),
+ pytest.param("abc", "def", False, id="mismatch"),
+ pytest.param(None, "abc", False, id="no-label"),
+ ],
+)
[email protected]("airflow_breeze.commands.ci_image_commands.calculate_ci_sources_hash")
[email protected]("airflow_breeze.commands.ci_image_commands.get_ci_image_sources_hash_label")
+def test_is_ci_image_built_from_current_sources(
+ mock_get_ci_image_sources_hash_label,
+ mock_calculate_ci_sources_hash,
+ image_hash,
+ current_hash,
+ expected,
+):
+ mock_get_ci_image_sources_hash_label.return_value = image_hash
+ mock_calculate_ci_sources_hash.return_value = current_hash
+ assert is_ci_image_built_from_current_sources(BuildCiParams()) is expected
+
+
[email protected]("airflow_breeze.commands.ci_image_commands.mark_image_as_rebuilt")
[email protected]("airflow_breeze.commands.ci_image_commands.is_ci_image_built_from_current_sources")
+def
test_should_we_run_the_build_skips_build_when_image_matches_current_sources(
+ mock_is_ci_image_built_from_current_sources, mock_mark_image_as_rebuilt
+):
+ mock_is_ci_image_built_from_current_sources.return_value = True
+ build_ci_params = BuildCiParams()
+ assert should_we_run_the_build(build_ci_params) is False
+
mock_mark_image_as_rebuilt.assert_called_once_with(ci_image_params=build_ci_params)
+
+
[email protected]("airflow_breeze.commands.ci_image_commands.md5sum_check_if_build_is_needed")
[email protected]("airflow_breeze.commands.ci_image_commands.mark_image_as_rebuilt")
[email protected]("airflow_breeze.commands.ci_image_commands.is_ci_image_built_from_current_sources")
+def
test_should_we_run_the_build_falls_back_to_md5_check_when_image_does_not_match(
+ mock_is_ci_image_built_from_current_sources,
+ mock_mark_image_as_rebuilt,
+ mock_md5sum_check_if_build_is_needed,
+):
+ mock_is_ci_image_built_from_current_sources.return_value = False
+ mock_md5sum_check_if_build_is_needed.return_value = False
+ assert should_we_run_the_build(BuildCiParams()) is False
+ mock_mark_image_as_rebuilt.assert_not_called()
+ mock_md5sum_check_if_build_is_needed.assert_called_once()
+
+
[email protected]("airflow_breeze.commands.ci_image_commands.run_build_ci_image")
[email protected]("airflow_breeze.commands.ci_image_commands.mark_image_as_rebuilt")
[email protected]("airflow_breeze.commands.ci_image_commands.is_ci_image_built_from_current_sources")
+def test_rebuild_or_pull_reuses_image_built_in_another_checkout(
+ mock_is_ci_image_built_from_current_sources,
+ mock_mark_image_as_rebuilt,
+ mock_run_build_ci_image,
+ tmp_path,
+ monkeypatch,
+):
+
monkeypatch.setattr("airflow_breeze.commands.ci_image_commands.BUILD_CACHE_PATH",
tmp_path)
+ mock_is_ci_image_built_from_current_sources.return_value = True
+ rebuild_or_pull_ci_image_if_needed(command_params=BuildCiParams())
+ mock_mark_image_as_rebuilt.assert_called_once()
+ mock_run_build_ci_image.assert_not_called()
+
+
[email protected]("airflow_breeze.commands.ci_image_commands.check_if_image_building_is_needed")
[email protected]("airflow_breeze.commands.ci_image_commands.run_build_ci_image")
[email protected]("airflow_breeze.commands.ci_image_commands.is_ci_image_built_from_current_sources")
+def test_rebuild_or_pull_forces_build_when_image_does_not_match_sources(
+ mock_is_ci_image_built_from_current_sources,
+ mock_run_build_ci_image,
+ mock_check_if_image_building_is_needed,
+ tmp_path,
+ monkeypatch,
+):
+
monkeypatch.setattr("airflow_breeze.commands.ci_image_commands.BUILD_CACHE_PATH",
tmp_path)
+ mock_is_ci_image_built_from_current_sources.return_value = False
+ mock_check_if_image_building_is_needed.return_value = True
+ mock_run_build_ci_image.return_value = (0, "built")
+ rebuild_or_pull_ci_image_if_needed(command_params=BuildCiParams())
+ assert
mock_check_if_image_building_is_needed.call_args.kwargs["ci_image_params"].force_build
is True
+ mock_run_build_ci_image.assert_called_once()
+
+
[email protected]("airflow_breeze.commands.ci_image_commands.check_if_image_building_is_needed")
[email protected]("airflow_breeze.commands.ci_image_commands.run_build_ci_image")
[email protected]("airflow_breeze.commands.ci_image_commands.is_ci_image_built_from_current_sources")
+def test_rebuild_or_pull_does_not_reuse_image_when_force_build_requested(
+ mock_is_ci_image_built_from_current_sources,
+ mock_run_build_ci_image,
+ mock_check_if_image_building_is_needed,
+ tmp_path,
+ monkeypatch,
+):
+
monkeypatch.setattr("airflow_breeze.commands.ci_image_commands.BUILD_CACHE_PATH",
tmp_path)
+ mock_check_if_image_building_is_needed.return_value = True
+ mock_run_build_ci_image.return_value = (0, "built")
+
rebuild_or_pull_ci_image_if_needed(command_params=BuildCiParams(force_build=True))
+ mock_is_ci_image_built_from_current_sources.assert_not_called()
+ mock_run_build_ci_image.assert_called_once()
+
+
[email protected]("airflow_breeze.commands.ci_image_commands.check_if_image_building_is_needed")
[email protected]("airflow_breeze.commands.ci_image_commands.is_ci_image_built_from_current_sources")
+def test_rebuild_or_pull_does_not_query_docker_when_marker_present(
+ mock_is_ci_image_built_from_current_sources,
+ mock_check_if_image_building_is_needed,
+ tmp_path,
+ monkeypatch,
+):
+
monkeypatch.setattr("airflow_breeze.commands.ci_image_commands.BUILD_CACHE_PATH",
tmp_path)
+ command_params = BuildCiParams()
+ marker = tmp_path / command_params.airflow_branch /
f".built_{command_params.python}"
+ marker.parent.mkdir(parents=True)
+ marker.touch()
+ mock_check_if_image_building_is_needed.return_value = False
+ rebuild_or_pull_ci_image_if_needed(command_params=command_params)
+ mock_is_ci_image_built_from_current_sources.assert_not_called()
diff --git a/dev/breeze/tests/test_docker_command_utils.py
b/dev/breeze/tests/test_docker_command_utils.py
index dc373cf8c40..d433ea7bb14 100644
--- a/dev/breeze/tests/test_docker_command_utils.py
+++ b/dev/breeze/tests/test_docker_command_utils.py
@@ -22,7 +22,13 @@ from unittest.mock import call
import pytest
-from airflow_breeze.global_constants import ALLOWED_POSTGRES_VERSIONS,
CURRENT_POSTGRES_VERSIONS
+from airflow_breeze.global_constants import (
+ ALLOWED_POSTGRES_VERSIONS,
+ CI_IMAGE_SOURCES_HASH_LABEL,
+ CURRENT_POSTGRES_VERSIONS,
+)
+from airflow_breeze.params.build_ci_params import BuildCiParams
+from airflow_breeze.params.build_prod_params import BuildProdParams
from airflow_breeze.utils.docker_command_utils import (
autodetect_docker_context,
bring_all_compose_projects_down,
@@ -32,6 +38,7 @@ from airflow_breeze.utils.docker_command_utils import (
enter_shell,
get_images_to_pull,
is_known_breeze_compose_project,
+ prepare_docker_build_command,
pull_images_with_retries,
)
@@ -553,3 +560,24 @@ def
test_pull_images_with_retries_does_not_pull_when_all_images_are_present(mock
assert pull_images_with_retries("breeze-test", env={},
skip_images={CI_IMAGE}) is True
assert not any(c.args[0][:2] == ["docker", "pull"] for c in
mock_run_command.call_args_list)
mock_sleep.assert_not_called()
+
+
[email protected]("airflow_breeze.utils.docker_command_utils.calculate_ci_sources_hash")
[email protected]("airflow_breeze.utils.docker_command_utils.check_if_buildx_plugin_installed")
+def test_prepare_docker_build_command_labels_ci_image_with_sources_hash(
+ mock_check_if_buildx_plugin_installed, mock_calculate_ci_sources_hash
+):
+ mock_check_if_buildx_plugin_installed.return_value = False
+ mock_calculate_ci_sources_hash.return_value = "hash-of-sources"
+ command = prepare_docker_build_command(BuildCiParams())
+ label_index = command.index("--label")
+ assert command[label_index + 1] ==
f"{CI_IMAGE_SOURCES_HASH_LABEL}=hash-of-sources"
+
+
[email protected]("airflow_breeze.utils.docker_command_utils.check_if_buildx_plugin_installed")
+def
test_prepare_docker_build_command_does_not_add_sources_hash_label_to_prod_image(
+ mock_check_if_buildx_plugin_installed,
+):
+ mock_check_if_buildx_plugin_installed.return_value = False
+ command = prepare_docker_build_command(BuildProdParams())
+ assert not any(flag.startswith(CI_IMAGE_SOURCES_HASH_LABEL) for flag in
command)