1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-16 15:37:51 +02:00

[ci] Automatic Flash/RAM impact analysis

This commit is contained in:
J. Nick Koston 2025-10-14 13:05:02 -10:00
parent 72ec9b672e
commit c4eeed7f7e
No known key found for this signature in database
5 changed files with 655 additions and 0 deletions

150
.github/workflows/memory-impact.yml vendored Normal file
View File

@ -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"

23
script/ci_helpers.py Executable file
View File

@ -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}")

View File

@ -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 = "<!-- esphome-memory-impact-analysis -->"
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())

View File

@ -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()

View File

@ -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())