Skip to content

feat(screenshot): report theme changes instead of writing them back #5685

feat(screenshot): report theme changes instead of writing them back

feat(screenshot): report theme changes instead of writing them back #5685

Workflow file for this run

name: PR Validation Pipeline
permissions:
contents: read
on:
pull_request:
branches: [ master ]
workflow_dispatch:
env:
PYTHON_VERSION: "3.13"
UV_CACHE_DIR: /tmp/.uv-cache
# renovate: datasource=docker depName=ghcr.io/home-assistant/home-assistant
HA_IMAGE_GHCR: "ghcr.io/home-assistant/home-assistant:2026.8.0"
# Disable Testcontainers' Ryuk reaper: it has been reported to leave
# zombie containers on GHA runners (see #366, Ilya0527 2026-05-18).
# The E2E fixture in tests/src/e2e/conftest.py relies instead on the
# enclosing ``with container:`` context manager — Python guarantees
# its ``__exit__`` fires on both normal and exception flows, so the
# Ryuk safety net is not needed for deterministic cleanup. Refs #366.
TESTCONTAINERS_RYUK_DISABLED: "true"
jobs:
# Decide whether the heavy E2E Validation lanes need to run. Default is RUN;
# we skip ONLY when every changed file is pure docs/website (top-level *.md,
# docs/**, site/**). Add-on + bake-input dirs are baked/shipped, so their docs
# DO matter and always count as code. On non-PR events (manual dispatch)
# always run. Consumed by e2e-validation's `if:` below — a job-level skip
# reports Success to a required check (a workflow path-skip would not).
# PR-time guard for the release-cycle version invariant (AGENTS.md "Version
# bumps ride the stable release cycle"): a PR that changes the component
# while the manifest version EQUALS the mirror's latest stable release would
# merge changes onto an already-shipped version — the mirror's stable tag
# step skips existing tags and v<VER>-dev.N pre-release tags sort BELOW the
# equal stable tag, so the changes would silently reach nobody. Level with
# stable means: bump once to open the pending version. The gate fires only
# on a provable component diff and fails open (with a warning) when the base
# or the mirror API cannot be read — the mirror sync's stable tag step
# carries the loud release-time backstop for what this check cannot see
# (stable shipping while the PR sits idle re-runs no PR checks).
component-version-gate:
name: Component Version Gate
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Fail component changes riding an already-released version
env:
BASE_REF: ${{ github.base_ref }}
GH_TOKEN: ${{ github.token }}
run: |
if ! git fetch --depth=1 origin "$BASE_REF" \
|| ! base_sha=$(git rev-parse "origin/$BASE_REF"); then
echo "::warning::could not resolve the base ref - skipping the version gate (the release-time backstop still applies)"
exit 0
fi
if git diff --quiet "$base_sha" HEAD -- custom_components/ha_mcp_tools; then
echo "no component changes - gate not applicable"
exit 0
fi
VER=$(python3 -c "import json; print(json.load(open('custom_components/ha_mcp_tools/manifest.json'))['version'])")
if ! latest=$(gh api repos/homeassistant-ai/ha-mcp-integration/releases/latest --jq .tag_name); then
echo "::warning::could not read the mirror's latest stable release - skipping the version gate (the release-time backstop still applies)"
exit 0
fi
stable="${latest#v}"
echo "component changed; manifest=${VER} released-stable=${stable}"
# Strict version comparison, not equality: a version BEHIND the
# released stable (a stale tree or bad merge resurrecting an old
# manifest) must fail here too, not slip through to a late
# release-time failure. sort -V gives semver ordering; both inputs
# come from our own manifest / release automation.
if [ "$VER" = "$stable" ]; then
verdict=equal
elif [ "$(printf '%s\n%s\n' "$stable" "$VER" | sort -V | tail -1)" = "$VER" ]; then
verdict=ahead
else
verdict=behind
fi
case "$verdict" in
ahead)
echo "version gate ok: pending ${VER} is strictly ahead of released ${stable}" ;;
equal)
echo "::error::This PR changes custom_components/ha_mcp_tools but keeps the component version at ${VER}, which is already released as the mirror's latest stable - merged this way the changes never ship (the stable tag step skips existing tags; dev pre-release tags sort below the equal stable). Master is level with stable: bump manifest.json + const.py (and the parity-test literal) once to open the next pending version, per AGENTS.md 'Version bumps ride the stable release cycle'."
exit 1 ;;
behind)
echo "::error::This PR changes custom_components/ha_mcp_tools with version ${VER}, which is BEHIND the mirror's released stable ${stable} - a stale tree or bad merge resurrected an old version. Restore the version to lead the released stable (per AGENTS.md 'Version bumps ride the stable release cycle')."
exit 1 ;;
esac
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
run: ${{ steps.filter.outputs.run }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Classify changed files
id: filter
env:
BASE_REF: ${{ github.base_ref }}
run: |
# Fail-closed gate: default to RUNNING the suite; skip ONLY when we can
# prove the PR changed nothing but docs/website. Any classifier failure
# (non-PR event, base fetch / rev-parse / diff error, empty diff) leaves
# run=true — branch protection treats a skipped REQUIRED job as passing,
# so a fail-open classifier could let code merge untested.
run=true
if [ "${{ github.event_name }}" = "pull_request" ] \
&& git fetch --depth=1 origin "$BASE_REF" \
&& base_sha=$(git rev-parse "origin/$BASE_REF") \
&& changed=$(git diff --name-only --diff-filter=ACMRD "$base_sha" HEAD) \
&& [ -n "$changed" ]; then
echo "Changed files:"; printf '%s\n' "$changed" | sed 's/^/ /'
run=false
while IFS= read -r f; do
[ -z "$f" ] && continue
case "$f" in
# Bake inputs / addon dirs are baked into the qcow2 / shipped —
# their docs change what the suite tests, so they count as code.
homeassistant-addon/*|homeassistant-addon-dev/*|homeassistant-addon-webhook-proxy/*|custom_components/ha_mcp_tools/*|tests/haos_image_build/*|tests/initial_test_state/*)
run=true; break ;;
# Pure docs / website do not, on their own, warrant the suite.
*.md|*.mdx|docs/*|site/*)
continue ;;
# Anything else (code, tests, config, workflows, lockfile, ...) runs.
*)
run=true; break ;;
esac
done <<< "$changed"
else
echo "Non-PR event or unresolved diff — running suite (fail-closed)."
fi
echo "run=$run"
echo "run=$run" >> "$GITHUB_OUTPUT"
e2e-validation-gate:
# Single, ALWAYS-reported required context for branch protection. The
# e2e-validation job above is a MATRIX job, and a job-level `if:`-skip on a
# matrix does NOT emit the per-variant contexts ("E2E Validation (os)") —
# they report nothing and would wedge a required check on a docs-only PR
# (actions/runner#952). So the REQUIRED check is THIS gate, not the matrix
# variants: it always runs, passes when the suite was skipped (docs/website)
# or every variant succeeded, and fails if any variant failed.
name: E2E Validation Gate
needs: [changes, e2e-validation, e2e-validation-embedded, e2e-validation-update-path]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require E2E success unless skipped
run: |
# Only a classifier that SUCCEEDED and said `false` out loud can
# authorize the skip. A non-success classifier leaves `run` empty, and
# passing on that would make a runner hiccup indistinguishable from a
# docs-only PR — the lanes above run in that case, so fall through and
# judge them on their results. Keep this branch equivalent to the lane
# `if:` predicates above; if they diverge, this gate demands success
# from a lane that never ran.
classifier_result="${{ needs.changes.result }}"
if [ "$classifier_result" != "success" ]; then
echo "::warning::change classifier did not succeed (result=$classifier_result); judging the lanes on their own results."
elif [ "${{ needs.changes.outputs.run }}" = "false" ]; then
echo "Docs/website-only PR — E2E Validation skipped; gate passes."
exit 0
fi
container_result="${{ needs.e2e-validation.result }}"
embedded_result="${{ needs.e2e-validation-embedded.result }}"
update_path_result="${{ needs.e2e-validation-update-path.result }}"
echo "e2e-validation result: $container_result"
echo "e2e-validation-embedded result: $embedded_result"
echo "e2e-validation-update-path result: $update_path_result"
# The container-backend matrix, the in-process MCP server lane, AND
# the update-path lane must all pass. Any one failing wedges the
# required gate.
if [ "$container_result" != "success" ] || [ "$embedded_result" != "success" ] || [ "$update_path_result" != "success" ]; then
# `skipped` here is NOT the docs-only skip (that exited 0 above): the
# lanes never started, e.g. the run was cancelled.
case "$container_result$embedded_result$update_path_result" in
*skipped*) echo "::error::E2E Validation lanes did not start (results: container=$container_result, embedded=$embedded_result, update-path=$update_path_result) — the run may have been cancelled." ;;
*) echo "::error::E2E Validation did not succeed (container=$container_result, embedded=$embedded_result, update-path=$update_path_result)." ;;
esac
exit 1
fi
lockfile:
name: Check uv.lock
runs-on: ubuntu-latest
container:
# renovate: datasource=docker depName=ghcr.io/astral-sh/uv
image: ghcr.io/astral-sh/uv:0.11.33-python3.13-trixie-slim
# 5 (was 2): this job now also fetches HA's constraints file over the
# network. The fetch is budgeted to fail fast (3 x 10s + short backoff)
# so its own distinct exit code is always reachable, and the headroom
# here keeps a slow network from turning that clean failure into an
# opaque runner kill.
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Verify uv.lock is in sync with pyproject.toml
run: uv lock --check
- name: Check dependency alignment with HA core constraints
# Mechanical link to HA's own package_constraints.txt (#2135/#2146):
# where HA pins a shared dependency exactly, ha-mcp must admit the pin;
# where HA is loose, ha-mcp must not pin exactly (an exact pin forces
# pip to replace the image-shipped copy in place — the torn-install
# window). Checked against the same HA version the e2e lanes run
# (HA_IMAGE_GHCR, renovate-managed), so drift on either side fails the
# PR that introduces it. Rules unit-tested offline in
# tests/src/unit/test_ha_constraint_alignment.py.
#
# Exit codes are load-bearing: 1 is a real drift violation and fails
# the job; 78 means HA's constraints file was UNREACHABLE, which is an
# outage on someone else's server and must not red-light every open PR
# — it warns and passes, the same fail-open the component-version gate
# above uses when the mirror API is unreadable. Every other code
# (including argparse's 2 for a usage error, uv's own 2, and a 4xx
# meaning the constraints file moved) fails the job: those are our
# problems, and failing open on them would leave this gate green
# forever while it checked nothing.
run: |
set +e
uv run --no-project --with packaging python \
scripts/check_ha_constraint_alignment.py \
--ha-version "${HA_IMAGE_GHCR##*:}"
status=$?
set -e
if [ "$status" -eq 78 ]; then
echo "::warning::could not reach HA's package_constraints.txt - skipping the alignment check (the e2e no-stomp guard still covers the real install)"
exit 0
fi
exit "$status"
docs-size:
name: Docs Size Check
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Check AGENTS.md size
run: |
size=$(LC_ALL=C.UTF-8 wc -m < AGENTS.md)
if [ "$size" -gt 40000 ]; then
echo "::warning file=AGENTS.md::AGENTS.md is ${size} chars (>40k). Claude Code will show a startup performance warning."
fi
# Anchors the docs-site accessibility work (#1574/#1595): astro check (types
# + a11y diagnostics), eslint-plugin-astro + jsx-a11y, and an axe-core audit
# over the built pages — all blocking. The baseline is clean, so any
# regression fails the PR.
site-checks:
name: Site (astro check + eslint a11y)
runs-on: ubuntu-latest
timeout-minutes: 8
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
# Corepack is bundled only through Node 24; `corepack npm ci` enforces packageManager.
node-version: "22"
cache: npm
cache-dependency-path: site/package-lock.json
- name: Install dependencies
run: corepack npm ci
- name: Astro type & diagnostics check
run: npm run check
- name: ESLint (eslint-plugin-astro + jsx-a11y)
run: npm run lint
- name: Build site
run: npm run build
- name: Accessibility audit (axe-core)
# Blocking: the baseline is clean (0 violations across all pages), so a
# new axe-core violation fails the PR rather than slipping through.
run: npm run audit:a11y
lint:
name: Ruff Lint
runs-on: ubuntu-latest
container:
# renovate: datasource=docker depName=ghcr.io/astral-sh/uv
image: ghcr.io/astral-sh/uv:0.11.33-python3.13-trixie-slim
timeout-minutes: 5
steps:
- name: Install git for changed-files diff
run: apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install dependencies
run: uv sync --dev
- name: Run ruff check
run: uv run ruff check src/ tests/ custom_components/ homeassistant-addon/ homeassistant-addon-webhook-proxy/ homeassistant-addon-webhook-proxy-dev/ packaging/ scripts/
- name: Run ruff format check on changed Python files
run: |
# actions/checkout sets safe.directory in a temp HOME that this
# step doesn't inherit; re-add it here so git operations succeed
# when the checkout dir is owned by a different UID than the
# container's root.
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Changed-files-only gate per the maintainer call on issue #1318:
# new edits must be formatted; pre-existing format-debt on
# untouched files stays grandfathered. Avoids the disruption of a
# repo-wide sweep while still preventing fresh debt from landing.
if [ -z "${GITHUB_BASE_REF}" ]; then
echo "No PR base ref (workflow_dispatch); skipping format check."
exit 0
fi
git fetch --depth=1 origin "${GITHUB_BASE_REF}"
base_sha=$(git rev-parse "origin/${GITHUB_BASE_REF}")
changed_py=$(git diff --name-only --diff-filter=ACMR "${base_sha}" -- '*.py')
if [ -z "$changed_py" ]; then
echo "No Python files changed; skipping ruff format check."
exit 0
fi
count=$(echo "$changed_py" | wc -l)
echo "Checking ruff format on $count changed Python files:"
echo "$changed_py" | sed 's/^/ /'
echo "$changed_py" | xargs uv run ruff format --check
ast-grep:
name: AST Lint
runs-on: ubuntu-latest
container:
# renovate: datasource=docker depName=ghcr.io/astral-sh/uv
image: ghcr.io/astral-sh/uv:0.11.33-python3.13-trixie-slim
timeout-minutes: 2
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install dependencies
run: uv sync --dev
- name: Run ast-grep
run: uv run ast-grep scan
mypy:
name: Mypy Type Check
runs-on: ubuntu-latest
container:
# renovate: datasource=docker depName=ghcr.io/astral-sh/uv
image: ghcr.io/astral-sh/uv:0.11.33-python3.13-trixie-slim
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install dependencies
run: uv sync --dev
- name: Run mypy
run: |
uv run mypy src/ custom_components/ homeassistant-addon/ scripts/
uv run mypy homeassistant-addon-webhook-proxy/
uv run mypy homeassistant-addon-webhook-proxy-dev/
# Fast unit tests (no Docker, no HA instance needed)
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
container:
# renovate: datasource=docker depName=ghcr.io/astral-sh/uv
image: ghcr.io/astral-sh/uv:0.11.33-python3.13-trixie-slim
# Bumped 5 → 8 for the issue #2027 regression tests, which spawn real
# subprocesses — including a full stdio server session — costing tens
# of seconds on a slow runner.
timeout-minutes: 8
steps:
- name: Restore apt download cache
# Cache apt downloads used by this containerized job.
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: /var/cache/apt/archives
key: apt-trixie-git-v1
- name: Install git
run: |
# Keep cached .debs after install so the cache stays warm for
# future runs (default Debian behaviour deletes them).
rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
> /etc/apt/apt.conf.d/keep-downloaded
apt-get update -qq
apt-get install -y -qq git >/dev/null 2>&1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
submodules: true
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
# Corepack is bundled only through Node 24; `corepack npm ci` enforces packageManager.
node-version: "24"
cache: npm
cache-dependency-path: tests/js/package-lock.json
- name: Install Python dependencies
run: uv sync --all-extras --dev
- name: Install JS test dependencies (jsdom, esbuild)
# `npm ci` enforces package-lock.json — same reproducibility
# discipline as uv.lock on the Python side. setup-node caches npm's
# download cache; node_modules is intentionally recreated by npm ci.
run: cd tests/js && corepack npm ci
- name: Run unit tests
run: uv run pytest tests/src/unit/ -n auto --tb=short -v
- name: Run add-on structure tests
# tests/addon/ validates add-on packaging / config.yaml structure (e.g.
# the backup-filename-safe name guard, #1707) — previously these ran
# nowhere in CI. Excludes test_addon_startup.py, which spins up real
# Docker containers (testcontainers) this lint/unit runner doesn't have.
run: >-
uv run pytest tests/addon/ -n auto --tb=short -v
--ignore=tests/addon/test_addon_startup.py
# Comprehensive E2E validation for all PRs
e2e-validation:
name: E2E Validation (${{ matrix.os }})
needs: changes
# A skipped job reports Success to the required status check, so a
# docs/website-only PR doesn't wedge the merge waiting on these lanes.
# Skipping requires a classifier that succeeded and said `false` out loud;
# anything else (a failed classifier leaves `run` empty) runs this lane.
# Keep this predicate equivalent to the skip branch in e2e-validation-gate:
# if they diverge, the gate demands success from a lane that never ran.
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
runs-on: ${{ matrix.os }}
# 20 (was 15): headroom for the in-process-server e2e test that runs on
# THIS lane (workflows/embedded/test_embedded_server.py) - its first
# bring-up pip-installs the wheel's dependency tree inside the test's HA
# container. Not about the separate e2e-validation-embedded job.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest # Linux x64
- ubuntu-24.04-arm # Linux ARM64
include:
- os: ubuntu-latest
pytest_workers: 3
- os: ubuntu-24.04-arm
pytest_workers: 4
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
submodules: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
with:
cache-binary: true
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "latest"
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Cache HA Docker image
id: cache-ha-image
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: /tmp/ha-image.tar
key: ha-image-${{ env.HA_IMAGE_GHCR }}-${{ runner.arch }}
- name: Load cached HA image
if: steps.cache-ha-image.outputs.cache-hit == 'true'
run: docker load -i /tmp/ha-image.tar
- name: Pull HA image (GHCR → Docker Hub fallback)
if: steps.cache-ha-image.outputs.cache-hit != 'true'
run: |
HA_VERSION="${HA_IMAGE_GHCR##*:}"
HA_IMAGE_DOCKERHUB="homeassistant/home-assistant:${HA_VERSION}"
for registry in "$HA_IMAGE_GHCR" "$HA_IMAGE_DOCKERHUB"; do
echo "Trying $registry..."
if docker pull "$registry"; then
if [ "$registry" != "$HA_IMAGE_GHCR" ]; then
docker tag "$registry" "$HA_IMAGE_GHCR"
fi
docker save "$HA_IMAGE_GHCR" -o /tmp/ha-image.tar
echo "Pulled and cached from $registry"
exit 0
fi
echo "Failed to pull from $registry, trying next..."
sleep 15
done
echo "All registries failed" && exit 1
- name: Run full E2E test suite
run: |
echo "🚀 Running full E2E test suite with ${{ matrix.pytest_workers }} workers..."
uv run pytest tests/src/e2e/ \
-n${{ matrix.pytest_workers }} \
--dist loadscope \
--tb=short \
-v
echo "✅ Full E2E test suite passed"
env:
HAMCP_ENV_FILE: "tests/.env.test"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# In-process MCP server backend (#1527): the SAME full E2E suite, but the
# server-under-test is the in-process MCP server (ha_mcp_tools entry) running inside
# each worker's testcontainer (E2E_BACKEND=embedded), driven over its ingress
# webhook. Matrixed x64 + arm64 to mirror e2e-validation (the container matrix);
# ``needs.e2e-validation-embedded.result`` aggregates the variants, so the
# e2e-validation-gate below fails if EITHER arch fails. Gated the same way as
# e2e-validation: skips on docs/website-only PRs (needs.changes) and folds into
# the required gate. -n2/-n3 (vs the container matrix's 3/4) because each worker
# additionally builds a ha-mcp wheel and its container preinstalls the fastmcp
# dependency tree before HA boots, so a lower worker count keeps peak memory +
# concurrent-pip pressure in check (arm gets +1 like the container matrix does);
# the larger timeout covers that per-worker preinstall window.
e2e-validation-embedded:
name: E2E Validation (embedded, ${{ matrix.os }})
needs: changes
# A skipped job reports Success, so a docs/website-only PR doesn't wedge the
# gate waiting on this lane (mirrors e2e-validation).
# Skipping requires a classifier that succeeded and said `false` out loud;
# anything else (a failed classifier leaves `run` empty) runs this lane.
# Keep this predicate equivalent to the skip branch in e2e-validation-gate:
# if they diverge, the gate demands success from a lane that never ran.
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest # Linux x64
- ubuntu-24.04-arm # Linux ARM64
include:
- os: ubuntu-latest
pytest_workers: 2
- os: ubuntu-24.04-arm
pytest_workers: 3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
submodules: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
with:
cache-binary: true
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "latest"
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Cache HA Docker image
id: cache-ha-image
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: /tmp/ha-image.tar
key: ha-image-${{ env.HA_IMAGE_GHCR }}-${{ runner.arch }}
- name: Load cached HA image
if: steps.cache-ha-image.outputs.cache-hit == 'true'
run: docker load -i /tmp/ha-image.tar
- name: Pull HA image (GHCR → Docker Hub fallback)
if: steps.cache-ha-image.outputs.cache-hit != 'true'
run: |
HA_VERSION="${HA_IMAGE_GHCR##*:}"
HA_IMAGE_DOCKERHUB="homeassistant/home-assistant:${HA_VERSION}"
for registry in "$HA_IMAGE_GHCR" "$HA_IMAGE_DOCKERHUB"; do
echo "Trying $registry..."
if docker pull "$registry"; then
if [ "$registry" != "$HA_IMAGE_GHCR" ]; then
docker tag "$registry" "$HA_IMAGE_GHCR"
fi
docker save "$HA_IMAGE_GHCR" -o /tmp/ha-image.tar
echo "Pulled and cached from $registry"
exit 0
fi
echo "Failed to pull from $registry, trying next..."
sleep 15
done
echo "All registries failed" && exit 1
- name: Run full E2E test suite (embedded backend)
run: |
echo "🚀 Running full E2E suite through the in-process MCP server (embedded backend) with ${{ matrix.pytest_workers }} workers..."
uv run pytest tests/src/e2e/ \
-n${{ matrix.pytest_workers }} \
--dist loadscope \
--tb=short \
-v
echo "✅ Full E2E test suite (embedded backend) passed"
env:
E2E_BACKEND: "embedded"
HAMCP_ENV_FILE: "tests/.env.test"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Update-path e2e lane (#1783/#1785): reproduces the in-place server update
# that broke on real installs. Each run parametrizes over the component source
# — the released ``stable`` git tag (proves a new server release won't break
# existing installs) AND this PR's working tree (proves the PR's own update
# machinery still works) — with the ha-mcp server installed from PyPI stable in
# both. The test then drives the in-process update that installs THIS PR's
# freshly built wheel over the running server — exercising the real
# reload/purge/restart path — and asserts the server survives. A single
# ubuntu-latest lane runs both scenarios serially (no -n), not the parallel
# suite. fetch-depth 0 is REQUIRED — the default shallow checkout omits the
# ``stable`` tag the stable scenario checks out from. Gated like the other e2e
# lanes: skips on docs/website-only PRs (needs.changes) and folds into
# e2e-validation-gate.
e2e-validation-update-path:
name: E2E Validation (update path)
needs: changes
# A skipped job reports Success, so a docs/website-only PR doesn't wedge the
# gate waiting on this lane (mirrors e2e-validation).
# Skipping requires a classifier that succeeded and said `false` out loud;
# anything else (a failed classifier leaves `run` empty) runs this lane.
# Keep this predicate equivalent to the skip branch in e2e-validation-gate:
# if they diverge, the gate demands success from a lane that never ran.
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
runs-on: ubuntu-latest
# 30m: each of the two scenarios (component@stable and component@working-tree)
# does a first bring-up that installs ha-mcp plus the full fastmcp dependency
# tree from PyPI stable before the in-process update swaps in the PR wheel. The
# two containers run sequentially in this one job (observed ~50s per scenario),
# so 30m still fits comfortably.
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
submodules: true
# fetch-depth 0: the update-path scenario checks the custom component
# out at the released ``stable`` git tag, which the default shallow
# checkout does not fetch.
fetch-depth: 0
- name: Verify stable tag is present
run: |
if ! git rev-parse --verify refs/tags/stable >/dev/null 2>&1; then
echo "::error::The 'stable' git tag is missing — the update-path lane checks the released component out from it. Ensure checkout uses fetch-depth: 0."
exit 1
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
with:
cache-binary: true
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "latest"
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Cache HA Docker image
id: cache-ha-image
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: /tmp/ha-image.tar
key: ha-image-${{ env.HA_IMAGE_GHCR }}-${{ runner.arch }}
- name: Load cached HA image
if: steps.cache-ha-image.outputs.cache-hit == 'true'
run: docker load -i /tmp/ha-image.tar
- name: Pull HA image (GHCR → Docker Hub fallback)
if: steps.cache-ha-image.outputs.cache-hit != 'true'
run: |
HA_VERSION="${HA_IMAGE_GHCR##*:}"
HA_IMAGE_DOCKERHUB="homeassistant/home-assistant:${HA_VERSION}"
for registry in "$HA_IMAGE_GHCR" "$HA_IMAGE_DOCKERHUB"; do
echo "Trying $registry..."
if docker pull "$registry"; then
if [ "$registry" != "$HA_IMAGE_GHCR" ]; then
docker tag "$registry" "$HA_IMAGE_GHCR"
fi
docker save "$HA_IMAGE_GHCR" -o /tmp/ha-image.tar
echo "Pulled and cached from $registry"
exit 0
fi
echo "Failed to pull from $registry, trying next..."
sleep 15
done
echo "All registries failed" && exit 1
- name: Run update-path e2e test
# E2E_BACKEND deliberately UNSET (container backend): the test drives its
# own dedicated container end to end and never touches the session
# backend, but conftest's autouse session fixture boots one regardless —
# the default container backend boots it cheaply, while
# E2E_BACKEND=embedded would add a wheel build + in-container preinstall
# of the whole fastmcp tree that nothing in this lane uses.
run: |
echo "🚀 Running the update-path e2e lane (released component + PyPI server → PR wheel)..."
uv run pytest tests/src/e2e/workflows/embedded/test_embedded_update_path.py \
-m update_path \
--tb=short \
-v
echo "✅ Update-path e2e lane passed"
env:
E2E_UPDATE_PATH: "1"
HAMCP_ENV_FILE: "tests/.env.test"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Docker and Add-on validation
docker-validation:
name: Docker & Add-on Validation
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
submodules: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
with:
cache-binary: true
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "latest"
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install test dependencies
run: uv sync --dev
- name: Run add-on tests
run: uv run pytest tests/addon/ --ignore=tests/addon/test_skills_config.py --tb=short -v
- name: Validate docker-compose configuration
run: uv run pytest tests/test_docker/test_docker_compose.py -v
- name: Build and test standalone Docker image
run: uv run pytest tests/test_docker/test_docker_build.py -v