[ci] Fix memory impact build selecting unbuildable platform (#16788)

This commit is contained in:
J. Nick Koston
2026-06-03 22:30:36 -05:00
committed by GitHub
parent d47f6b896e
commit 3e562b9267
4 changed files with 171 additions and 74 deletions

View File

@@ -70,6 +70,7 @@ from helpers import (
get_changed_components, get_changed_components,
get_component_from_path, get_component_from_path,
get_component_test_files, get_component_test_files,
get_component_test_platforms,
get_components_with_dependencies, get_components_with_dependencies,
get_cpp_changed_components, get_cpp_changed_components,
get_fixture_to_test_files, get_fixture_to_test_files,
@@ -77,7 +78,6 @@ from helpers import (
get_target_branch, get_target_branch,
git_ls_files, git_ls_files,
is_validate_only_file, is_validate_only_file,
parse_test_filename,
root_path, root_path,
) )
from split_components_for_ci import create_intelligent_batches from split_components_for_ci import create_intelligent_batches
@@ -169,24 +169,6 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch
MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform
MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical
# Platform-specific components that can only be built on their respective platforms
# These components contain platform-specific code and cannot be cross-compiled
# Regular components (wifi, logger, api, etc.) are cross-platform and not listed here
PLATFORM_SPECIFIC_COMPONENTS = frozenset(
{
"esp32", # ESP32 platform implementation
"esp8266", # ESP8266 platform implementation
"rp2040", # Raspberry Pi Pico / RP2040 platform implementation
"libretiny", # LibreTiny base platform implementation
"bk72xx", # Beken BK72xx platform implementation (uses LibreTiny)
"rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny)
"ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny)
"host", # Host platform (for testing on development machine)
"nrf52", # Nordic nRF52 platform implementation (uses Zephyr)
"zephyr", # Zephyr RTOS platform implementation
}
)
# Platform preference order for memory impact analysis # Platform preference order for memory impact analysis
# This order is used when no platform-specific hints are detected from filenames # This order is used when no platform-specific hints are detected from filenames
# Priority rationale: # Priority rationale:
@@ -1006,23 +988,24 @@ def detect_memory_impact_config(
] = {} # Track which platforms each component supports ] = {} # Track which platforms each component supports
for component in sorted(changed_component_set): for component in sorted(changed_component_set):
# Look for test files on preferred platforms # Discover the platforms this component has BASE tests for, using the
test_files = get_component_test_files(component, all_variants=True) # same logic as the build runner (get_component_test_platforms wraps the
if not test_files: # shared get_component_test_files + parse_test_filename helpers). Base
continue # tests only: the memory impact CI build runs test_build_components.py
# with --base-only, which compiles base test.<platform>.yaml files but
# Check if component has tests for any preferred platform # never variant test-<variant>.<platform>.yaml files. Counting
available_platforms = [ # variant-only platforms here would let us select a platform the build
platform # then has nothing to compile for, producing no memory output.
for test_file in test_files available_platforms = {
if (platform := parse_test_filename(test_file)[1]) != "all" Platform(platform)
and platform in MEMORY_IMPACT_PLATFORM_PREFERENCE for platform in get_component_test_platforms(component)
] if platform in MEMORY_IMPACT_PLATFORM_PREFERENCE
}
if not available_platforms: if not available_platforms:
continue continue
component_platforms_map[component] = set(available_platforms) component_platforms_map[component] = available_platforms
components_with_tests.append(component) components_with_tests.append(component)
# If no components have tests, don't run memory impact # If no components have tests, don't run memory impact
@@ -1084,20 +1067,57 @@ def detect_memory_impact_config(
) )
platform = _select_platform_by_count(platform_counts) platform = _select_platform_by_count(platform_counts)
# Filter out platform-specific components that are incompatible with selected platform # Keep only components that have a base test on the selected platform.
# Platform components (esp32, esp8266, rp2040, etc.) can only build on their own platform # The merged build runs test_build_components.py -t <platform> --base-only,
# Other components (wifi, logger, etc.) are cross-platform and can build anywhere # so a component without a base test.<platform>.yaml compiles nothing and
compatible_components = [ # contributes no memory output. This also covers platform-specific
component # components (esp32, esp8266, etc.), which only have tests on their own
for component in components_with_tests # platform. When components don't share a common platform we build the
if component not in PLATFORM_SPECIFIC_COMPONENTS # largest subset that does, dropping the rest.
or platform in component_platforms_map.get(component, set()) def components_supporting(target: Platform) -> list[str]:
] return [
component
for component in components_with_tests
if target in component_platforms_map.get(component, set())
]
# If no components are compatible with the selected platform, don't run compatible_components = components_supporting(platform)
# A platform hint (or no-common-platform fallback) can pick a platform that
# no changed component actually has a base test for, leaving nothing to
# build. In that case fall back to the platform supported by the most
# components. component_platforms_map is non-empty (guarded above) and every
# value is a non-empty platform set (components with no supported platform
# are skipped at discovery), so this always yields a buildable platform with
# at least one compatible component.
if not compatible_components:
platform = _select_platform_by_count(
Counter(
p for platforms in component_platforms_map.values() for p in platforms
)
)
compatible_components = components_supporting(platform)
# Defensive backstop: unreachable given the invariant above, but guards
# against a future regression in platform selection silently passing an
# empty component list to the build.
if not compatible_components: if not compatible_components:
return {"should_run": "false"} return {"should_run": "false"}
# Log components dropped because they lack a base test on the selected
# platform so partial-subset builds are visible in CI logs.
dropped_components = [
component
for component in components_with_tests
if component not in compatible_components
]
if dropped_components:
print(
f"Memory impact: Dropping components without a base test on "
f"{platform}: {dropped_components}",
file=sys.stderr,
)
# Debug output # Debug output
print("Memory impact analysis:", file=sys.stderr) print("Memory impact analysis:", file=sys.stderr)
print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr) print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr)

View File

@@ -149,6 +149,31 @@ def get_component_test_files(
return files return files
def get_component_test_platforms(component: str, *, base_only: bool = True) -> set[str]:
"""Return the set of platforms a component has compilable test files for.
Uses the same discovery as ``test_build_components.py`` (``get_component_test_files``
+ ``parse_test_filename``) so callers agree with what the build runner would
actually compile. With ``base_only=True`` (the default, matching the
memory-impact build's ``--base-only``), only base ``test.<platform>.yaml``
files are considered; variant ``test-<variant>.<platform>.yaml`` files are
excluded. The ``"all"`` platform sentinel is excluded.
Args:
component: Component name (e.g. "wifi")
base_only: If True, only consider base test files (default).
Returns:
Set of platform identifiers (e.g. {"esp32-idf", "esp8266-ard"}).
"""
platforms: set[str] = set()
for test_file in get_component_test_files(component, all_variants=not base_only):
platform = parse_test_filename(test_file)[1]
if platform != "all":
platforms.add(platform)
return platforms
def is_validate_only_file(test_file: Path) -> bool: def is_validate_only_file(test_file: Path) -> bool:
"""Return True if the given path is a config-only validate file. """Return True if the given path is a config-only validate file.

View File

@@ -42,6 +42,7 @@ from script.analyze_component_buses import (
from script.helpers import ( from script.helpers import (
get_component_test_files, get_component_test_files,
is_validate_only_file, is_validate_only_file,
parse_test_filename,
split_conflicting_groups, split_conflicting_groups,
) )
from script.merge_component_configs import merge_component_configs from script.merge_component_configs import merge_component_configs
@@ -122,21 +123,6 @@ def find_component_tests(
return dict(component_tests) return dict(component_tests)
def parse_test_filename(test_file: Path) -> tuple[str, str]:
"""Parse test filename to extract test name and platform.
Args:
test_file: Path to test file
Returns:
Tuple of (test_name, platform)
"""
parts = test_file.stem.split(".")
if len(parts) == 2:
return parts[0], parts[1] # test, platform
return parts[0], "all"
def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]: def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]:
"""Get all platform base files. """Get all platform base files.

View File

@@ -1426,7 +1426,15 @@ def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) ->
@pytest.mark.usefixtures("mock_target_branch_dev") @pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
"""Test memory impact detection when components have no common platform.""" """Test memory impact detection when components have no common platform.
The merged build runs with --base-only on a single platform, so components
without a base test on the selected platform cannot be built and must be
dropped. We build the largest subset that shares the selected platform
rather than handing the runner components it has nothing to compile for
(which previously produced "0 passed, 0 failed" and a failed memory
extraction).
"""
# Create test directory structure # Create test directory structure
tests_dir = tmp_path / "tests" / "components" tests_dir = tmp_path / "tests" / "components"
@@ -1453,12 +1461,70 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
result = determine_jobs.detect_memory_impact_config() result = determine_jobs.detect_memory_impact_config()
# Should pick the most frequently supported platform # No common platform: pick the most preferred platform among those supported
# (esp8266-ard outranks esp32-idf in the preference list) and build only the
# components that have a base test on it. wifi (esp32-idf only) is dropped.
assert result["should_run"] == "true" assert result["should_run"] == "true"
assert set(result["components"]) == {"wifi", "logger"} assert result["platform"] == "esp8266-ard"
# When no common platform, picks most commonly supported assert result["components"] == ["logger"]
# esp8266-ard is preferred over esp32-idf in the preference list assert result["use_merged_config"] == "true"
assert result["platform"] in ["esp32-idf", "esp8266-ard"]
def test_detect_memory_impact_config_variant_only_platform_excluded(
tmp_path: Path,
) -> None:
"""Regression test for the const + shelly_dimmer memory-impact failure.
Reproduces https://github.com/esphome/esphome/actions/runs/26746938473
where a platform hint selected esp32-idf even though neither changed
component had a base test.esp32-idf.yaml. The merged --base-only build then
found nothing to compile ("0 passed, 0 failed") and memory extraction
failed. Also covers a component whose only esp32-idf test is a *variant*
(test-*.esp32-idf.yaml): --base-only never compiles variants, so it must
not count toward platform availability.
"""
tests_dir = tmp_path / "tests" / "components"
# const: base test only on esp32-s3-idf
const_dir = tests_dir / "const"
const_dir.mkdir(parents=True)
(const_dir / "test.esp32-s3-idf.yaml").write_text("test: const")
# shelly_dimmer: base test only on esp8266-ard
shelly_dir = tests_dir / "shelly_dimmer"
shelly_dir.mkdir(parents=True)
(shelly_dir / "test.esp8266-ard.yaml").write_text("test: shelly_dimmer")
# mdns: only a VARIANT test on esp32-idf (no base test.esp32-idf.yaml).
# --base-only would never build it, so it must be excluded entirely.
mdns_dir = tests_dir / "mdns"
mdns_dir.mkdir(parents=True)
(mdns_dir / "test-min.esp32-idf.yaml").write_text("test: mdns")
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
patch.object(determine_jobs, "changed_files") as mock_changed_files,
):
# The "_esp32" filename yields an esp32-idf platform hint, reproducing
# the original bug where the hint picked a platform no component could
# build as a base test.
mock_changed_files.return_value = [
"esphome/components/const/const.cpp",
"esphome/components/shelly_dimmer/shelly_dimmer_esp32.cpp",
"esphome/components/mdns/mdns.cpp",
]
result = determine_jobs.detect_memory_impact_config()
# The esp32-idf hint is unbuildable (no base test), so we fall back to the
# platform supported by the most components, broken by preference order:
# esp8266-ard (shelly_dimmer) outranks esp32-s3-idf (const). Only the
# component with a base test on the selected platform is returned; the
# variant-only mdns is excluded entirely.
assert result["should_run"] == "true"
assert result["platform"] == "esp8266-ard"
assert result["components"] == ["shelly_dimmer"]
assert result["use_merged_config"] == "true" assert result["use_merged_config"] == "true"
@@ -1545,12 +1611,16 @@ def test_detect_memory_impact_config_includes_base_bus_components(
@pytest.mark.usefixtures("mock_target_branch_dev") @pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: def test_detect_memory_impact_config_variant_only_components_skipped(
"""Test memory impact detection for components with only variant test files. tmp_path: Path,
) -> None:
"""Components with only variant tests are skipped for memory impact.
This verifies that memory impact analysis works correctly for components like Components like improv_serial and ethernet only have variant test files
improv_serial, ethernet, mdns, etc. which only have variant test files (test-*.yaml), no base test.<platform>.yaml. The memory-impact build runs
(test-*.yaml) instead of base test files (test.*.yaml). test_build_components.py with --base-only, which never compiles variants, so
these components have nothing buildable and must not be selected. Selecting
them previously produced "0 passed, 0 failed" and a failed memory extraction.
""" """
# Create test directory structure # Create test directory structure
tests_dir = tmp_path / "tests" / "components" tests_dir = tmp_path / "tests" / "components"
@@ -1581,12 +1651,8 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
result = determine_jobs.detect_memory_impact_config() result = determine_jobs.detect_memory_impact_config()
# Should detect both components even though they only have variant tests # Neither component has a base test, so nothing is buildable under --base-only
assert result["should_run"] == "true" assert result["should_run"] == "false"
assert set(result["components"]) == {"improv_serial", "ethernet"}
# Both components support esp32-idf
assert result["platform"] == "esp32-idf"
assert result["use_merged_config"] == "true"
# Tests for clang-tidy split mode logic # Tests for clang-tidy split mode logic