diff --git a/.github/workflows/memory-impact.yml b/.github/workflows/memory-impact.yml new file mode 100644 index 0000000000..dff73e6cd7 --- /dev/null +++ b/.github/workflows/memory-impact.yml @@ -0,0 +1,150 @@ +--- +name: Memory Impact Analysis + +on: + pull_request: + paths: + - "esphome/components/**" + - "esphome/core/**" + +permissions: + contents: read + pull-requests: write + +env: + DEFAULT_PYTHON: "3.11" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + detect-single-component: + name: Detect single component change + runs-on: ubuntu-24.04 + outputs: + should_run: ${{ steps.detect.outputs.should_run }} + component: ${{ steps.detect.outputs.component }} + test_file: ${{ steps.detect.outputs.test_file }} + platform: ${{ steps.detect.outputs.platform }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install PyYAML + - name: Detect single component change + id: detect + run: | + python script/ci_memory_impact_detector.py + + build-target-branch: + name: Build target branch + runs-on: ubuntu-24.04 + needs: detect-single-component + if: needs.detect-single-component.outputs.should_run == 'true' + outputs: + ram_usage: ${{ steps.extract.outputs.ram_usage }} + flash_usage: ${{ steps.extract.outputs.flash_usage }} + steps: + - name: Check out target branch + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.base_ref }} + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Install ESPHome + run: | + pip install -e . + - name: Cache platformio + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.platformio + key: platformio-memory-${{ needs.detect-single-component.outputs.platform }}-${{ hashFiles('platformio.ini') }} + - name: Compile test configuration and extract memory usage + id: extract + run: | + component="${{ needs.detect-single-component.outputs.component }}" + platform="${{ needs.detect-single-component.outputs.platform }}" + test_file="${{ needs.detect-single-component.outputs.test_file }}" + + echo "Compiling $component for $platform using $test_file" + python script/test_build_components.py -e compile -c "$component" -t "$platform" --no-grouping 2>&1 | \ + python script/ci_memory_impact_extract.py --output-env + + build-pr-branch: + name: Build PR branch + runs-on: ubuntu-24.04 + needs: detect-single-component + if: needs.detect-single-component.outputs.should_run == 'true' + outputs: + ram_usage: ${{ steps.extract.outputs.ram_usage }} + flash_usage: ${{ steps.extract.outputs.flash_usage }} + steps: + - name: Check out PR branch + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Install ESPHome + run: | + pip install -e . + - name: Cache platformio + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.platformio + key: platformio-memory-${{ needs.detect-single-component.outputs.platform }}-${{ hashFiles('platformio.ini') }} + - name: Compile test configuration and extract memory usage + id: extract + run: | + component="${{ needs.detect-single-component.outputs.component }}" + platform="${{ needs.detect-single-component.outputs.platform }}" + test_file="${{ needs.detect-single-component.outputs.test_file }}" + + echo "Compiling $component for $platform using $test_file" + python script/test_build_components.py -e compile -c "$component" -t "$platform" --no-grouping 2>&1 | \ + python script/ci_memory_impact_extract.py --output-env + + comment-results: + name: Comment memory impact + runs-on: ubuntu-24.04 + needs: + - detect-single-component + - build-target-branch + - build-pr-branch + if: needs.detect-single-component.outputs.should_run == 'true' + steps: + - name: Check out code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Post or update PR comment + env: + GH_TOKEN: ${{ github.token }} + COMPONENT: ${{ needs.detect-single-component.outputs.component }} + PLATFORM: ${{ needs.detect-single-component.outputs.platform }} + TARGET_RAM: ${{ needs.build-target-branch.outputs.ram_usage }} + TARGET_FLASH: ${{ needs.build-target-branch.outputs.flash_usage }} + PR_RAM: ${{ needs.build-pr-branch.outputs.ram_usage }} + PR_FLASH: ${{ needs.build-pr-branch.outputs.flash_usage }} + run: | + python script/ci_memory_impact_comment.py \ + --pr-number "${{ github.event.pull_request.number }}" \ + --component "$COMPONENT" \ + --platform "$PLATFORM" \ + --target-ram "$TARGET_RAM" \ + --target-flash "$TARGET_FLASH" \ + --pr-ram "$PR_RAM" \ + --pr-flash "$PR_FLASH" diff --git a/script/ci_helpers.py b/script/ci_helpers.py new file mode 100755 index 0000000000..48b0e4bbfe --- /dev/null +++ b/script/ci_helpers.py @@ -0,0 +1,23 @@ +"""Common helper functions for CI scripts.""" + +from __future__ import annotations + +import os + + +def write_github_output(outputs: dict[str, str | int]) -> None: + """Write multiple outputs to GITHUB_OUTPUT or stdout. + + When running in GitHub Actions, writes to the GITHUB_OUTPUT file. + When running locally, writes to stdout for debugging. + + Args: + outputs: Dictionary of key-value pairs to write + """ + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a", encoding="utf-8") as f: + f.writelines(f"{key}={value}\n" for key, value in outputs.items()) + else: + for key, value in outputs.items(): + print(f"{key}={value}") diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py new file mode 100755 index 0000000000..69f703bd78 --- /dev/null +++ b/script/ci_memory_impact_comment.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Post or update a PR comment with memory impact analysis results. + +This script creates or updates a GitHub PR comment with memory usage changes. +It uses the GitHub CLI (gh) to manage comments and maintains a single comment +that gets updated on subsequent runs. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys + +# Comment marker to identify our memory impact comments +COMMENT_MARKER = "" + + +def format_bytes(bytes_value: int) -> str: + """Format bytes value with appropriate unit. + + Args: + bytes_value: Number of bytes + + Returns: + Formatted string (e.g., "1.5 KB", "256 bytes") + """ + if bytes_value < 1024: + return f"{bytes_value} bytes" + if bytes_value < 1024 * 1024: + return f"{bytes_value / 1024:.2f} KB" + return f"{bytes_value / (1024 * 1024):.2f} MB" + + +def format_change(before: int, after: int) -> str: + """Format memory change with delta and percentage. + + Args: + before: Memory usage before change + after: Memory usage after change + + Returns: + Formatted string with delta and percentage + """ + delta = after - before + percentage = 0.0 if before == 0 else (delta / before) * 100 + + # Format delta with sign + delta_str = f"+{format_bytes(delta)}" if delta >= 0 else format_bytes(delta) + + # Format percentage with sign + if percentage > 0: + pct_str = f"+{percentage:.2f}%" + elif percentage < 0: + pct_str = f"{percentage:.2f}%" + else: + pct_str = "0.00%" + + # Add emoji indicator + if delta > 0: + emoji = "📈" + elif delta < 0: + emoji = "📉" + else: + emoji = "➡️" + + return f"{emoji} {delta_str} ({pct_str})" + + +def create_comment_body( + component: str, + platform: str, + target_ram: int, + target_flash: int, + pr_ram: int, + pr_flash: int, +) -> str: + """Create the comment body with memory impact analysis. + + Args: + component: Component name + platform: Platform name + target_ram: RAM usage in target branch + target_flash: Flash usage in target branch + pr_ram: RAM usage in PR branch + pr_flash: Flash usage in PR branch + + Returns: + Formatted comment body + """ + ram_change = format_change(target_ram, pr_ram) + flash_change = format_change(target_flash, pr_flash) + + return f"""{COMMENT_MARKER} +## Memory Impact Analysis + +**Component:** `{component}` +**Platform:** `{platform}` + +| Metric | Target Branch | This PR | Change | +|--------|--------------|---------|--------| +| **RAM** | {format_bytes(target_ram)} | {format_bytes(pr_ram)} | {ram_change} | +| **Flash** | {format_bytes(target_flash)} | {format_bytes(pr_flash)} | {flash_change} | + +--- +*This analysis runs automatically when a single component changes. Memory usage is measured from a representative test configuration.* +""" + + +def find_existing_comment(pr_number: str) -> str | None: + """Find existing memory impact comment on the PR. + + Args: + pr_number: PR number + + Returns: + Comment ID if found, None otherwise + """ + try: + # List all comments on the PR + result = subprocess.run( + [ + "gh", + "pr", + "view", + pr_number, + "--json", + "comments", + "--jq", + ".comments[]", + ], + capture_output=True, + text=True, + check=True, + ) + + # Parse comments and look for our marker + for line in result.stdout.strip().split("\n"): + if not line: + continue + + try: + comment = json.loads(line) + if COMMENT_MARKER in comment.get("body", ""): + return str(comment["id"]) + except json.JSONDecodeError: + continue + + return None + + except subprocess.CalledProcessError as e: + print(f"Error finding existing comment: {e}", file=sys.stderr) + return None + + +def post_or_update_comment(pr_number: str, comment_body: str) -> bool: + """Post a new comment or update existing one. + + Args: + pr_number: PR number + comment_body: Comment body text + + Returns: + True if successful, False otherwise + """ + # Look for existing comment + existing_comment_id = find_existing_comment(pr_number) + + try: + if existing_comment_id: + # Update existing comment + print(f"Updating existing comment {existing_comment_id}", file=sys.stderr) + subprocess.run( + [ + "gh", + "api", + f"/repos/{{owner}}/{{repo}}/issues/comments/{existing_comment_id}", + "-X", + "PATCH", + "-f", + f"body={comment_body}", + ], + check=True, + capture_output=True, + ) + else: + # Post new comment + print("Posting new comment", file=sys.stderr) + subprocess.run( + ["gh", "pr", "comment", pr_number, "--body", comment_body], + check=True, + capture_output=True, + ) + + print("Comment posted/updated successfully", file=sys.stderr) + return True + + except subprocess.CalledProcessError as e: + print(f"Error posting/updating comment: {e}", file=sys.stderr) + if e.stderr: + print(f"stderr: {e.stderr.decode()}", file=sys.stderr) + return False + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Post or update PR comment with memory impact analysis" + ) + parser.add_argument("--pr-number", required=True, help="PR number") + parser.add_argument("--component", required=True, help="Component name") + parser.add_argument("--platform", required=True, help="Platform name") + parser.add_argument( + "--target-ram", type=int, required=True, help="Target branch RAM usage" + ) + parser.add_argument( + "--target-flash", type=int, required=True, help="Target branch flash usage" + ) + parser.add_argument("--pr-ram", type=int, required=True, help="PR branch RAM usage") + parser.add_argument( + "--pr-flash", type=int, required=True, help="PR branch flash usage" + ) + + args = parser.parse_args() + + # Create comment body + comment_body = create_comment_body( + component=args.component, + platform=args.platform, + target_ram=args.target_ram, + target_flash=args.target_flash, + pr_ram=args.pr_ram, + pr_flash=args.pr_flash, + ) + + # Post or update comment + success = post_or_update_comment(args.pr_number, comment_body) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/ci_memory_impact_detector.py b/script/ci_memory_impact_detector.py new file mode 100755 index 0000000000..8c3045ab00 --- /dev/null +++ b/script/ci_memory_impact_detector.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Detect if a PR changes exactly one component for memory impact analysis. + +This script is used by the CI workflow to determine if a PR should trigger +memory impact analysis. The analysis only runs when: +1. Exactly one component has changed (not counting core changes) +2. The component has at least one test configuration + +The script outputs GitHub Actions environment variables to control the workflow. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +# Add esphome to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# pylint: disable=wrong-import-position +from script.ci_helpers import write_github_output +from script.helpers import ESPHOME_COMPONENTS_PATH, changed_files + +# Platform preference order for memory impact analysis +# Ordered by production relevance and memory constraint importance +PLATFORM_PREFERENCE = [ + "esp32-idf", # Primary ESP32 IDF platform + "esp32-c3-idf", # ESP32-C3 IDF + "esp32-c6-idf", # ESP32-C6 IDF + "esp32-s2-idf", # ESP32-S2 IDF + "esp32-s3-idf", # ESP32-S3 IDF + "esp32-c2-idf", # ESP32-C2 IDF + "esp32-c5-idf", # ESP32-C5 IDF + "esp32-h2-idf", # ESP32-H2 IDF + "esp32-p4-idf", # ESP32-P4 IDF + "esp8266-ard", # ESP8266 Arduino (memory constrained) + "esp32-ard", # ESP32 Arduino + "esp32-c3-ard", # ESP32-C3 Arduino + "esp32-s2-ard", # ESP32-S2 Arduino + "esp32-s3-ard", # ESP32-S3 Arduino + "bk72xx-ard", # BK72xx Arduino + "rp2040-ard", # RP2040 Arduino + "nrf52-adafruit", # nRF52 Adafruit + "host", # Host platform (development/testing) +] + + +def find_test_for_component(component: str) -> tuple[str | None, str | None]: + """Find a test configuration for the given component. + + Prefers platforms based on PLATFORM_PREFERENCE order. + + Args: + component: Component name + + Returns: + Tuple of (test_file_name, platform) or (None, None) if no test found + """ + tests_dir = Path(__file__).parent.parent / "tests" / "components" / component + + if not tests_dir.exists(): + return None, None + + # Look for test files + test_files = list(tests_dir.glob("test.*.yaml")) + if not test_files: + return None, None + + # Try each preferred platform in order + for preferred_platform in PLATFORM_PREFERENCE: + for test_file in test_files: + parts = test_file.stem.split(".") + if len(parts) >= 2: + platform = parts[1] + if platform == preferred_platform: + return test_file.name, platform + + # Fall back to first test file + test_file = test_files[0] + parts = test_file.stem.split(".") + platform = parts[1] if len(parts) >= 2 else "esp32-idf" + return test_file.name, platform + + +def detect_single_component_change() -> None: + """Detect if exactly one component changed and output GitHub Actions variables.""" + files = changed_files() + + # Find all changed components (excluding core) + changed_components = set() + + for file in files: + if file.startswith(ESPHOME_COMPONENTS_PATH): + parts = file.split("/") + if len(parts) >= 3: + component = parts[2] + # Skip base bus components as they're used across many builds + if component not in ["i2c", "spi", "uart", "modbus"]: + changed_components.add(component) + + # Only proceed if exactly one component changed + if len(changed_components) != 1: + print( + f"Found {len(changed_components)} component(s) changed, skipping memory analysis" + ) + write_github_output({"should_run": "false"}) + return + + component = list(changed_components)[0] + print(f"Detected single component change: {component}") + + # Find a test configuration for this component + test_file, platform = find_test_for_component(component) + + if not test_file: + print(f"No test configuration found for {component}, skipping memory analysis") + write_github_output({"should_run": "false"}) + return + + print(f"Found test: {test_file} for platform: {platform}") + print("Memory impact analysis will run") + + write_github_output( + { + "should_run": "true", + "component": component, + "test_file": test_file, + "platform": platform, + } + ) + + +if __name__ == "__main__": + detect_single_component_change() diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py new file mode 100755 index 0000000000..9ddd39096f --- /dev/null +++ b/script/ci_memory_impact_extract.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Extract memory usage statistics from ESPHome build output. + +This script parses the PlatformIO build output to extract RAM and flash +usage statistics for a compiled component. It's used by the CI workflow to +compare memory usage between branches. + +The script reads compile output from stdin and looks for the standard +PlatformIO output format: + RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) + Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + +# Add esphome to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# pylint: disable=wrong-import-position +from script.ci_helpers import write_github_output + + +def extract_from_compile_output(output_text: str) -> tuple[int | None, int | None]: + """Extract memory usage from PlatformIO compile output. + + Looks for lines like: + RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) + Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) + + Args: + output_text: Compile output text + + Returns: + Tuple of (ram_bytes, flash_bytes) or (None, None) if not found + """ + ram_match = re.search( + r"RAM:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes", output_text + ) + flash_match = re.search( + r"Flash:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes", output_text + ) + + if ram_match and flash_match: + return int(ram_match.group(1)), int(flash_match.group(1)) + + return None, None + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Extract memory usage from ESPHome build output" + ) + parser.add_argument( + "--output-env", + action="store_true", + help="Output to GITHUB_OUTPUT environment file", + ) + + args = parser.parse_args() + + # Read compile output from stdin + compile_output = sys.stdin.read() + + # Extract memory usage + ram_bytes, flash_bytes = extract_from_compile_output(compile_output) + + if ram_bytes is None or flash_bytes is None: + print("Failed to extract memory usage from compile output", file=sys.stderr) + print("Expected lines like:", file=sys.stderr) + print( + " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)", + file=sys.stderr, + ) + print( + " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)", + file=sys.stderr, + ) + return 1 + + print(f"RAM: {ram_bytes} bytes", file=sys.stderr) + print(f"Flash: {flash_bytes} bytes", file=sys.stderr) + + if args.output_env: + # Output to GitHub Actions + write_github_output( + { + "ram_usage": ram_bytes, + "flash_usage": flash_bytes, + } + ) + else: + print(f"{ram_bytes},{flash_bytes}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())