diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 8193fa9050..b8f324784d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -457,9 +457,15 @@ def should_run_device_builder(branch: str | None = None) -> bool: """Determine if downstream esphome/device-builder tests should run. device-builder imports esphome as a library, so whenever the importable - Python surface or the runtime dependencies change we re-run its test - suite against the PR's code to catch breakage we'd otherwise only see - after a release. + Python surface, the runtime dependencies, or any non-C++ file packaged + with esphome (pyproject.toml has ``include-package-data = true``, so + things like esphome/idf_component.yml ship and can affect installs) + changes we re-run its test suite against the PR's code to catch + breakage we'd otherwise only see after a release. + + Skipped on beta/release branches: those branches typically lag behind + device-builder@main, so a new device-builder API dependency would + falsely fail the run without reflecting any problem in the PR itself. Args: branch: Branch to compare against. If None, uses default. @@ -467,11 +473,22 @@ def should_run_device_builder(branch: str | None = None) -> bool: Returns: True if the device-builder downstream tests should run, False otherwise. """ + target_branch = get_target_branch() + if target_branch and ( + target_branch.startswith("release") or target_branch.startswith("beta") + ): + return False + for file in changed_files(branch): - if file.startswith("esphome/") and file.endswith(PYTHON_FILE_EXTENSIONS): - return True if file in DEVICE_BUILDER_TRIGGER_FILES: return True + # Anything under esphome/ that isn't C++ source can change the + # importable / packaged surface device-builder consumes + # (Python sources, packaged YAML/JSON like idf_component.yml, + # etc.). C++ files only affect compiled firmware, not the + # Python install device-builder pulls in. + if file.startswith("esphome/") and not file.endswith(CPP_FILE_EXTENSIONS): + return True return False diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 75a6d42f8e..cc795bc553 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -762,17 +762,27 @@ def test_should_run_import_time_with_branch() -> None: # Runtime dependency changes trigger (["requirements.txt"], True), (["pyproject.toml"], True), + # Non-C++ files packaged with esphome trigger -- device-builder + # picks them up because esphome's pyproject sets + # include-package-data = true. + (["esphome/idf_component.yml"], True), + (["esphome/dashboard/templates/index.html"], True), + (["esphome/components/api/api_pb2_service.json"], True), # Mixed: any triggering file is enough (["docs/README.md", "esphome/config.py"], True), # Dev/test-only dependency changes don't trigger device-builder # (they don't affect the importable surface device-builder uses) (["requirements_dev.txt"], False), (["requirements_test.txt"], False), - # Python files outside esphome/ don't trigger + # Files outside esphome/ don't trigger (["script/some_other_script.py"], False), (["tests/script/test_determine_jobs.py"], False), - # Non-Python changes don't trigger + # C++ files under esphome/ don't trigger -- they only affect + # compiled firmware, not the Python install device-builder pulls in. (["esphome/core/component.cpp"], False), + (["esphome/core/component.h"], False), + (["esphome/components/wifi/wifi_component.cpp"], False), + # Files outside esphome/ entirely (["tests/components/wifi/test.esp32-idf.yaml"], False), (["README.md"], False), ([], False), @@ -781,20 +791,42 @@ def test_should_run_import_time_with_branch() -> None: def test_should_run_device_builder( changed_files: list[str], expected_result: bool ) -> None: - """Test should_run_device_builder function.""" - with patch.object(determine_jobs, "changed_files", return_value=changed_files): + """Test should_run_device_builder function (non-beta/release target).""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + # Mock target branch to "dev" so the beta/release skip is bypassed + # for these per-file behavior checks. + patch.object(determine_jobs, "get_target_branch", return_value="dev"), + ): result = determine_jobs.should_run_device_builder() assert result == expected_result def test_should_run_device_builder_with_branch() -> None: """Test should_run_device_builder with branch argument.""" - with patch.object(determine_jobs, "changed_files") as mock_changed: + with ( + patch.object(determine_jobs, "changed_files") as mock_changed, + patch.object(determine_jobs, "get_target_branch", return_value="dev"), + ): mock_changed.return_value = [] determine_jobs.should_run_device_builder("release") mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize("target_branch", ["beta", "release", "release-2026.5"]) +def test_should_run_device_builder_skips_beta_release(target_branch: str) -> None: + """Beta/release target branches skip device-builder (lag behind device-builder@main).""" + with ( + patch.object(determine_jobs, "get_target_branch", return_value=target_branch), + patch.object(determine_jobs, "changed_files") as mock_changed, + ): + # Even with a triggering file present, the target-branch guard wins. + mock_changed.return_value = ["esphome/__main__.py"] + assert determine_jobs.should_run_device_builder() is False + # changed_files shouldn't even be consulted -- the guard short-circuits. + mock_changed.assert_not_called() + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [