ci: migrate pytest + Docker to PHCC (retire HA core-checkout harness) (#3)

* openspec: propose migrate-ci-to-phcc (proposal + design)

Design-first per repo convention. Migrates pytest + Docker CI off the dead HA core-checkout harness to the PHCC-based local test suite that already passes locally (164). Implementation (tasks + code) held pending review of the design's open questions.

* ci: migrate pytest + Docker off HA core-checkout to PHCC

Implements openspec change migrate-ci-to-phcc.

- pytest.yaml: run the local PHCC suite (latest + dev matrix; dev is
  continue-on-error). Fixes the trigger (was push:[master] -> never ran on
  main; now main). Drops coverage (was computed but never uploaded). Recipe
  verified in a clean env locally: 164 passed, Python 3.13, latest PHCC.
- Delete dead core-checkout scaffolding: install_dependencies action,
  setup-dependencies, setup-symlinks, update-test-matrix.{py,yaml},
  test_dependencies.py, Dockerfile, docker-build.yml.
- setup-devcontainer: PHCC flow (uv sync --group test).
- tests/README.md: rewritten for 'uv run pytest'.
- CLAUDE.md: drop the 'Refresh test matrix' row.
- hacs.json 2024.12.0 -> 2026.1.0; README min-version line corrected (it
  wrongly claimed '2025.1.0 pinned in manifest.json' - manifest pins none).

* openspec: mark migrate-ci-to-phcc 4.3 done (CI verified green)
This commit is contained in:
Casey Romkes 2026-07-09 13:31:53 +02:00 • committed by GitHub
commit 027a6de184
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 202 additions and 577 deletions

View file

@ -1,45 +0,0 @@
#!/usr/bin/env bash
set -ex
cd "$(dirname "$0")/.."
# Remove mypy-dev from requirements_test.txt since the maintainer deletes old versions from PyPI.
# We'll install the latest version separately below.
# See: https://github.com/cdce8p/mypy-dev/issues/62
grep -v '^mypy-dev' core/requirements_test.txt > core/requirements_test.txt.tmp && mv core/requirements_test.txt.tmp core/requirements_test.txt
uv pip install -r core/requirements.txt
uv pip install -r core/requirements_test.txt
# HA 2026.4+ imports aiohasupervisor from tests/components/conftest.py
# but pins it in requirements_test_all.txt instead of requirements_test.txt.
aiohasupervisor_req=""
if [[ -f core/requirements_test_all.txt ]]; then
aiohasupervisor_req="$(grep -m1 '^aiohasupervisor' core/requirements_test_all.txt || true)"
fi
if [[ -n "${aiohasupervisor_req}" ]]; then
uv pip install "${aiohasupervisor_req}"
fi
# HA 2026.4+ validates service translations through translations/en.json.
# The core checkout keeps English source strings in strings.json, so seed en.json
# in the temporary CI checkout before tests load integrations.
for strings_file in core/homeassistant/components/*/strings.json; do
[[ -f "${strings_file}" ]] || continue
translations_dir="$(dirname "${strings_file}")/translations"
en_translation="${translations_dir}/en.json"
if [[ ! -f "${en_translation}" ]]; then
mkdir -p "${translations_dir}"
cp "${strings_file}" "${en_translation}"
fi
done
uv pip install -e core/
uv pip install ulid-transform # this is in Adaptive-lighting's manifest.json
uv pip install $(python3 test_dependencies.py)
# Install the latest mypy-dev (not pinned since old versions get deleted from PyPI)
uv pip install --upgrade mypy-dev
# Workaround for aiodns/pycares compatibility issue
# See: https://github.com/aio-libs/aiodns/issues/214
uv pip install --upgrade aiodns

View file

@ -2,20 +2,8 @@
set -ex
cd "$(dirname "$0")/.."
# Clone only if the folder doesn't exist
if [[ ! -d "core" ]]; then
git clone --depth 1 --branch dev https://github.com/home-assistant/core.git
fi
# Tests run via pytest-homeassistant-custom-component (PHCC); no Home Assistant
# core checkout is required. See tests/README.md.
uv sync --group test
pip install \
colorlog \
pip \
ruff \
uv
pip cache purge
uv venv --clear --python 3.14.2
./scripts/setup-dependencies
./scripts/setup-symlinks
uv run pre-commit install-hooks

View file

@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -ex
cd "$(dirname "$0")/.."
# Link custom components
cd core/homeassistant/components/
ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting
cd -
# Link tests
cd core/tests/components/
ln -fs ../../../tests/ adaptive_lighting
cd -

View file

@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""Update the pytest workflow matrix with latest HA Core versions.
This script fetches the latest Home Assistant Core release versions from GitHub
and updates the pytest workflow matrix to test against them.
Usage:
python scripts/update-test-matrix.py
"""
from __future__ import annotations
import json
import re
import urllib.request
from pathlib import Path
# Minimum HA Core version to include in the test matrix
# This should be the oldest version we want to support
MIN_VERSION = (2024, 12)
def get_ha_core_versions() -> list[str]:
"""Fetch latest stable HA Core versions from GitHub API."""
all_tags = []
page = 1
# Paginate through all tags to ensure we get older versions too
while True:
url = f"https://api.github.com/repos/home-assistant/core/tags?per_page=100&page={page}"
with urllib.request.urlopen(url) as response: # noqa: S310
tags = json.loads(response.read().decode())
if not tags:
break
all_tags.extend(tags)
# Check if we've gone far enough back
# Stop if we've found versions older than our minimum
oldest_in_page = None
for t in tags:
if re.match(r"^\d+\.\d+\.\d+$", t["name"]):
parts = t["name"].split(".")
year, month = int(parts[0]), int(parts[1])
if oldest_in_page is None or (year, month) < oldest_in_page:
oldest_in_page = (year, month)
if oldest_in_page and oldest_in_page < MIN_VERSION:
break
page += 1
if page > 10: # Safety limit
break
# Filter to stable releases only (no beta/rc)
stable_pattern = re.compile(r"^\d+\.\d+\.\d+$")
versions = [t["name"] for t in all_tags if stable_pattern.match(t["name"])]
# Group by minor version and get latest patch for each
latest: dict[str, str] = {}
for version in versions:
parts = version.split(".")
year, month = int(parts[0]), int(parts[1])
# Only include versions >= MIN_VERSION
if (year, month) >= MIN_VERSION:
minor_key = f"{parts[0]}.{parts[1]}"
# Keep the one with highest patch number
if minor_key not in latest:
latest[minor_key] = version
else:
current_patch = int(latest[minor_key].split(".")[2])
new_patch = int(parts[2])
if new_patch > current_patch:
latest[minor_key] = version
# Sort by version
return sorted(latest.values(), key=lambda v: [int(x) for x in v.split(".")])
def get_python_version(ha_version: str) -> str:
"""Determine Python version based on HA Core version."""
parts = ha_version.split(".")
year, month = int(parts[0]), int(parts[1])
# 2024.x and 2025.1 use Python 3.12.
# 2025.2 through 2026.2 use Python 3.13.
# 2026.3+ uses Python 3.14.
if year == 2024 or (year == 2025 and month == 1):
return "3.12"
if year > 2026 or (year == 2026 and month >= 3):
return "3.14.2"
return "3.13"
def generate_matrix_yaml(versions: list[str]) -> str:
"""Generate the YAML matrix include block."""
lines = []
for version in versions:
python_ver = get_python_version(version)
lines.append(f' - core-version: "{version}"')
lines.append(f' python-version: "{python_ver}"')
# Add dev version
lines.append(' - core-version: "dev"')
lines.append(' python-version: "3.14.2"')
return "\n".join(lines)
def update_workflow_file(workflow_path: Path, new_matrix: str) -> bool:
"""Update the workflow file with new matrix. Returns True if changed."""
content = workflow_path.read_text()
# Pattern to match the matrix include block
# Matches from "include:" to just before " steps:"
pattern = re.compile(
r"( include:\n)(.*?)( steps:)",
re.DOTALL,
)
def replacer(match: re.Match) -> str:
return f"{match.group(1)}{new_matrix}\n{match.group(3)}"
new_content = pattern.sub(replacer, content)
if new_content == content:
return False
workflow_path.write_text(new_content)
return True
def main() -> None:
"""Main entry point."""
print("Fetching latest HA Core versions...") # noqa: T201
versions = get_ha_core_versions()
print(f"Found {len(versions)} versions: {', '.join(versions)}") # noqa: T201
print("\nGenerating matrix...") # noqa: T201
matrix = generate_matrix_yaml(versions)
print(matrix) # noqa: T201
workflow_path = Path(__file__).parent.parent / ".github/workflows/pytest.yaml"
print(f"\nUpdating {workflow_path}...") # noqa: T201
if update_workflow_file(workflow_path, matrix):
print("Workflow updated successfully!") # noqa: T201
else:
print("No changes needed.") # noqa: T201
if __name__ == "__main__":
main()