fix(addon): restore Supervisor app management #5737
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |