home-assistant/core

10 workflows · maturity 83% · 12 patterns · GitHub ↗

Security 68.33/100

Security dimensions

permissions
25
security scan
8.3
supply chain
20
secret handling
15
harden runner
0

Tools: github/codeql-action/analyze, github/codeql-action/init

Workflows (10)

builder matrix perms .github/workflows/builder.yml
Triggers
workflow_dispatch, release, schedule
Runs on
ubuntu-latest, ${{ matrix.os }}, ${{ matrix.runs-on }}, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
init, build_base, build_machine, publish_ha, publish_container, build_python, hassfest-image
Matrix
arch, include, include.arch, include.machine, include.os, include.runs-on, machine, registry→ ${{ fromJson(needs.init.outputs.architectures) }}, aarch64, amd64, docker.io/homeassistant, generic-x86-64, ghcr.io/home-assistant, green, khadas-vim3, odroid-c2, odroid-c4, odroid-m1, odroid-n2, qemuarm-64, qemux86-64, raspberrypi3-64, raspberrypi4-64, raspberrypi5-64, ubuntu-24.04, ubuntu-24.04-arm, yellow
Actions
home-assistant/actions/helpers/info, home-assistant/actions/helpers/version, home-assistant/actions/helpers/verify-version, dawidd6/action-download-artifact, dawidd6/action-download-artifact, home-assistant/builder/actions/build-image, home-assistant/builder/actions/build-image, home-assistant/actions/helpers/git-init, home-assistant/actions/helpers/version-push, home-assistant/actions/helpers/version-push, sigstore/cosign-installer, docker/login-action, docker/login-action, docker/metadata-action, docker/setup-buildx-action, pypa/gh-action-pypi-publish, docker/login-action, docker/build-push-action, docker/build-push-action, actions/attest
Commands
  • if [ -n "$(find homeassistant/components/*/translations -type f)" ]; then echo "Translations files are checked in, please remove the following files:" find homeassistant/components/*/translations -type f exit 1 fi
  • python3 -m script.translations download
  • find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
  • python3 -m pip install "$(grep '^uv' < requirements.txt)" uv pip install packaging tomli uv pip install . python3 script/version_bump.py nightly --set-nightly-version "${VERSION}" if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}" frontend_version="${BASH_REMATCH[1]}" yq \ --inplace e -o json \ '.requirements = ["home-assistant-frontend=="+env(frontend_version)]' \ homeassistant/components/frontend/manifest.json sed -i "s|home-assistant-frontend==.*|home-assistant-frontend==${BASH_REMATCH[1]}|" \ homeassistant/package_constraints.txt sed -i "s|home-assistant-frontend==.*||" requirements_all.txt fi if [[ "$(ls home_assistant_intents*.whl)" =~ ^home_assistant_intents-(.*)-py3-none-any.whl$ ]]; then echo "Found intents wheel, setting version to: ${BASH_REMATCH[1]}" yq \ --inplace e -o json \ 'del(.requirements[] | select(contains("home-assistant-intents")))' \ homeassistant/components/conversation/manifest.json intents_version="${BASH_REMATCH[1]}" yq \ --inplace e -o json \ '.requirements += ["home-assistant-intents=="+env(intents_version)]' \ homeassistant/components/conversation/manifest.json sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \ homeassistant/package_constraints.txt sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt fi
  • tar xvf translations.tar.gz rm translations.tar.gz
  • echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
  • if [[ "${VERSION}" =~ d ]]; then echo "extra_tags=dev" >> "$GITHUB_OUTPUT" elif [[ "${VERSION}" =~ b ]]; then echo "extra_tags=beta" >> "$GITHUB_OUTPUT" else echo "extra_tags=stable" >> "$GITHUB_OUTPUT" fi
  • ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]') for arch in $ARCHS; do echo "Verifying ${arch} image signature..." cosign verify \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp https://github.com/home-assistant/core/.* \ "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}" done echo "✓ All images verified successfully"
View raw YAML
name: Build images

# yamllint disable-line rule:truthy
on:
  workflow_dispatch:
  release:
    types: ["published"]
  schedule:
    - cron: "0 2 * * *"

env:
  BUILD_TYPE: core
  PIP_TIMEOUT: 60
  UV_HTTP_TIMEOUT: 60
  UV_SYSTEM_PYTHON: "true"
  # Base image version from https://github.com/home-assistant/docker
  BASE_IMAGE_VERSION: "2026.01.0"
  ARCHITECTURES: '["amd64", "aarch64"]'

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  init:
    name: Initialize build
    if: github.repository_owner == 'home-assistant'
    runs-on: ubuntu-latest
    permissions:
      contents: read # To check out the repository
    outputs:
      version: ${{ steps.version.outputs.version }}
      channel: ${{ steps.version.outputs.channel }}
      publish: ${{ steps.version.outputs.publish }}
      architectures: ${{ env.ARCHITECTURES }}
      base_image_version: ${{ env.BASE_IMAGE_VERSION }}
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"

      - name: Get information
        id: info
        uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]

      - name: Get version
        id: version
        uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses]
        with:
          type: ${{ env.BUILD_TYPE }}

      - name: Verify version
        uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
        with:
          ignore-dev: true

      - name: Fail if translations files are checked in
        run: |
          if [ -n "$(find homeassistant/components/*/translations -type f)" ]; then
              echo "Translations files are checked in, please remove the following files:"
              find homeassistant/components/*/translations -type f
              exit 1
          fi

      - name: Download Translations
        run: python3 -m script.translations download
        env:
          LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]

      - name: Archive translations
        shell: bash
        run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -

      - name: Upload translations
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: translations
          path: translations.tar.gz
          if-no-files-found: error

  build_base:
    name: Build ${{ matrix.arch }} base core image
    if: github.repository_owner == 'home-assistant'
    needs: init
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read # To check out the repository
      packages: write # To push to GHCR
      id-token: write # For cosign signing
    strategy:
      fail-fast: false
      matrix:
        arch: ${{ fromJson(needs.init.outputs.architectures) }}
        include:
          - arch: amd64
            os: ubuntu-24.04
          - arch: aarch64
            os: ubuntu-24.04-arm
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Download nightly wheels of frontend
        if: needs.init.outputs.channel == 'dev'
        uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
        with:
          github_token: ${{secrets.GITHUB_TOKEN}}
          repo: home-assistant/frontend
          branch: dev
          workflow: nightly.yaml
          workflow_conclusion: success
          name: wheels

      - name: Download nightly wheels of intents
        if: needs.init.outputs.channel == 'dev'
        uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
        with:
          github_token: ${{secrets.GITHUB_TOKEN}}
          repo: OHF-Voice/intents-package
          branch: main
          workflow: nightly.yaml
          workflow_conclusion: success
          name: package

      - name: Set up Python
        if: needs.init.outputs.channel == 'dev'
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"

      - name: Adjust nightly version
        if: needs.init.outputs.channel == 'dev'
        shell: bash
        env:
          UV_PRERELEASE: allow
          VERSION: ${{ needs.init.outputs.version }}
        run: |
          python3 -m pip install "$(grep '^uv' < requirements.txt)"
          uv pip install packaging tomli
          uv pip install .
          python3 script/version_bump.py nightly --set-nightly-version "${VERSION}"

          if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
            echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}"
            frontend_version="${BASH_REMATCH[1]}" yq \
              --inplace e -o json \
              '.requirements = ["home-assistant-frontend=="+env(frontend_version)]' \
              homeassistant/components/frontend/manifest.json

            sed -i "s|home-assistant-frontend==.*|home-assistant-frontend==${BASH_REMATCH[1]}|" \
              homeassistant/package_constraints.txt

            sed -i "s|home-assistant-frontend==.*||" requirements_all.txt
          fi

          if [[ "$(ls home_assistant_intents*.whl)" =~ ^home_assistant_intents-(.*)-py3-none-any.whl$ ]]; then
            echo "Found intents wheel, setting version to: ${BASH_REMATCH[1]}"
            yq \
              --inplace e -o json \
              'del(.requirements[] | select(contains("home-assistant-intents")))' \
              homeassistant/components/conversation/manifest.json

            intents_version="${BASH_REMATCH[1]}" yq \
              --inplace e -o json \
              '.requirements += ["home-assistant-intents=="+env(intents_version)]' \
              homeassistant/components/conversation/manifest.json

            sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
              homeassistant/package_constraints.txt

            sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
          fi

      - name: Download translations
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: translations

      - name: Extract translations
        run: |
          tar xvf translations.tar.gz
          rm translations.tar.gz

      - name: Write meta info file
        shell: bash
        run: |
          echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE

      - name: Build base image
        uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
        with:
          arch: ${{ matrix.arch }}
          build-args: |
            BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
          cache-gha: false
          container-registry-password: ${{ secrets.GITHUB_TOKEN }}
          cosign-base-identity: "https://github.com/home-assistant/docker/.*"
          cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
          image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
          image-tags: ${{ needs.init.outputs.version }}
          push: true
          version: ${{ needs.init.outputs.version }}

  build_machine:
    name: Build ${{ matrix.machine }} machine core image
    if: github.repository_owner == 'home-assistant'
    needs: ["init", "build_base"]
    runs-on: ${{ matrix.runs-on }}
    permissions:
      contents: read # To check out the repository
      packages: write # To push to GHCR
      id-token: write # For cosign signing
    strategy:
      matrix:
        machine:
          - generic-x86-64
          - khadas-vim3
          - odroid-c2
          - odroid-c4
          - odroid-m1
          - odroid-n2
          - qemuarm-64
          - qemux86-64
          - raspberrypi3-64
          - raspberrypi4-64
          - raspberrypi5-64
          - yellow
          - green
        include:
          # Default: aarch64 on native ARM runner
          - arch: aarch64
            runs-on: ubuntu-24.04-arm
          # Overrides for amd64 machines
          - machine: generic-x86-64
            arch: amd64
            runs-on: ubuntu-24.04
          - machine: qemux86-64
            arch: amd64
            runs-on: ubuntu-24.04
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Compute extra tags
        id: tags
        shell: bash
        env:
          VERSION: ${{ needs.init.outputs.version }}
        run: |
          if [[ "${VERSION}" =~ d ]]; then
            echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
          elif [[ "${VERSION}" =~ b ]]; then
            echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
          else
            echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
          fi

      - name: Build machine image
        uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
        with:
          arch: ${{ matrix.arch }}
          build-args: |
            BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
          cache-gha: false
          container-registry-password: ${{ secrets.GITHUB_TOKEN }}
          context: machine/
          cosign-base-identity: "https://github.com/home-assistant/core/.*"
          cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
          file: machine/${{ matrix.machine }}
          image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
          image-tags: |
            ${{ needs.init.outputs.version }}
            ${{ steps.tags.outputs.extra_tags }}
          push: true
          version: ${{ needs.init.outputs.version }}

  publish_ha:
    name: Publish version files
    environment: ${{ needs.init.outputs.channel }}
    if: github.repository_owner == 'home-assistant'
    needs: ["init", "build_machine"]
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Initialize git
        uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
        with:
          name: ${{ secrets.GIT_NAME }}
          email: ${{ secrets.GIT_EMAIL }}
          token: ${{ secrets.GIT_TOKEN }}

      - name: Update version file
        uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
        with:
          key: "homeassistant[]"
          key-description: "Home Assistant Core"
          version: ${{ needs.init.outputs.version }}
          channel: ${{ needs.init.outputs.channel }}
          exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'

      - name: Update version file (stable -> beta)
        if: needs.init.outputs.channel == 'stable'
        uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
        with:
          key: "homeassistant[]"
          key-description: "Home Assistant Core"
          version: ${{ needs.init.outputs.version }}
          channel: beta
          exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'

  publish_container:
    name: Publish meta container for ${{ matrix.registry }}
    environment: ${{ needs.init.outputs.channel }}
    if: github.repository_owner == 'home-assistant'
    needs: ["init", "build_base"]
    runs-on: ubuntu-latest
    permissions:
      contents: read # To check out the repository
      packages: write # To push to GHCR
      id-token: write # For cosign signing
    strategy:
      fail-fast: false
      matrix:
        registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
    steps:
      - name: Install Cosign
        uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
        with:
          cosign-release: "v2.5.3"

      - name: Login to DockerHub
        if: matrix.registry == 'docker.io/homeassistant'
        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Login to GitHub Container Registry
        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Verify architecture image signatures
        shell: bash
        env:
          ARCHITECTURES: ${{ needs.init.outputs.architectures }}
          VERSION: ${{ needs.init.outputs.version }}
        run: |
          ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
          for arch in $ARCHS; do
            echo "Verifying ${arch} image signature..."
            cosign verify \
              --certificate-oidc-issuer https://token.actions.githubusercontent.com \
              --certificate-identity-regexp https://github.com/home-assistant/core/.* \
              "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
          done
          echo "✓ All images verified successfully"

      # Generate all Docker tags based on version string
      # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
      # Examples:
      #   2025.12.1     (stable)    -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
      #   2025.12.0b3   (beta)      -> tags: 2025.12.0b3, beta, rc
      #   2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
      - name: Generate Docker metadata
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
        with:
          images: ${{ matrix.registry }}/home-assistant
          sep-tags: ","
          tags: |
            type=raw,value=${{ needs.init.outputs.version }},priority=9999
            type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
            type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
            type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
            type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
            type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
            type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1

      - name: Copy architecture images to DockerHub
        if: matrix.registry == 'docker.io/homeassistant'
        shell: bash
        env:
          ARCHITECTURES: ${{ needs.init.outputs.architectures }}
          VERSION: ${{ needs.init.outputs.version }}
        run: |
          # Use imagetools to copy image blobs directly between registries
          # This preserves provenance/attestations and seems to be much faster than pull/push
          ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
          for arch in $ARCHS; do
            echo "Copying ${arch} image to DockerHub..."
            for attempt in 1 2 3; do
              if docker buildx imagetools create \
                --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
                "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
                break
              fi
              echo "Attempt ${attempt} failed, retrying in 10 seconds..."
              sleep 10
              if [ "${attempt}" -eq 3 ]; then
                echo "Failed after 3 attempts"
                exit 1
              fi
            done
            cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
          done

      - name: Create and push multi-arch manifests
        shell: bash
        env:
          ARCHITECTURES: ${{ needs.init.outputs.architectures }}
          REGISTRY: ${{ matrix.registry }}
          VERSION: ${{ needs.init.outputs.version }}
          META_TAGS: ${{ steps.meta.outputs.tags }}
        run: |
          # Build list of architecture images dynamically
          ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
          ARCH_IMAGES=()
          for arch in $ARCHS; do
            ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
          done

          # Build list of all tags for single manifest creation
          # Note: Using sep-tags=',' in metadata-action for easier parsing
          TAG_ARGS=()
          IFS=',' read -ra TAGS <<< "${META_TAGS}"
          for tag in "${TAGS[@]}"; do
            TAG_ARGS+=("--tag" "${tag}")
          done

          # Create manifest with ALL tags in a single operation (much faster!)
          echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
          docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"

          # Sign each tag separately (signing requires individual tag names)
          echo "Signing all tags..."
          for tag in "${TAGS[@]}"; do
            echo "Signing ${tag}"
            cosign sign --yes "${tag}"
          done

          echo "All manifests created and signed successfully"

  build_python:
    name: Build PyPi package
    environment: ${{ needs.init.outputs.channel }}
    needs: ["init", "build_base"]
    runs-on: ubuntu-latest
    permissions:
      contents: read # To check out the repository
      id-token: write # For PyPI trusted publishing
    if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"

      - name: Download translations
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: translations

      - name: Extract translations
        run: |
          tar xvf translations.tar.gz
          rm translations.tar.gz

      - name: Build package
        shell: bash
        run: |
          # Remove dist, build, and homeassistant.egg-info
          # when build locally for testing!
          pip install build
          python -m build

      - name: Upload package to PyPI
        uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
        with:
          skip-existing: true

  hassfest-image:
    name: Build and test hassfest image
    runs-on: ubuntu-latest
    permissions:
      contents: read # To check out the repository
      packages: write # To push to GHCR
      attestations: write # For build provenance attestation
      id-token: write # For build provenance attestation
    needs: ["init"]
    if: github.repository_owner == 'home-assistant'
    env:
      HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
      HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Login to GitHub Container Registry
        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build Docker image
        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
        with:
          context: . # So action will not pull the repository again
          file: ./script/hassfest/docker/Dockerfile
          load: true
          tags: ${{ env.HASSFEST_IMAGE_TAG }}

      - name: Run hassfest against core
        run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace

      - name: Push Docker image
        if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
        id: push
        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
        with:
          context: . # So action will not pull the repository again
          file: ./script/hassfest/docker/Dockerfile
          push: true
          tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest

      - name: Generate artifact attestation
        if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
        uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
        with:
          subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
ci matrix perms .github/workflows/ci.yaml
Triggers
push, pull_request, workflow_dispatch
Runs on
ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04
Jobs
info, prek, zizmor, lint-hadolint, base, hassfest, gen-requirements-all, gen-copilot-instructions, dependency-review, audit-licenses, pylint, pylint-tests, mypy, prepare-pytest-full, pytest-full, pytest-mariadb, pytest-postgres, coverage-full, pytest-partial, coverage-partial, upload-test-results
Matrix
file, group, mariadb-group, postgresql-group, python-version→ ${{ fromJson(needs.info.outputs.mariadb_groups) }}, ${{ fromJson(needs.info.outputs.postgresql_groups) }}, ${{ fromJson(needs.info.outputs.python_versions) }}, ${{ fromJson(needs.info.outputs.test_groups) }}, Dockerfile, Dockerfile.dev, script/hassfest/docker/Dockerfile
Actions
dorny/paths-filter, dorny/paths-filter, j178/prek-action, j178/prek-action, docker://hadolint/hadolint:v2.12.0, actions/dependency-review-action, codecov/codecov-action, codecov/codecov-action, codecov/codecov-action
Commands
  • # Include HA_SHORT_VERSION to force the immediate creation # of a new uv cache entry after a version bump. echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
  • echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
  • integrations=$(ls -Ad ./homeassistant/components/[!_]* | xargs -n 1 basename) touch .integration_paths.yaml for integration in $integrations; do echo "${integration}: [homeassistant/components/${integration}/**, tests/components/${integration}/**]" \ >> .integration_paths.yaml; done echo "Result:" cat .integration_paths.yaml
  • # Defaults integrations_glob="" mariadb_groups=${MARIADB_VERSIONS} postgresql_groups=${POSTGRESQL_VERSIONS} test_full_suite="true" test_groups="[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]" test_group_count=10 tests="[]" tests_glob="" lint_only="" skip_coverage="" default_python=$(cat .python-version) all_python_versions=$(jq -cn \ --arg default_python "${default_python}" \ --argjson additional_python_versions "${ADDITIONAL_PYTHON_VERSIONS}" \ '[$default_python] + $additional_python_versions') if [[ "${INTEGRATION_CHANGES}" != "[]" ]]; then # Create a space-separated list of integrations integrations_glob=$(echo "${INTEGRATION_CHANGES}" | jq -r '. | join(" ")') # Create list of testable integrations possible_integrations=$(echo "${INTEGRATION_CHANGES}" | jq -cSr '.[]') tests=$( for integration in ${possible_integrations}; do if [[ -d "tests/components/${integration}" ]]; then echo -n "\"${integration}\","; fi; done ) [[ ! -z "${tests}" ]] && tests="${tests::-1}" tests="[${tests}]" test_groups="${tests}" # Test group count should be 1, we don't split partial tests test_group_count=1 # Create a space-separated list of test integrations tests_glob=$(echo "${tests}" | jq -r '. | join(" ")') mariadb_groups="[]" postgresql_groups="[]" test_full_suite="false" fi # We need to run the full suite on certain branches. # Or, in case core files are touched, for the full suite as well. if [[ "${GITHUB_REF}" == "refs/heads/dev" ]] \ || [[ "${GITHUB_REF}" == "refs/heads/master" ]] \ || [[ "${GITHUB_REF}" == "refs/heads/rc" ]] \ || [[ "${CORE_ANY}" == "true" ]] \ || [[ "${INPUT_FULL}" == "true" ]] \ || [[ "${HAS_CI_FULL_RUN_LABEL}" == "true" ]]; then mariadb_groups=${MARIADB_VERSIONS} postgresql_groups=${POSTGRESQL_VERSIONS} test_groups="[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]" test_group_count=10 test_full_suite="true" fi if [[ "${INPUT_LINT_ONLY}" == "true" ]] \ || [[ "${INPUT_PYLINT_ONLY}" == "true" ]] \ || [[ "${INPUT_MYPY_ONLY}" == "true" ]] \ || [[ "${INPUT_AUDIT_LICENSES_ONLY}" == "true" ]] \ || [[ "${GITHUB_EVENT_NAME}" == "push" \ && "${REPO_FULL_NAME}" != "home-assistant/core" ]]; then lint_only="true" skip_coverage="true" fi if [[ "${INPUT_SKIP_COVERAGE}" == "true" ]] \ || [[ "${HAS_CI_SKIP_COVERAGE_LABEL}" == "true" ]]; then skip_coverage="true" fi # Output & sent to GitHub Actions echo "mariadb_groups: ${mariadb_groups}" echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT echo "postgresql_groups: ${postgresql_groups}" echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT echo "python_versions: ${all_python_versions}" echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT echo "test_full_suite: ${test_full_suite}" echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT echo "integrations_glob: ${integrations_glob}" echo "integrations_glob=${integrations_glob}" >> $GITHUB_OUTPUT echo "test_group_count: ${test_group_count}" echo "test_group_count=${test_group_count}" >> $GITHUB_OUTPUT echo "test_groups: ${test_groups}" echo "test_groups=${test_groups}" >> $GITHUB_OUTPUT echo "tests: ${tests}" echo "tests=${tests}" >> $GITHUB_OUTPUT echo "tests_glob: ${tests_glob}" echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT echo "lint_only": ${lint_only} echo "lint_only=${lint_only}" >> $GITHUB_OUTPUT echo "skip_coverage: ${skip_coverage}" echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
  • echo "::add-matcher::.github/workflows/matchers/yamllint.json" echo "::add-matcher::.github/workflows/matchers/check-json.json" echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/codespell.json"
  • echo "::add-matcher::.github/workflows/matchers/hadolint.json"
  • uv_version=$(cat requirements.txt | grep uv | cut -d '=' -f 3) echo "version=${uv_version}" >> $GITHUB_OUTPUT echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
  • sudo rm /etc/apt/sources.list.d/microsoft-prod.list if [[ "${APT_CACHE_HIT}" != 'true' ]]; then mkdir -p ${APT_CACHE_DIR} mkdir -p ${APT_LIST_CACHE_DIR} fi sudo apt-get update \ -o Dir::Cache=${APT_CACHE_DIR} \ -o Dir::State::Lists=${APT_LIST_CACHE_DIR} sudo apt-get -y install \ -o Dir::Cache=${APT_CACHE_DIR} \ -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \ bluez \ ffmpeg \ libturbojpeg \ libxml2-utils \ libavcodec-dev \ libavdevice-dev \ libavfilter-dev \ libavformat-dev \ libavutil-dev \ libswresample-dev \ libswscale-dev \ libudev-dev if [[ "${APT_CACHE_HIT}" != 'true' ]]; then sudo chmod -R 755 ${APT_CACHE_BASE} fi
View raw YAML
name: CI
run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: {0}', github.ref_name) || '' }}"

# yamllint disable-line rule:truthy
on:
  push:
    branches:
      - dev
      - rc
      - master
  pull_request: ~
  workflow_dispatch:
    inputs:
      full:
        description: "Full run (regardless of changes)"
        default: false
        type: boolean
      lint-only:
        description: "Skip pytest"
        default: false
        type: boolean
      skip-coverage:
        description: "Skip coverage"
        default: false
        type: boolean
      pylint-only:
        description: "Only run pylint"
        default: false
        type: boolean
      mypy-only:
        description: "Only run mypy"
        default: false
        type: boolean
      audit-licenses-only:
        description: "Only run audit licenses"
        default: false
        type: boolean

env:
  CACHE_VERSION: 3
  UV_CACHE_VERSION: 1
  MYPY_CACHE_VERSION: 1
  HA_SHORT_VERSION: "2026.5"
  ADDITIONAL_PYTHON_VERSIONS: "[]"
  # 10.3 is the oldest supported version
  # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
  # 10.6 is the current long-term-support
  # - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023)
  # 10.10 is the latest short-term-support
  # - 10.10.3 is the latest (as of 6 Feb 2023)
  # 10.11 is the latest long-term-support
  # - 10.11.2 is the version currently shipped with Synology (as of 11 Oct 2023)
  # mysql 8.0.32 does not always behave the same as MariaDB
  # and some queries that work on MariaDB do not work on MySQL
  MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mysql:8.0.32']"
  # 12 is the oldest supported version
  # - 12.14 is the latest (as of 9 Feb 2023)
  # 15 is the latest version
  # - 15.2 is the latest (as of 9 Feb 2023)
  POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
  UV_CACHE_DIR: /tmp/uv-cache
  APT_CACHE_BASE: /home/runner/work/apt
  APT_CACHE_DIR: /home/runner/work/apt/cache
  APT_LIST_CACHE_DIR: /home/runner/work/apt/lists
  SQLALCHEMY_WARN_20: 1
  PYTHONASYNCIODEBUG: 1
  HASS_CI: 1

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  info:
    name: Collect information & changes data
    runs-on: ubuntu-24.04
    permissions:
      contents: read # To check out the repository
      pull-requests: read # For paths-filter to detect changed files
    outputs:
      # In case of issues with the partial run, use the following line instead:
      # test_full_suite: 'true'
      core: ${{ steps.core.outputs.changes }}
      integrations_glob: ${{ steps.info.outputs.integrations_glob }}
      integrations: ${{ steps.integrations.outputs.changes }}
      apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
      python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
      requirements: ${{ steps.core.outputs.requirements }}
      mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
      postgresql_groups: ${{ steps.info.outputs.postgresql_groups }}
      python_versions: ${{ steps.info.outputs.python_versions }}
      test_full_suite: ${{ steps.info.outputs.test_full_suite }}
      test_group_count: ${{ steps.info.outputs.test_group_count }}
      test_groups: ${{ steps.info.outputs.test_groups }}
      tests_glob: ${{ steps.info.outputs.tests_glob }}
      tests: ${{ steps.info.outputs.tests }}
      lint_only: ${{ steps.info.outputs.lint_only }}
      skip_coverage: ${{ steps.info.outputs.skip_coverage }}
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Generate partial Python venv restore key
        id: generate_python_cache_key
        env:
          HASH_REQUIREMENTS_TEST: ${{ hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}
          HASH_REQUIREMENTS: ${{ hashFiles('requirements.txt') }}
          HASH_REQUIREMENTS_ALL: ${{ hashFiles('requirements_all.txt') }}
          HASH_PACKAGE_CONSTRAINTS: ${{ hashFiles('homeassistant/package_constraints.txt') }}
          HASH_GEN_REQUIREMENTS: ${{ hashFiles('script/gen_requirements_all.py') }}
        run: |
          # Include HA_SHORT_VERSION to force the immediate creation
          # of a new uv cache entry after a version bump.
          echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT
      - name: Generate partial apt restore key
        id: generate_apt_cache_key
        run: |
          echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
      - name: Filter for core changes
        uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
        id: core
        with:
          filters: .core_files.yaml
      - name: Create a list of integrations to filter for changes
        run: |
          integrations=$(ls -Ad ./homeassistant/components/[!_]*  | xargs -n 1 basename)
          touch .integration_paths.yaml
          for integration in $integrations; do
            echo "${integration}: [homeassistant/components/${integration}/**, tests/components/${integration}/**]" \
              >> .integration_paths.yaml;
          done
          echo "Result:"
          cat .integration_paths.yaml
      - name: Filter for integration changes
        uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
        id: integrations
        with:
          filters: .integration_paths.yaml
      - name: Collect additional information
        id: info
        env:
          INTEGRATION_CHANGES: ${{ steps.integrations.outputs.changes }}
          CORE_ANY: ${{ steps.core.outputs.any }}
          INPUT_FULL: ${{ github.event.inputs.full }}
          HAS_CI_FULL_RUN_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci-full-run') }}
          INPUT_LINT_ONLY: ${{ github.event.inputs.lint-only }}
          INPUT_PYLINT_ONLY: ${{ github.event.inputs.pylint-only }}
          INPUT_MYPY_ONLY: ${{ github.event.inputs.mypy-only }}
          INPUT_AUDIT_LICENSES_ONLY: ${{ github.event.inputs.audit-licenses-only }}
          REPO_FULL_NAME: ${{ github.event.repository.full_name }}
          INPUT_SKIP_COVERAGE: ${{ github.event.inputs.skip-coverage }}
          HAS_CI_SKIP_COVERAGE_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}
        run: |
          # Defaults
          integrations_glob=""
          mariadb_groups=${MARIADB_VERSIONS}
          postgresql_groups=${POSTGRESQL_VERSIONS}
          test_full_suite="true"
          test_groups="[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]"
          test_group_count=10
          tests="[]"
          tests_glob=""
          lint_only=""
          skip_coverage=""
          default_python=$(cat .python-version)
          all_python_versions=$(jq -cn \
            --arg default_python "${default_python}" \
            --argjson additional_python_versions "${ADDITIONAL_PYTHON_VERSIONS}" \
            '[$default_python] + $additional_python_versions')

          if [[ "${INTEGRATION_CHANGES}" != "[]" ]];
          then
            # Create a space-separated list of integrations
            integrations_glob=$(echo "${INTEGRATION_CHANGES}" | jq -r '. | join(" ")')

            # Create list of testable integrations
            possible_integrations=$(echo "${INTEGRATION_CHANGES}" | jq -cSr '.[]')
            tests=$(
              for integration in ${possible_integrations};
              do
                if [[ -d "tests/components/${integration}" ]]; then
                  echo -n "\"${integration}\",";
                fi;
              done
            )

            [[ ! -z "${tests}" ]] && tests="${tests::-1}"
            tests="[${tests}]"
            test_groups="${tests}"
            # Test group count should be 1, we don't split partial tests
            test_group_count=1

            # Create a space-separated list of test integrations
            tests_glob=$(echo "${tests}" | jq -r '. | join(" ")')

            mariadb_groups="[]"
            postgresql_groups="[]"
            test_full_suite="false"
          fi

          # We need to run the full suite on certain branches.
          # Or, in case core files are touched, for the full suite as well.
          if [[ "${GITHUB_REF}" == "refs/heads/dev" ]] \
            || [[ "${GITHUB_REF}" == "refs/heads/master" ]] \
            || [[ "${GITHUB_REF}" == "refs/heads/rc" ]] \
            || [[ "${CORE_ANY}" == "true" ]] \
            || [[ "${INPUT_FULL}" == "true" ]] \
            || [[ "${HAS_CI_FULL_RUN_LABEL}" == "true" ]];
          then
            mariadb_groups=${MARIADB_VERSIONS}
            postgresql_groups=${POSTGRESQL_VERSIONS}
            test_groups="[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]"
            test_group_count=10
            test_full_suite="true"
          fi

          if [[ "${INPUT_LINT_ONLY}" == "true" ]] \
            || [[ "${INPUT_PYLINT_ONLY}" == "true" ]] \
            || [[ "${INPUT_MYPY_ONLY}" == "true" ]] \
            || [[ "${INPUT_AUDIT_LICENSES_ONLY}" == "true" ]] \
            || [[ "${GITHUB_EVENT_NAME}" == "push" \
              && "${REPO_FULL_NAME}" != "home-assistant/core" ]];
          then
            lint_only="true"
            skip_coverage="true"
          fi

          if [[ "${INPUT_SKIP_COVERAGE}" == "true" ]] \
            || [[ "${HAS_CI_SKIP_COVERAGE_LABEL}" == "true" ]];
          then
            skip_coverage="true"
          fi

          # Output & sent to GitHub Actions
          echo "mariadb_groups: ${mariadb_groups}"
          echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT
          echo "postgresql_groups: ${postgresql_groups}"
          echo "postgresql_groups=${postgresql_groups}" >> $GITHUB_OUTPUT
          echo "python_versions: ${all_python_versions}"
          echo "python_versions=${all_python_versions}" >> $GITHUB_OUTPUT
          echo "test_full_suite: ${test_full_suite}"
          echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT
          echo "integrations_glob: ${integrations_glob}"
          echo "integrations_glob=${integrations_glob}" >> $GITHUB_OUTPUT
          echo "test_group_count: ${test_group_count}"
          echo "test_group_count=${test_group_count}" >> $GITHUB_OUTPUT
          echo "test_groups: ${test_groups}"
          echo "test_groups=${test_groups}" >> $GITHUB_OUTPUT
          echo "tests: ${tests}"
          echo "tests=${tests}" >> $GITHUB_OUTPUT
          echo "tests_glob: ${tests_glob}"
          echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT
          echo "lint_only": ${lint_only}
          echo "lint_only=${lint_only}" >> $GITHUB_OUTPUT
          echo "skip_coverage: ${skip_coverage}"
          echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT

  prek:
    name: Run prek checks
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs: [info]
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.mypy-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Register problem matchers
        run: |
          echo "::add-matcher::.github/workflows/matchers/yamllint.json"
          echo "::add-matcher::.github/workflows/matchers/check-json.json"
          echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
          echo "::add-matcher::.github/workflows/matchers/codespell.json"
      - name: Run prek
        uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
        env:
          PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
          RUFF_OUTPUT_FORMAT: github

  zizmor:
    name: Check GitHub Actions workflows
    runs-on: ubuntu-24.04
    permissions:
      contents: read # To check out the repository
    needs: [info]
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.mypy-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Run zizmor
        uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
        with:
          extra-args: --all-files zizmor

  lint-hadolint:
    name: Check ${{ matrix.file }}
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs: [info]
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.mypy-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
    strategy:
      fail-fast: false
      matrix:
        file:
          - Dockerfile
          - Dockerfile.dev
          - script/hassfest/docker/Dockerfile
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Register hadolint problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/hadolint.json"
      - name: Check ${{ matrix.file }}
        uses: docker://hadolint/hadolint:v2.12.0@sha256:30a8fd2e785ab6176eed53f74769e04f125afb2f74a6c52aef7d463583b6d45e
        with:
          args: hadolint ${{ matrix.file }}

  base:
    name: Prepare dependencies
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs: [info]
    timeout-minutes: 60
    strategy:
      matrix:
        python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python ${{ matrix.python-version }}
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          check-latest: true
      - name: Generate partial uv restore key
        id: generate-uv-key
        run: |
          uv_version=$(cat requirements.txt | grep uv | cut -d '=' -f 3)
          echo "version=${uv_version}" >> $GITHUB_OUTPUT
          echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
      - name: Restore base Python virtual environment
        id: cache-venv
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Restore uv wheel cache
        if: steps.cache-venv.outputs.cache-hit != 'true'
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: ${{ env.UV_CACHE_DIR }}
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            steps.generate-uv-key.outputs.key }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
            env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
            env.HA_SHORT_VERSION }}-
      - name: Check if apt cache exists
        id: cache-apt-check
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Install additional OS dependencies
        if: |
          steps.cache-venv.outputs.cache-hit != 'true'
          || steps.cache-apt-check.outputs.cache-hit != 'true'
        timeout-minutes: 10
        env:
          APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }}
        run: |
          sudo rm /etc/apt/sources.list.d/microsoft-prod.list
          if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
            mkdir -p ${APT_CACHE_DIR}
            mkdir -p ${APT_LIST_CACHE_DIR}
          fi

          sudo apt-get update \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR}
          sudo apt-get -y install \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
            bluez \
            ffmpeg \
            libturbojpeg \
            libxml2-utils \
            libavcodec-dev \
            libavdevice-dev \
            libavfilter-dev \
            libavformat-dev \
            libavutil-dev \
            libswresample-dev \
            libswscale-dev \
            libudev-dev

          if [[ "${APT_CACHE_HIT}" != 'true' ]]; then
            sudo chmod -R 755 ${APT_CACHE_BASE}
          fi
      - name: Save apt cache
        if: steps.cache-apt-check.outputs.cache-hit != 'true'
        uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Create Python virtual environment
        if: steps.cache-venv.outputs.cache-hit != 'true'
        run: |
          python -m venv venv
          . venv/bin/activate
          python --version
          pip install "$(grep '^uv' < requirements.txt)"
          uv pip install -U "pip>=25.2"
          uv pip install -r requirements.txt
          python -m script.gen_requirements_all ci
          uv pip install -r requirements_all_pytest.txt -r requirements_test.txt
          uv pip install -e . --config-settings editable_mode=compat
      - name: Dump pip freeze
        run: |
          python -m venv venv
          . venv/bin/activate
          python --version
          uv pip freeze >> pip_freeze.txt
      - name: Upload pip_freeze artifact
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: pip-freeze-${{ matrix.python-version }}
          path: pip_freeze.txt
          overwrite: true
      - name: Remove pip_freeze
        run: rm pip_freeze.txt
      - name: Remove generated requirements_all
        if: steps.cache-venv.outputs.cache-hit != 'true'
        run: rm requirements_all_pytest.txt requirements_all_wheels_*.txt
      - name: Check dirty
        run: |
          ./script/check_dirty

  hassfest:
    name: Check hassfest
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.mypy-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
    steps:
      - name: Restore apt cache
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Install additional OS dependencies
        timeout-minutes: 10
        run: |
          sudo rm /etc/apt/sources.list.d/microsoft-prod.list
          sudo apt-get update \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR}
          sudo apt-get -y install \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
            libturbojpeg
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true
      - name: Restore full Python virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Run hassfest
        run: |
          . venv/bin/activate
          python -m script.hassfest --requirements --action validate

  gen-requirements-all:
    name: Check all requirements
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.mypy-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true
      - name: Restore full Python virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Run gen_requirements_all.py
        run: |
          . venv/bin/activate
          python -m script.gen_requirements_all validate

  gen-copilot-instructions:
    name: Check copilot instructions
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.mypy-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true
      - name: Run gen_copilot_instructions.py
        run: |
          python -m script.gen_copilot_instructions validate

  dependency-review:
    name: Dependency review
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.mypy-only != 'true'
      && needs.info.outputs.requirements == 'true'
      && github.event_name == 'pull_request'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Dependency review
        uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
        with:
          license-check: false # We use our own license audit checks

  audit-licenses:
    name: Audit licenses
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
    if: |
      (github.event.inputs.pylint-only != 'true'
        && github.event.inputs.mypy-only != 'true'
        || github.event.inputs.audit-licenses-only == 'true')
      && needs.info.outputs.requirements == 'true'
    strategy:
      fail-fast: false
      matrix:
        python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python ${{ matrix.python-version }}
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          check-latest: true
      - name: Restore full Python ${{ matrix.python-version }} virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Extract license data
        env:
          PYTHON_VERSION: ${{ matrix.python-version }}
        run: |
          . venv/bin/activate
          python -m script.licenses extract --output-file=licenses-${PYTHON_VERSION}.json
      - name: Upload licenses
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
          path: licenses-${{ matrix.python-version }}.json
      - name: Check licenses
        env:
          PYTHON_VERSION: ${{ matrix.python-version }}
        run: |
          . venv/bin/activate
          python -m script.licenses check licenses-${PYTHON_VERSION}.json

  pylint:
    name: Check pylint
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
    timeout-minutes: 20
    if: |
      github.event.inputs.mypy-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
      || github.event.inputs.pylint-only == 'true'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true
      - name: Restore full Python virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Register pylint problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/pylint.json"
      - name: Run pylint (fully)
        if: needs.info.outputs.test_full_suite == 'true'
        run: |
          . venv/bin/activate
          python --version
          pylint --ignore-missing-annotations=y homeassistant
      - name: Run pylint (partially)
        if: needs.info.outputs.test_full_suite == 'false'
        shell: bash
        env:
          INTEGRATIONS_GLOB: ${{ needs.info.outputs.integrations_glob }}
        run: |
          . venv/bin/activate
          python --version
          pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})

  pylint-tests:
    name: Check pylint on tests
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
    timeout-minutes: 20
    if: |
      (github.event.inputs.mypy-only != 'true'
        && github.event.inputs.audit-licenses-only != 'true'
        || github.event.inputs.pylint-only == 'true')
      && (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true
      - name: Restore full Python virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Register pylint problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/pylint.json"
      - name: Run pylint (fully)
        if: needs.info.outputs.test_full_suite == 'true'
        run: |
          . venv/bin/activate
          python --version
          pylint tests
      - name: Run pylint (partially)
        if: needs.info.outputs.test_full_suite == 'false'
        shell: bash
        env:
          TESTS_GLOB: ${{ needs.info.outputs.tests_glob }}
        run: |
          . venv/bin/activate
          python --version
          pylint $(printf "tests/components/%s " ${TESTS_GLOB})

  mypy:
    name: Check mypy
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
    if: |
      github.event.inputs.pylint-only != 'true'
      && github.event.inputs.audit-licenses-only != 'true'
      || github.event.inputs.mypy-only == 'true'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true
      - name: Generate partial mypy restore key
        id: generate-mypy-key
        run: |
          mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3)
          echo "version=${mypy_version}" >> $GITHUB_OUTPUT
          echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
      - name: Restore full Python virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Restore mypy cache
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: .mypy_cache
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            steps.generate-mypy-key.outputs.key }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{
            env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{
            env.HA_SHORT_VERSION }}-
      - name: Register mypy problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/mypy.json"
      - name: Run mypy (fully)
        if: needs.info.outputs.test_full_suite == 'true'
        run: |
          . venv/bin/activate
          python --version
          mypy homeassistant pylint
      - name: Run mypy (partially)
        if: needs.info.outputs.test_full_suite == 'false'
        shell: bash
        env:
          INTEGRATIONS_GLOB: ${{ needs.info.outputs.integrations_glob }}
        run: |
          . venv/bin/activate
          python --version
          mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})

  prepare-pytest-full:
    name: Split tests for full run
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    if: |
      needs.info.outputs.lint_only != 'true'
      && needs.info.outputs.test_full_suite == 'true'
    needs:
      - info
      - base
    steps:
      - name: Restore apt cache
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Install additional OS dependencies
        timeout-minutes: 10
        run: |
          sudo rm /etc/apt/sources.list.d/microsoft-prod.list
          sudo apt-get update \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR}
          sudo apt-get -y install \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
            bluez \
            ffmpeg \
            libturbojpeg
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true
      - name: Restore full Python virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Run split_tests.py
        env:
          TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
        run: |
          . venv/bin/activate
          python -m script.split_tests ${TEST_GROUP_COUNT} tests
      - name: Upload pytest_buckets
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: pytest_buckets
          path: pytest_buckets.txt
          overwrite: true

  pytest-full:
    name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
      - gen-requirements-all
      - hassfest
      - prek
      - mypy
      - prepare-pytest-full
    if: |
      needs.info.outputs.lint_only != 'true'
      && needs.info.outputs.test_full_suite == 'true'
    strategy:
      fail-fast: false
      matrix:
        python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
        group: ${{ fromJson(needs.info.outputs.test_groups) }}
    steps:
      - name: Restore apt cache
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Install additional OS dependencies
        timeout-minutes: 10
        run: |
          sudo rm /etc/apt/sources.list.d/microsoft-prod.list
          sudo apt-get update \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR}
          sudo apt-get -y install \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
            bluez \
            ffmpeg \
            libturbojpeg \
            libxml2-utils
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python ${{ matrix.python-version }}
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          check-latest: true
      - name: Restore full Python ${{ matrix.python-version }} virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Register Python problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/python.json"
      - name: Register pytest slow test problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
      - name: Download pytest_buckets
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: pytest_buckets
      - name: Compile English translations
        run: |
          . venv/bin/activate
          python3 -m script.translations develop --all
      - name: Run pytest
        timeout-minutes: 60
        id: pytest-full
        env:
          PYTHONDONTWRITEBYTECODE: 1
          SKIP_COVERAGE: ${{ needs.info.outputs.skip_coverage }}
          TEST_GROUP: ${{ matrix.group }}
          PYTHON_VERSION: ${{ matrix.python-version }}
        run: |
          . venv/bin/activate
          python --version
          set -o pipefail
          cov_params=()
          if [[ "${SKIP_COVERAGE}" != "true" ]]; then
            cov_params+=(--cov="homeassistant")
            cov_params+=(--cov-report=xml)
            cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
          fi

          echo "Test group ${TEST_GROUP}: $(sed -n "${TEST_GROUP},1p" pytest_buckets.txt)"
          python3 -b -X dev -m pytest \
            -qq \
            --timeout=9 \
            --durations=10 \
            --numprocesses auto \
            --snapshot-details \
            --dist=loadfile \
            ${cov_params[@]} \
            -o console_output_style=count \
            -p no:sugar \
            --exclude-warning-annotations \
            $(sed -n "${TEST_GROUP},1p" pytest_buckets.txt) \
              2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
      - name: Upload pytest output
        if: success() || failure() && steps.pytest-full.conclusion == 'failure'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
          path: pytest-*.txt
          overwrite: true
      - name: Upload coverage artifact
        if: needs.info.outputs.skip_coverage != 'true'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
          path: coverage.xml
          overwrite: true
      - name: Beautify test results
        # For easier identification of parsing errors
        if: needs.info.outputs.skip_coverage != 'true'
        run: |
          xmllint --format "junit.xml" > "junit.xml-tmp"
          mv "junit.xml-tmp" "junit.xml"
      - name: Upload test results artifact
        if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
          path: junit.xml
      - name: Remove pytest_buckets
        run: rm pytest_buckets.txt
      - name: Check dirty
        run: |
          ./script/check_dirty

  pytest-mariadb:
    name: Run ${{ matrix.mariadb-group }} tests Python ${{ matrix.python-version }}
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    services:
      mariadb:
        image: ${{ matrix.mariadb-group }} # zizmor: ignore[unpinned-images]
        ports:
          - 3306:3306
        env:
          MYSQL_ROOT_PASSWORD: password
        options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3
    needs:
      - info
      - base
      - gen-requirements-all
      - hassfest
      - prek
      - mypy
    if: |
      needs.info.outputs.lint_only != 'true'
      && needs.info.outputs.mariadb_groups != '[]'
    strategy:
      fail-fast: false
      matrix:
        python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
        mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
    steps:
      - name: Restore apt cache
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Install additional OS dependencies
        timeout-minutes: 10
        run: |
          sudo rm /etc/apt/sources.list.d/microsoft-prod.list
          sudo apt-get update \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR}
          sudo apt-get -y install \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
            bluez \
            ffmpeg \
            libturbojpeg \
            libmariadb-dev-compat \
            libxml2-utils
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python ${{ matrix.python-version }}
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          check-latest: true
      - name: Restore full Python ${{ matrix.python-version }} virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Register Python problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/python.json"
      - name: Register pytest slow test problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
      - name: Install SQL Python libraries
        run: |
          . venv/bin/activate
          uv pip install mysqlclient sqlalchemy_utils
      - name: Compile English translations
        run: |
          . venv/bin/activate
          python3 -m script.translations develop --all
      - name: Run pytest (partially)
        timeout-minutes: 20
        id: pytest-partial
        shell: bash
        env:
          PYTHONDONTWRITEBYTECODE: 1
          MARIADB_GROUP: ${{ matrix.mariadb-group }}
          SKIP_COVERAGE: ${{ needs.info.outputs.skip_coverage }}
          PYTHON_VERSION: ${{ matrix.python-version }}
        run: |
          . venv/bin/activate
          python --version
          set -o pipefail
          mariadb=$(echo "${MARIADB_GROUP}" | sed "s/:/-/g")
          echo "mariadb=${mariadb}" >> $GITHUB_OUTPUT
          cov_params=()
          if [[ "${SKIP_COVERAGE}" != "true" ]]; then
            cov_params+=(--cov="homeassistant.components.recorder")
            cov_params+=(--cov-report=xml)
            cov_params+=(--cov-report=term-missing)
            cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
          fi

          python3 -b -X dev -m pytest \
            -qq \
            --timeout=20 \
            --numprocesses 1 \
            --snapshot-details \
            ${cov_params[@]} \
            -o console_output_style=count \
            --durations=10 \
            -p no:sugar \
            --exclude-warning-annotations \
            --dburl=mysql://root:password@127.0.0.1/homeassistant-test \
            tests/components/history \
            tests/components/logbook \
            tests/components/recorder \
            tests/components/sensor \
              2>&1 | tee pytest-${PYTHON_VERSION}-${mariadb}.txt
      - name: Upload pytest output
        if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
            steps.pytest-partial.outputs.mariadb }}
          path: pytest-*.txt
          overwrite: true
      - name: Upload coverage artifact
        if: needs.info.outputs.skip_coverage != 'true'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: coverage-${{ matrix.python-version }}-${{
            steps.pytest-partial.outputs.mariadb }}
          path: coverage.xml
          overwrite: true
      - name: Beautify test results
        # For easier identification of parsing errors
        if: needs.info.outputs.skip_coverage != 'true'
        run: |
          xmllint --format "junit.xml" > "junit.xml-tmp"
          mv "junit.xml-tmp" "junit.xml"
      - name: Upload test results artifact
        if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: test-results-mariadb-${{ matrix.python-version }}-${{
            steps.pytest-partial.outputs.mariadb }}
          path: junit.xml
      - name: Check dirty
        run: |
          ./script/check_dirty

  pytest-postgres:
    name: Run ${{ matrix.postgresql-group }} tests Python ${{ matrix.python-version }}
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    services:
      postgres:
        image: ${{ matrix.postgresql-group }} # zizmor: ignore[unpinned-images]
        ports:
          - 5432:5432
        env:
          POSTGRES_PASSWORD: password
        options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
    needs:
      - info
      - base
      - gen-requirements-all
      - hassfest
      - prek
      - mypy
    if: |
      needs.info.outputs.lint_only != 'true'
      && needs.info.outputs.postgresql_groups != '[]'
    strategy:
      fail-fast: false
      matrix:
        python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
        postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
    steps:
      - name: Restore apt cache
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Install additional OS dependencies
        timeout-minutes: 10
        run: |
          sudo rm /etc/apt/sources.list.d/microsoft-prod.list
          sudo apt-get update \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR}
          sudo apt-get -y install \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
            bluez \
            ffmpeg \
            libturbojpeg \
            libxml2-utils
          sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
          sudo apt-get -y install \
            postgresql-server-dev-14
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python ${{ matrix.python-version }}
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          check-latest: true
      - name: Restore full Python ${{ matrix.python-version }} virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Register Python problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/python.json"
      - name: Register pytest slow test problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
      - name: Install SQL Python libraries
        run: |
          . venv/bin/activate
          uv pip install psycopg2 sqlalchemy_utils
      - name: Compile English translations
        run: |
          . venv/bin/activate
          python3 -m script.translations develop --all
      - name: Run pytest (partially)
        timeout-minutes: 20
        id: pytest-partial
        shell: bash
        env:
          PYTHONDONTWRITEBYTECODE: 1
          POSTGRESQL_GROUP: ${{ matrix.postgresql-group }}
          SKIP_COVERAGE: ${{ needs.info.outputs.skip_coverage }}
          PYTHON_VERSION: ${{ matrix.python-version }}
        run: |
          . venv/bin/activate
          python --version
          set -o pipefail
          postgresql=$(echo "${POSTGRESQL_GROUP}" | sed "s/:/-/g")
          echo "postgresql=${postgresql}" >> $GITHUB_OUTPUT
          cov_params=()
          if [[ "${SKIP_COVERAGE}" != "true" ]]; then
            cov_params+=(--cov="homeassistant.components.recorder")
            cov_params+=(--cov-report=xml)
            cov_params+=(--cov-report=term-missing)
            cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
          fi

          python3 -b -X dev -m pytest \
            -qq \
            --timeout=9 \
            --numprocesses 1 \
            --snapshot-details \
            ${cov_params[@]} \
            -o console_output_style=count \
            --durations=0 \
            --durations-min=10 \
            -p no:sugar \
            --exclude-warning-annotations \
            --dburl=postgresql://postgres:password@127.0.0.1/homeassistant-test \
            tests/components/history \
            tests/components/logbook \
            tests/components/recorder \
            tests/components/sensor \
              2>&1 | tee pytest-${PYTHON_VERSION}-${postgresql}.txt
      - name: Upload pytest output
        if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
            steps.pytest-partial.outputs.postgresql }}
          path: pytest-*.txt
          overwrite: true
      - name: Upload coverage artifact
        if: needs.info.outputs.skip_coverage != 'true'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: coverage-${{ matrix.python-version }}-${{
            steps.pytest-partial.outputs.postgresql }}
          path: coverage.xml
          overwrite: true
      - name: Beautify test results
        # For easier identification of parsing errors
        if: needs.info.outputs.skip_coverage != 'true'
        run: |
          xmllint --format "junit.xml" > "junit.xml-tmp"
          mv "junit.xml-tmp" "junit.xml"
      - name: Upload test results artifact
        if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: test-results-postgres-${{ matrix.python-version }}-${{
            steps.pytest-partial.outputs.postgresql }}
          path: junit.xml
      - name: Check dirty
        run: |
          ./script/check_dirty

  coverage-full:
    name: Upload test coverage to Codecov (full suite)
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - pytest-full
      - pytest-postgres
      - pytest-mariadb
    timeout-minutes: 10
    if: needs.info.outputs.skip_coverage != 'true'
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Download all coverage artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          pattern: coverage-*
      - name: Upload coverage to Codecov
        if: needs.info.outputs.test_full_suite == 'true'
        uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
        with:
          fail_ci_if_error: true
          flags: full-suite
          token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]

  pytest-partial:
    name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    needs:
      - info
      - base
      - gen-requirements-all
      - hassfest
      - prek
      - mypy
    if: |
      needs.info.outputs.lint_only != 'true'
      && needs.info.outputs.tests_glob
      && needs.info.outputs.test_full_suite == 'false'
    strategy:
      fail-fast: false
      matrix:
        python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
        group: ${{ fromJson(needs.info.outputs.test_groups) }}
    steps:
      - name: Restore apt cache
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: |
            ${{ env.APT_CACHE_DIR }}
            ${{ env.APT_LIST_CACHE_DIR }}
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
      - name: Install additional OS dependencies
        timeout-minutes: 10
        run: |
          sudo rm /etc/apt/sources.list.d/microsoft-prod.list
          sudo apt-get update \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR}
          sudo apt-get -y install \
            -o Dir::Cache=${APT_CACHE_DIR} \
            -o Dir::State::Lists=${APT_LIST_CACHE_DIR} \
            bluez \
            ffmpeg \
            libturbojpeg \
            libxml2-utils
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python ${{ matrix.python-version }}
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          check-latest: true
      - name: Restore full Python ${{ matrix.python-version }} virtual environment
        id: cache-venv
        uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: venv
          fail-on-cache-miss: true
          key: >-
            ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
            needs.info.outputs.python_cache_key }}
      - name: Register Python problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/python.json"
      - name: Register pytest slow test problem matcher
        run: |
          echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
      - name: Compile English translations
        run: |
          . venv/bin/activate
          python3 -m script.translations develop --all
      - name: Run pytest
        timeout-minutes: 10
        id: pytest-partial
        shell: bash
        env:
          PYTHONDONTWRITEBYTECODE: 1
          TEST_GROUP: ${{ matrix.group }}
          SKIP_COVERAGE: ${{ needs.info.outputs.skip_coverage }}
          PYTHON_VERSION: ${{ matrix.python-version }}
        run: |
          . venv/bin/activate
          python --version
          set -o pipefail

          if [[ ! -f "tests/components/${TEST_GROUP}/__init__.py" ]]; then
            echo "::error:: missing file tests/components/${TEST_GROUP}/__init__.py"
            exit 1
          fi

          cov_params=()
          if [[ "${SKIP_COVERAGE}" != "true" ]]; then
            cov_params+=(--cov="homeassistant.components.${TEST_GROUP}")
            cov_params+=(--cov-report=xml)
            cov_params+=(--cov-report=term-missing)
            cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
          fi

          python3 -b -X dev -m pytest \
            -qq \
            --timeout=9 \
            --numprocesses auto \
            --snapshot-details \
            ${cov_params[@]} \
            -o console_output_style=count \
            --durations=0 \
            --durations-min=1 \
            -p no:sugar \
            --exclude-warning-annotations \
            tests/components/${TEST_GROUP} \
              2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt
      - name: Upload pytest output
        if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
          path: pytest-*.txt
          overwrite: true
      - name: Upload coverage artifact
        if: needs.info.outputs.skip_coverage != 'true'
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
          path: coverage.xml
          overwrite: true
      - name: Beautify test results
        # For easier identification of parsing errors
        if: needs.info.outputs.skip_coverage != 'true'
        run: |
          xmllint --format "junit.xml" > "junit.xml-tmp"
          mv "junit.xml-tmp" "junit.xml"
      - name: Upload test results artifact
        if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
          path: junit.xml
      - name: Check dirty
        run: |
          ./script/check_dirty

  coverage-partial:
    name: Upload test coverage to Codecov (partial suite)
    if: needs.info.outputs.skip_coverage != 'true'
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    timeout-minutes: 10
    needs:
      - info
      - pytest-partial
    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Download all coverage artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          pattern: coverage-*
      - name: Upload coverage to Codecov
        if: needs.info.outputs.test_full_suite == 'false'
        uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
        with:
          fail_ci_if_error: true
          token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]

  upload-test-results:
    name: Upload test results to Codecov
    runs-on: ubuntu-24.04
    needs:
      - info
      - pytest-partial
      - pytest-full
      - pytest-postgres
      - pytest-mariadb
    timeout-minutes: 10
    permissions:
      id-token: write # For Codecov OIDC upload
    # codecov/test-results-action currently doesn't support tokenless uploads
    # therefore we can't run it on forks
    if: |
      (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
      && needs.info.outputs.skip_coverage != 'true' && !cancelled()
    steps:
      - name: Download all coverage artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          pattern: test-results-*
      - name: Upload test results to Codecov
        uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
        with:
          report_type: test_results
          fail_ci_if_error: true
          verbose: true
          use_oidc: true
codeql perms security .github/workflows/codeql.yml
Triggers
schedule
Runs on
ubuntu-latest
Jobs
analyze
Actions
github/codeql-action/init, github/codeql-action/analyze
View raw YAML
name: "CodeQL"

# yamllint disable-line rule:truthy
on:
  schedule:
    - cron: "30 18 * * 4"

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  analyze:
    name: Analyze
    runs-on: ubuntu-latest
    timeout-minutes: 360
    permissions:
      actions: read # To read workflow information for CodeQL
      contents: read # To check out the repository
      security-events: write # To upload CodeQL results

    steps:
      - name: Check out code from GitHub
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Initialize CodeQL
        uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
        with:
          languages: python

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
        with:
          category: "/language:python"
detect-duplicate-issues perms .github/workflows/detect-duplicate-issues.yml
Triggers
issues
Runs on
ubuntu-latest
Jobs
detect-duplicates
Actions
actions/ai-inference
View raw YAML
name: Auto-detect duplicate issues

# yamllint disable-line rule:truthy
on:
  issues:
    types: [labeled]

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.event.issue.number }}

jobs:
  detect-duplicates:
    name: Detect duplicate issues
    runs-on: ubuntu-latest
    permissions:
      issues: write # To comment on and label issues
      models: read # For AI-based duplicate detection

    steps:
      - name: Check if integration label was added and extract details
        id: extract
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            // Debug: Log the event payload
            console.log('Event name:', context.eventName);
            console.log('Event action:', context.payload.action);
            console.log('Event payload keys:', Object.keys(context.payload));

            // Check the specific label that was added
            const addedLabel = context.payload.label;
            if (!addedLabel) {
              console.log('No label found in labeled event payload');
              core.setOutput('should_continue', 'false');
              return;
            }

            console.log(`Label added: ${addedLabel.name}`);

            if (!addedLabel.name.startsWith('integration:')) {
              console.log('Added label is not an integration label, skipping duplicate detection');
              core.setOutput('should_continue', 'false');
              return;
            }

            console.log(`Integration label added: ${addedLabel.name}`);

            let currentIssue;
            let integrationLabels = [];

            try {
              const issue = await github.rest.issues.get({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.payload.issue.number
              });

              currentIssue = issue.data;

              // Check if potential-duplicate label already exists
              const hasPotentialDuplicateLabel = currentIssue.labels
                .some(label => label.name === 'potential-duplicate');

              if (hasPotentialDuplicateLabel) {
                console.log('Issue already has potential-duplicate label, skipping duplicate detection');
                core.setOutput('should_continue', 'false');
                return;
              }

              integrationLabels = currentIssue.labels
                .filter(label => label.name.startsWith('integration:'))
                .map(label => label.name);
            } catch (error) {
              core.error(`Failed to fetch issue #${context.payload.issue.number}:`, error.message);
              core.setOutput('should_continue', 'false');
              return;
            }

            // Check if we've already posted a duplicate detection comment recently
            let comments;
            try {
              comments = await github.rest.issues.listComments({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.payload.issue.number,
                per_page: 10
              });
            } catch (error) {
              core.error('Failed to fetch comments:', error.message);
              // Continue anyway, worst case we might post a duplicate comment
              comments = { data: [] };
            }

            // Check if we've already posted a duplicate detection comment
            const recentDuplicateComment = comments.data.find(comment =>
              comment.user && comment.user.login === 'github-actions[bot]' &&
              comment.body.includes('<!-- workflow: detect-duplicate-issues -->')
            );

            if (recentDuplicateComment) {
              console.log('Already posted duplicate detection comment, skipping');
              core.setOutput('should_continue', 'false');
              return;
            }

            core.setOutput('should_continue', 'true');
            core.setOutput('current_number', currentIssue.number);
            core.setOutput('current_title', currentIssue.title);
            core.setOutput('current_body', currentIssue.body);
            core.setOutput('current_url', currentIssue.html_url);
            core.setOutput('integration_labels', JSON.stringify(integrationLabels));

            console.log(`Current issue: #${currentIssue.number}`);
            console.log(`Integration labels: ${integrationLabels.join(', ')}`);

      - name: Fetch similar issues
        id: fetch_similar
        if: steps.extract.outputs.should_continue == 'true'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        env:
          INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
          CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
        with:
          script: |
            const integrationLabels = JSON.parse(process.env.INTEGRATION_LABELS);
            const currentNumber = parseInt(process.env.CURRENT_NUMBER);

            if (integrationLabels.length === 0) {
              console.log('No integration labels found, skipping duplicate detection');
              core.setOutput('has_similar', 'false');
              return;
            }

            // Use GitHub search API to find issues with matching integration labels
            console.log(`Searching for issues with integration labels: ${integrationLabels.join(', ')}`);

            // Build search query for issues with any of the current integration labels
            const labelQueries = integrationLabels.map(label => `label:"${label}"`);

            // Calculate date 6 months ago
            const sixMonthsAgo = new Date();
            sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
            const dateFilter = `created:>=${sixMonthsAgo.toISOString().split('T')[0]}`;

            let searchQuery;

            if (labelQueries.length === 1) {
              searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]} ${dateFilter}`;
            } else {
              searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')}) ${dateFilter}`;
            }

            console.log(`Search query: ${searchQuery}`);

            let result;
            try {
              result = await github.rest.search.issuesAndPullRequests({
                q: searchQuery,
                per_page: 15,
                sort: 'updated',
                order: 'desc'
              });
            } catch (error) {
              core.error('Failed to search for similar issues:', error.message);
              if (error.status === 403 && error.message.includes('rate limit')) {
                core.error('GitHub API rate limit exceeded');
              }
              core.setOutput('has_similar', 'false');
              return;
            }

            // Filter out the current issue, pull requests, and newer issues (higher numbers)
            const similarIssues = result.data.items
              .filter(item =>
                item.number !== currentNumber &&
                !item.pull_request &&
                item.number < currentNumber // Only include older issues (lower numbers)
              )
              .map(item => ({
                number: item.number,
                title: item.title,
                body: item.body,
                url: item.html_url,
                state: item.state,
                createdAt: item.created_at,
                updatedAt: item.updated_at,
                comments: item.comments,
                labels: item.labels.map(l => l.name)
              }));

            console.log(`Found ${similarIssues.length} issues with matching integration labels`);
            console.log('Raw similar issues:', JSON.stringify(similarIssues.slice(0, 3), null, 2));

            if (similarIssues.length === 0) {
              console.log('No similar issues found, setting has_similar to false');
              core.setOutput('has_similar', 'false');
              return;
            }

            console.log('Similar issues found, setting has_similar to true');
            core.setOutput('has_similar', 'true');

            // Clean the issue data to prevent JSON parsing issues
            const cleanedIssues = similarIssues.slice(0, 15).map(item => {
              // Handle body with improved truncation and null handling
              let cleanBody = '';
              if (item.body && typeof item.body === 'string') {
                // Remove control characters
                const cleaned = item.body.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
                // Truncate to 1000 characters and add ellipsis if needed
                cleanBody = cleaned.length > 1000
                  ? cleaned.substring(0, 1000) + '...'
                  : cleaned;
              }

              return {
                number: item.number,
                title: item.title.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''), // Remove control characters
                body: cleanBody,
                url: item.url,
                state: item.state,
                createdAt: item.createdAt,
                updatedAt: item.updatedAt,
                comments: item.comments,
                labels: item.labels
              };
            });

            console.log(`Cleaned issues count: ${cleanedIssues.length}`);
            console.log('First cleaned issue:', JSON.stringify(cleanedIssues[0], null, 2));

            core.setOutput('similar_issues', JSON.stringify(cleanedIssues));

      - name: Detect duplicates using AI
        id: ai_detection
        if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
        uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
        with:
          model: openai/gpt-4o
          system-prompt: |
            You are a Home Assistant issue duplicate detector. Your task is to identify TRUE DUPLICATES - issues that report the EXACT SAME problem, not just similar or related issues.

            CRITICAL: An issue is ONLY a duplicate if:
            - It describes the SAME problem with the SAME root cause
            - Issues about the same integration but different problems are NOT duplicates
            - Issues with similar symptoms but different causes are NOT duplicates

            Important considerations:
            - Open issues are more relevant than closed ones for duplicate detection
            - Recently updated issues may indicate ongoing work or discussion
            - Issues with more comments are generally more relevant and active
            - Older closed issues might be resolved differently than newer approaches
            - Consider the time between issues - very old issues may have different contexts

            Rules:
            1. ONLY mark as duplicate if the issues describe IDENTICAL problems
            2. Look for issues that report the same problem or request the same functionality
            3. Different error messages = NOT a duplicate (even if same integration)
            4. For CLOSED issues, only mark as duplicate if they describe the EXACT same problem
            5. For OPEN issues, use a lower threshold (90%+ similarity)
            6. Prioritize issues with higher comment counts as they indicate more activity/relevance
            7. When in doubt, do NOT mark as duplicate
            8. Return ONLY a JSON array of issue numbers that are duplicates
            9. If no duplicates are found, return an empty array: []
            10. Maximum 5 potential duplicates, prioritize open issues with comments
            11. Consider the age of issues - prefer recent duplicates over very old ones

            Example response format:
            [1234, 5678, 9012]

          prompt: |
            Current issue (just created):
            Title: ${{ steps.extract.outputs.current_title }}
            Body: ${{ steps.extract.outputs.current_body }}

            Other issues to compare against (each includes state, creation date, last update, and comment count):
            ${{ steps.fetch_similar.outputs.similar_issues }}

            Analyze these issues and identify which ones describe IDENTICAL problems and thus are duplicates of the current issue. When sorting them, consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant).

          max-tokens: 100

      - name: Post duplicate detection results
        id: post_results
        if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        env:
          AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
          SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}
        with:
          script: |
            const aiResponse = process.env.AI_RESPONSE;

            console.log('Raw AI response:', JSON.stringify(aiResponse));

            let duplicateNumbers = [];
            try {
              // Clean the response of any potential control characters
              const cleanResponse = aiResponse.trim().replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
              console.log('Cleaned AI response:', cleanResponse);

              duplicateNumbers = JSON.parse(cleanResponse);

              // Ensure it's an array and contains only numbers
              if (!Array.isArray(duplicateNumbers)) {
                console.log('AI response is not an array, trying to extract numbers');
                const numberMatches = cleanResponse.match(/\d+/g);
                duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
              }

              // Filter to only valid numbers
              duplicateNumbers = duplicateNumbers.filter(n => typeof n === 'number' && !isNaN(n));

            } catch (error) {
              console.log('Failed to parse AI response as JSON:', error.message);
              console.log('Raw response:', aiResponse);

              // Fallback: try to extract numbers from the response
              const numberMatches = aiResponse.match(/\d+/g);
              duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
              console.log('Extracted numbers as fallback:', duplicateNumbers);
            }

            if (!Array.isArray(duplicateNumbers) || duplicateNumbers.length === 0) {
              console.log('No duplicates detected by AI');
              return;
            }

            console.log(`AI detected ${duplicateNumbers.length} potential duplicates: ${duplicateNumbers.join(', ')}`);

            // Get details of detected duplicates
            const similarIssues = JSON.parse(process.env.SIMILAR_ISSUES);
            const duplicates = similarIssues.filter(issue => duplicateNumbers.includes(issue.number));

            if (duplicates.length === 0) {
              console.log('No matching issues found for detected numbers');
              return;
            }

            // Create comment with duplicate detection results
            const duplicateLinks = duplicates.map(issue => `- [#${issue.number}: ${issue.title}](${issue.url})`).join('\n');

            const commentBody = [
              '<!-- workflow: detect-duplicate-issues -->',
              '### 🔍 **Potential duplicate detection**',
              '',
              'I\'ve analyzed similar issues and found the following potential duplicates:',
              '',
              duplicateLinks,
              '',
              '**What to do next:**',
              '1. Please review these issues to see if they match your issue',
              '2. If you find an existing issue that covers your problem:',
              '   - Consider closing this issue',
              '   - Add your findings or 👍 on the existing issue instead',
              '3. If your issue is different or adds new aspects, please clarify how it differs',
              '',
              'This helps keep our issues organized and ensures similar issues are consolidated for better visibility.',
              '',
              '*This message was generated automatically by our duplicate detection system.*'
            ].join('\n');

            try {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.payload.issue.number,
                body: commentBody
              });

              console.log(`Posted duplicate detection comment with ${duplicates.length} potential duplicates`);

              // Add the potential-duplicate label
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.payload.issue.number,
                labels: ['potential-duplicate']
              });

              console.log('Added potential-duplicate label to the issue');
            } catch (error) {
              core.error('Failed to post duplicate detection comment or add label:', error.message);
              if (error.status === 403) {
                core.error('Permission denied or rate limit exceeded');
              }
              // Don't throw - we've done the analysis, just couldn't post the result
            }
detect-non-english-issues perms .github/workflows/detect-non-english-issues.yml
Triggers
issues
Runs on
ubuntu-latest
Jobs
detect-language
Actions
actions/ai-inference
View raw YAML
name: Auto-detect non-English issues

# yamllint disable-line rule:truthy
on:
  issues:
    types: [opened]

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.event.issue.number }}

jobs:
  detect-language:
    name: Detect non-English issues
    runs-on: ubuntu-latest
    permissions:
      issues: write # To comment on, label, and close issues
      models: read # For AI-based language detection

    steps:
      - name: Check issue language
        id: detect_language
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        env:
          ISSUE_NUMBER: ${{ github.event.issue.number }}
          ISSUE_TITLE: ${{ github.event.issue.title }}
          ISSUE_BODY: ${{ github.event.issue.body }}
          ISSUE_USER_TYPE: ${{ github.event.issue.user.type }}
        with:
          script: |
            // Get the issue details from environment variables
            const issueNumber = process.env.ISSUE_NUMBER;
            const issueTitle = process.env.ISSUE_TITLE || '';
            const issueBody = process.env.ISSUE_BODY || '';
            const userType = process.env.ISSUE_USER_TYPE;

            // Skip language detection for bot users
            if (userType === 'Bot') {
              console.log('Skipping language detection for bot user');
              core.setOutput('should_continue', 'false');
              return;
            }

            console.log(`Checking language for issue #${issueNumber}`);
            console.log(`Title: ${issueTitle}`);

            // Combine title and body for language detection
            const fullText = `${issueTitle}\n\n${issueBody}`;

            // Check if the text is too short to reliably detect language
            if (fullText.trim().length < 20) {
              console.log('Text too short for reliable language detection');
              core.setOutput('should_continue', 'false'); // Skip processing for very short text
              return;
            }

            core.setOutput('issue_number', issueNumber);
            core.setOutput('issue_text', fullText);
            core.setOutput('should_continue', 'true');

      - name: Detect language using AI
        id: ai_language_detection
        if: steps.detect_language.outputs.should_continue == 'true'
        uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
        with:
          model: openai/gpt-4o-mini
          system-prompt: |
            You are a language detection system. Your task is to determine if the provided text is written in English or another language.

            Rules:
            1. Analyze the text and determine the primary language of the USER'S DESCRIPTION only
            2. IGNORE markdown headers (lines starting with #, ##, ###, etc.) as these are from issue templates, not user input
            3. IGNORE all code blocks (text between ``` or ` markers) as they may contain system-generated error messages in other languages
            4. IGNORE error messages, logs, and system output even if not in code blocks - these often appear in the user's system language
            5. Consider technical terms, code snippets, URLs, and file paths as neutral (they don't indicate non-English)
            6. Focus ONLY on the actual sentences and descriptions written by the user explaining their issue
            7. If the user's explanation/description is in English but includes non-English error messages or logs, consider it ENGLISH
            8. Return ONLY a JSON object with two fields:
               - "is_english": boolean (true if the user's description is primarily in English, false otherwise)
               - "detected_language": string (the name of the detected language, e.g., "English", "Spanish", "Chinese", etc.)
            9. Be lenient - if the user's explanation is in English with non-English system output, it's still English
            10. Common programming terms, error messages, and technical jargon should not be considered as non-English
            11. If you cannot reliably determine the language, set detected_language to "undefined"

            Example response:
            {"is_english": false, "detected_language": "Spanish"}

          prompt: |
            Please analyze the following issue text and determine if it is written in English:

            ${{ steps.detect_language.outputs.issue_text }}

          max-tokens: 50

      - name: Process non-English issues
        if: steps.detect_language.outputs.should_continue == 'true'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        env:
          AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }}
          ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }}
        with:
          script: |
            const issueNumber = parseInt(process.env.ISSUE_NUMBER);
            const aiResponse = process.env.AI_RESPONSE;

            console.log('AI language detection response:', aiResponse);

            let languageResult;
            try {
              languageResult = JSON.parse(aiResponse.trim());

              // Validate the response structure
              if (!languageResult || typeof languageResult.is_english !== 'boolean') {
                throw new Error('Invalid response structure');
              }
            } catch (error) {
              core.error(`Failed to parse AI response: ${error.message}`);
              console.log('Raw AI response:', aiResponse);

              // Log more details for debugging
              core.warning('Defaulting to English due to parsing error');

              // Default to English if we can't parse the response
              return;
            }

            if (languageResult.is_english) {
              console.log('Issue is in English, no action needed');
              return;
            }

            // If language is undefined or not detected, skip processing
            if (!languageResult.detected_language || languageResult.detected_language === 'undefined') {
              console.log('Language could not be determined, skipping processing');
              return;
            }

            console.log(`Issue detected as non-English: ${languageResult.detected_language}`);

            // Post comment explaining the language requirement
            const commentBody = [
              '<!-- workflow: detect-non-english-issues -->',
              '### 🌐 Non-English issue detected',
              '',
              `This issue appears to be written in **${languageResult.detected_language}** rather than English.`,
              '',
              'The Home Assistant project uses English as the primary language for issues to ensure that everyone in our international community can participate and help resolve issues. This allows any of our thousands of contributors to jump in and provide assistance.',
              '',
              '**What to do:**',
              '1. Re-create the issue using the English language',
              '2. If you need help with translation, consider using:',
              '   - Translation tools like Google Translate',
              '   - AI assistants like ChatGPT or Claude',
              '',
              'This helps our community provide the best possible support and ensures your issue gets the attention it deserves from our global contributor base.',
              '',
              'Thank you for your understanding! 🙏'
            ].join('\n');

            try {
              // Add comment
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueNumber,
                body: commentBody
              });

              console.log('Posted language requirement comment');

              // Add non-english label
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueNumber,
                labels: ['non-english']
              });

              console.log('Added non-english label');

              // Close the issue
              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueNumber,
                state: 'closed',
                state_reason: 'not_planned'
              });

              console.log('Closed the issue');

            } catch (error) {
              core.error('Failed to process non-English issue:', error.message);
              if (error.status === 403) {
                core.error('Permission denied or rate limit exceeded');
              }
            }
lock perms .github/workflows/lock.yml
Triggers
schedule
Runs on
ubuntu-latest
Jobs
lock
Actions
dessant/lock-threads
View raw YAML
name: Lock

# yamllint disable-line rule:truthy
on:
  schedule:
    - cron: "0 * * * *"

permissions: {}

concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true

jobs:
  lock:
    name: Lock inactive threads
    if: github.repository_owner == 'home-assistant'
    runs-on: ubuntu-latest
    permissions:
      issues: write # To lock issues
      pull-requests: write # To lock pull requests
    steps:
      - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
        with:
          github-token: ${{ github.token }}
          issue-inactive-days: "30"
          exclude-issue-created-before: "2020-10-01T00:00:00Z"
          issue-lock-reason: ""
          pr-inactive-days: "1"
          exclude-pr-created-before: "2020-11-01T00:00:00Z"
          pr-lock-reason: ""
restrict-task-creation perms .github/workflows/restrict-task-creation.yml
Triggers
issues
Runs on
ubuntu-latest, ubuntu-latest
Jobs
add-no-stale, check-authorization
View raw YAML
name: Restrict task creation

# yamllint disable-line rule:truthy
on:
  issues:
    types: [opened]

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.event.issue.number }}

jobs:
  add-no-stale:
    name: Add no-stale label
    runs-on: ubuntu-latest
    permissions:
      issues: write # To add labels to issues
    if: >-
      github.event.issue.type.name == 'Task'
      || github.event.issue.type.name == 'Epic'
      || github.event.issue.type.name == 'Opportunity'
    steps:
      - name: Add no-stale label
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              labels: ['no-stale']
            });

  check-authorization:
    name: Check authorization
    runs-on: ubuntu-latest
    permissions:
      contents: read # To read CODEOWNERS file
      issues: write # To comment on, label, and close issues
    # Only run if this is a Task issue type (from the issue form)
    if: github.event.issue.type.name == 'Task'
    steps:
      - name: Check if user is authorized
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const issueAuthor = context.payload.issue.user.login;

            // First check if user is an organization member
            try {
              await github.rest.orgs.checkMembershipForUser({
                org: 'home-assistant',
                username: issueAuthor
              });
              console.log(`✅ ${issueAuthor} is an organization member`);
              return; // Authorized, no need to check further
            } catch (error) {
              console.log(`ℹ️ ${issueAuthor} is not an organization member, checking codeowners...`);
            }

            // If not an org member, check if they're a codeowner
            try {
              // Fetch CODEOWNERS file from the repository
              const { data: codeownersFile } = await github.rest.repos.getContent({
                owner: context.repo.owner,
                repo: context.repo.repo,
                path: 'CODEOWNERS',
                ref: 'dev'
              });

              // Decode the content (it's base64 encoded)
              const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8');

              // Check if the issue author is mentioned in CODEOWNERS
              // GitHub usernames in CODEOWNERS are prefixed with @
              if (codeownersContent.includes(`@${issueAuthor}`)) {
                console.log(`✅ ${issueAuthor} is a integration code owner`);
                return; // Authorized
              }
            } catch (error) {
              console.error('Error checking CODEOWNERS:', error);
            }

            // If we reach here, user is not authorized
            console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);

            // Close the issue with a comment
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
                    `Task issues are restricted to Open Home Foundation staff, authorized contributors, and integration code owners.\n\n` +
                    `If you would like to:\n` +
                    `- Report a bug: Please use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)\n` +
                    `- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
                    `If you believe you should have access to create Task issues, please contact the maintainers.`
            });

            await github.rest.issues.update({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              state: 'closed'
            });

            // Add a label to indicate this was auto-closed
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              labels: ['auto-closed']
            });
stale perms .github/workflows/stale.yml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest
Jobs
stale
Actions
actions/stale, tibdex/github-app-token, actions/stale, actions/stale
View raw YAML
name: Stale

# yamllint disable-line rule:truthy
on:
  schedule:
    - cron: "0 * * * *"
  workflow_dispatch:

permissions: {}

concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true

jobs:
  stale:
    name: Mark stale issues and PRs
    if: github.repository_owner == 'home-assistant'
    runs-on: ubuntu-latest
    permissions:
      issues: write # To label and close stale issues
      pull-requests: write # To label and close stale PRs
    steps:
      # The 60 day stale policy for PRs
      # Used for:
      # - PRs
      # - No PRs marked as no-stale
      # - No issues (-1)
      - name: 60 days stale PRs policy
        uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          days-before-stale: 60
          days-before-close: 7
          days-before-issue-stale: -1
          days-before-issue-close: -1
          operations-per-run: 150
          remove-stale-when-updated: true
          stale-pr-label: "stale"
          exempt-pr-labels: "no-stale"
          stale-pr-message: >
            There hasn't been any activity on this pull request recently. This
            pull request has been automatically marked as stale because of that
            and will be closed if no further activity occurs within 7 days.

            If you are the author of this PR, please leave a comment if you want
            to keep it open. Also, please rebase your PR onto the latest dev
            branch to ensure that it's up to date with the latest changes.

            Thank you for your contribution!

      # Generate a token for the GitHub App, we use this method to avoid
      # hitting API limits for our GitHub actions + have a higher rate limit.
      # This is only used for issues.
      - name: Generate app token
        id: token
        # Pinned to a specific version of the action for security reasons
        # v1.7.0
        uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
        with:
          app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
          private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]

      # The 90 day stale policy for issues
      # Used for:
      # - Issues
      # - No issues marked as no-stale or help-wanted
      # - No PRs (-1)
      - name: 90 days stale issues
        uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
        with:
          repo-token: ${{ steps.token.outputs.token }}
          days-before-stale: 90
          days-before-close: 7
          days-before-pr-stale: -1
          days-before-pr-close: -1
          operations-per-run: 250
          remove-stale-when-updated: true
          stale-issue-label: "stale"
          exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
          stale-issue-message: >
            There hasn't been any activity on this issue recently. Due to the
            high number of incoming GitHub notifications, we have to clean some
            of the old issues, as many of them have already been resolved with
            the latest updates.

            Please make sure to update to the latest Home Assistant version and
            check if that solves the issue. Let us know if that works for you by
            adding a comment 👍

            This issue has now been marked as stale and will be closed if no
            further activity occurs. Thank you for your contributions.

      # The 30 day stale policy for issues
      # Used for:
      # - Issues that are pending more information (incomplete issues)
      # - No Issues marked as no-stale or help-wanted
      # - No PRs (-1)
      - name: Needs more information stale issues policy
        uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
        with:
          repo-token: ${{ steps.token.outputs.token }}
          only-labels: "needs-more-information"
          days-before-stale: 14
          days-before-close: 7
          days-before-pr-stale: -1
          days-before-pr-close: -1
          operations-per-run: 250
          remove-stale-when-updated: true
          stale-issue-label: "stale"
          exempt-issue-labels: "no-stale,help-wanted"
          stale-issue-message: >
            There hasn't been any activity on this issue recently. Due to the
            high number of incoming GitHub notifications, we have to clean some
            of the old issues, as many of them have already been resolved with
            the latest updates.

            Please make sure to update to the latest Home Assistant version and
            check if that solves the issue. Let us know if that works for you by
            adding a comment 👍

            This issue has now been marked as stale and will be closed if no
            further activity occurs. Thank you for your contributions.
translations perms .github/workflows/translations.yml
Triggers
workflow_dispatch, push
Runs on
ubuntu-latest
Jobs
upload
Commands
  • python3 -m script.translations upload
View raw YAML
name: Translations

# yamllint disable-line rule:truthy
on:
  workflow_dispatch:
  push:
    branches:
      - dev
    paths:
      - "**strings.json"

permissions: {}

concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true

jobs:
  upload:
    name: Upload
    if: github.repository_owner == 'home-assistant'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"

      - name: Upload Translations
        env:
          LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
        run: |
          python3 -m script.translations upload
wheels matrix perms .github/workflows/wheels.yml
Triggers
workflow_dispatch, schedule, push
Runs on
ubuntu-latest, ${{ matrix.os }}, ${{ matrix.os }}
Jobs
init, core, integrations
Matrix
abi, arch, include, include.arch, include.os→ aarch64, amd64, cp314, ubuntu-24.04-arm, ubuntu-latest
Actions
home-assistant/wheels, home-assistant/wheels
Commands
  • python -m venv venv . venv/bin/activate python --version pip install "$(grep '^uv' < requirements.txt)" uv pip install -r requirements.txt
  • if [[ "${GITHUB_EVENT_NAME}" =~ (schedule|workflow_dispatch) ]]; then touch requirements_diff.txt else curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt fi
  • ( echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" # Fix out of memory issues with rust echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" # OpenCV headless installation echo "CI_BUILD=1" echo "ENABLE_HEADLESS=1" # Use C-Extension for SQLAlchemy echo "REQUIRE_SQLALCHEMY_CEXT=1" ) > .env_file
  • . venv/bin/activate python -m script.gen_requirements_all ci
  • # Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt
  • # Do not pin numpy in wheels building sed -i "/numpy/d" homeassistant/package_constraints.txt # Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt
View raw YAML
name: Build wheels

# yamllint disable-line rule:truthy
on:
  workflow_dispatch:
  schedule:
    - cron: "0 4 * * *"
  push:
    branches:
      - dev
      - rc
    paths:
      - ".github/workflows/wheels.yml"
      - "homeassistant/package_constraints.txt"
      - "requirements_all.txt"
      - "requirements.txt"
      - "script/gen_requirements_all.py"

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.ref_name}}
  cancel-in-progress: true

jobs:
  init:
    name: Initialize wheels builder
    if: github.repository_owner == 'home-assistant'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Python
        id: python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version-file: ".python-version"
          check-latest: true

      - name: Create Python virtual environment
        run: |
          python -m venv venv
          . venv/bin/activate
          python --version
          pip install "$(grep '^uv' < requirements.txt)"
          uv pip install -r requirements.txt

      - name: Create requirements_diff file
        run: |
          if [[ "${GITHUB_EVENT_NAME}" =~ (schedule|workflow_dispatch) ]]; then
            touch requirements_diff.txt
          else
            curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt
          fi

      - name: Write env-file
        run: |
          (
            echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true"
            echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true"

            # Fix out of memory issues with rust
            echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"

            # OpenCV headless installation
            echo "CI_BUILD=1"
            echo "ENABLE_HEADLESS=1"

            # Use C-Extension for SQLAlchemy
            echo "REQUIRE_SQLALCHEMY_CEXT=1"
          ) > .env_file

      - name: Upload env_file
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: env_file
          path: ./.env_file
          include-hidden-files: true
          overwrite: true

      - name: Upload requirements_diff
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: requirements_diff
          path: ./requirements_diff.txt
          overwrite: true

      - name: Generate requirements
        run: |
          . venv/bin/activate
          python -m script.gen_requirements_all ci

      - name: Upload requirements_all_wheels
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: requirements_all_wheels
          path: ./requirements_all_wheels_*.txt

  core:
    name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
    if: github.repository_owner == 'home-assistant'
    needs: init
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        abi: ["cp314"]
        arch: ["amd64", "aarch64"]
        include:
          - arch: amd64
            os: ubuntu-latest
          - arch: aarch64
            os: ubuntu-24.04-arm
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Download env_file
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: env_file

      - name: Download requirements_diff
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: requirements_diff

      - name: Adjust build env
        run: |
          # Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
          sed -i "/uv/d" requirements.txt
          sed -i "/uv/d" requirements_diff.txt

      - name: Build wheels
        uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
        with:
          abi: ${{ matrix.abi }}
          tag: musllinux_1_2
          arch: ${{ matrix.arch }}
          wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
          env-file: true
          apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
          skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
          constraints: "homeassistant/package_constraints.txt"
          requirements-diff: "requirements_diff.txt"
          requirements: "requirements.txt"

  integrations:
    name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }}
    if: github.repository_owner == 'home-assistant'
    needs: init
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        abi: ["cp314"]
        arch: ["amd64", "aarch64"]
        include:
          - arch: amd64
            os: ubuntu-latest
          - arch: aarch64
            os: ubuntu-24.04-arm
    steps:
      - name: Checkout the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Download env_file
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: env_file

      - name: Download requirements_diff
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: requirements_diff

      - name: Download requirements_all_wheels
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: requirements_all_wheels

      - name: Adjust build env
        run: |
          # Do not pin numpy in wheels building
          sed -i "/numpy/d" homeassistant/package_constraints.txt
          # Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
          sed -i "/uv/d" requirements.txt
          sed -i "/uv/d" requirements_diff.txt

      - name: Build wheels
        uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
        with:
          abi: ${{ matrix.abi }}
          tag: musllinux_1_2
          arch: ${{ matrix.arch }}
          wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
          env-file: true
          apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
          skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
          constraints: "homeassistant/package_constraints.txt"
          requirements-diff: "requirements_diff.txt"
          requirements: "requirements_all_wheels_${{ matrix.arch }}.txt"