Add ESPHOME_PCH_STRICT and enforce it in the esp8266/esp32/rp2 image compile tests

This commit is contained in:
J. Nick Koston
2026-08-26 22:29:53 -05:00
parent 7449623022
commit 264ccc0989
8 changed files with 116 additions and 2 deletions
+12
View File
@@ -201,6 +201,17 @@ jobs:
- ln882x-arduino
- nrf52
- host
# Fail the job if the precompiled header silently degrades on the
# platforms where it must work (libretiny needs a toolchain bump)
include:
- id: esp8266-arduino
pch_strict: "1"
- id: esp32-idf-esp-idf
pch_strict: "1"
- id: esp32-arduino-esp-idf
pch_strict: "1"
- id: rp2040-arduino
pch_strict: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
@@ -212,6 +223,7 @@ jobs:
- name: Compile ${{ matrix.id }}
run: |
docker run --rm \
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '0' }}" \
-v "${{ github.workspace }}/docker/test_configs:/config" \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+3
View File
@@ -39,6 +39,7 @@ from esphome.build_helpers.pch import (
PCH_HEADER_NAME,
mark_pch_emitted,
pch_checksum,
pch_degraded,
pch_enabled,
pch_header_text,
)
@@ -1230,6 +1231,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
"A -include in build_flags prevents the precompiled header from "
"loading; compiling without it"
)
pch_degraded("a user -include precedes the pch")
elif pch_enabled():
# C++ src edges swap the force-includes for one precompiled prefix
# header (same content plus defines.h); C/assembly keep srcflags
@@ -1263,6 +1265,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
_LOGGER.warning(
"Could not establish the pch identity; compiling without it: %s", err
)
pch_degraded(f"identity unknown: {err}")
else:
_LOGGER.info(
"Compiling with a precompiled header "
+19
View File
@@ -102,6 +102,19 @@ def pch_enabled() -> bool:
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def pch_strict() -> bool:
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal."""
return parse_enable_env("ESPHOME_PCH_STRICT") is True
def pch_degraded(reason: str) -> None:
"""Every degrade path funnels through here; strict mode raises."""
if pch_strict():
from esphome.core import EsphomeError
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
def ccache_pch_env() -> dict[str, str]:
"""Settings ccache needs to cache compiles that consume the .gch;
empty unless this build actually emitted one. User-set values win.
@@ -333,6 +346,7 @@ def prepare_pch(
if cmd_and_dir is None:
# Freshness cannot be validated; a leftover .gch must not be consumed
discard_pch(build_dir)
pch_degraded("no usable compile command")
return
cmd, cmd_dir = cmd_and_dir
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
@@ -359,6 +373,7 @@ def prepare_pch(
"Could not establish the pch identity; compiling without it: %s", err
)
discard_pch(build_dir)
pch_degraded(f"identity unknown: {err}")
return
if gch.is_file() and _read_stamp(sum_path) == checksum:
_log_pch_in_use()
@@ -369,6 +384,7 @@ def prepare_pch(
"Precompiled header disabled after an earlier failure; delete %s to retry",
failed_marker,
)
pch_degraded("earlier failure latched")
return
_log_pch_in_use()
try:
@@ -400,6 +416,7 @@ def prepare_pch(
# Transient (timeout, spawn/IO): warn and retry next build, no marker
_LOGGER.warning("Precompiled header compile did not run: %s", err)
discard_pch(build_dir)
pch_degraded(f"compile did not run: {err}")
return
if error is not None:
_LOGGER.warning(
@@ -410,10 +427,12 @@ def prepare_pch(
discard_pch(build_dir)
if any(m in error for m in _TRANSIENT_ERRORS):
# Resource exhaustion clears on its own; retry next build
pch_degraded(f"transient compile failure: {error[:200]}")
return
# Skip retries until a header/flag/backend-identity/command change
failed_marker.write_text(checksum + "\n", encoding="utf-8")
os.utime(header)
pch_degraded(f"compile failed: {error[:200]}")
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
+12 -2
View File
@@ -158,7 +158,7 @@ def _read_stamp(path: Path) -> str:
return ""
def _setup_pch() -> None:
def _setup_pch() -> bool | None:
if projenv is None:
# Expected under -t nobuild; anything else must leave a trail
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
@@ -355,10 +355,20 @@ def _setup_pch() -> None:
)
projenv["ENV"].update(ccache_updates) # noqa: F821
print("ESPHome: Compiling with precompiled header")
return True
_strict = os.environ.get("ESPHOME_PCH_STRICT", "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
try:
_setup_pch()
if not _setup_pch() and _strict:
raise RuntimeError("ESPHOME_PCH_STRICT: precompiled header was not used")
except Exception: # noqa: BLE001 -- a speedup must never break the build
if _strict:
raise
print("ESPHome: pch internal error; compiling without it")
traceback.print_exc()
@@ -1842,3 +1842,17 @@ def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None:
(CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text()
)
assert sums[0] == sums[1]
def test_write_project_pch_strict_raises_on_skip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
paths = _make_framework(tmp_path)
_set_flags(
"-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h"
)
with pytest.raises(EsphomeError, match="precedes the pch"):
_write_ninja(paths, ccache="/usr/bin/ccache")
+19
View File
@@ -1078,3 +1078,22 @@ def test_prepare_pch_transient_compiler_failure_does_not_latch(
prepare_pch()
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
assert len(calls) == 2
def test_prepare_pch_strict_raises_on_missing_db(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_PCH_STRICT turns the silent skip into a failure."""
from esphome.build_gen.espidf import prepare_pch
from esphome.core import EsphomeError
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
dev = _make_pch_device(tmp_path, "dev_st")
(dev / "build" / "compile_commands.json").unlink()
CORE.build_path = dev
with (
patch.object(CORE, "name", "test"),
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
pytest.raises(EsphomeError, match="no usable compile command"),
):
prepare_pch()
@@ -216,3 +216,29 @@ def test_ccache_pch_env_warns_on_falsy_extsum(
env = pch.ccache_pch_env()
assert "CCACHE_PCH_EXTSUM" not in env
assert "disables pch caching" in caplog.text
@pytest.mark.parametrize(
("value", "expected"),
[(None, False), ("0", False), ("1", True), ("true", True)],
)
def test_pch_strict(
value: str | None, expected: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
if value is None:
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
else:
monkeypatch.setenv("ESPHOME_PCH_STRICT", value)
assert pch.pch_strict() is expected
def test_pch_degraded_raises_only_in_strict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from esphome.core import EsphomeError
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
pch.pch_degraded("reason")
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
with pytest.raises(EsphomeError, match="reason"):
pch.pch_degraded("reason")
@@ -458,3 +458,14 @@ def test_pch_script_unreadable_local_header_skips_pch(
assert not (proj / "esphome_pch.h.gch.sum").exists()
assert scons_env.prepended == []
assert "skipping precompiled header" in capsys.readouterr().out
def test_pch_script_strict_raises_when_pch_not_used(tmp_path: Path) -> None:
"""ESPHOME_PCH_STRICT fails the build instead of degrading."""
with pytest.raises(Exception, match="ESPHOME_PCH_STRICT|boom"):
_run_script(tmp_path, fail=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
def test_pch_script_strict_passes_on_success(tmp_path: Path) -> None:
scons_env = _run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"})
assert scons_env.prepended