diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index e1f95c5c0a9..d206f8fe8c8 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -482,3 +482,56 @@ jobs: export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}" twine upload dist/* --skip-existing + + hassfest-image: + name: Build and test hassfest image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + needs: ["init", "build_base"] + 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Login to GitHub Container Registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Docker image + uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0 + with: + context: ./script/hassfest/docker + build-args: BASE_IMAGE=ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }} + load: true + tags: ${{ env.HASSFEST_IMAGE_TAG }} + + - name: Run hassfest against core + run: docker run --rm -v ${{ github.workspace }}/homeassistant:/github/workspace/homeassistant ${{ env.HASSFEST_IMAGE_TAG }} --core-integrations-path=/github/workspace/homeassistant/components + + - name: Push Docker image + if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' + id: push + uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0 + with: + context: ./script/hassfest/docker + build-args: BASE_IMAGE=ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }} + 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-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 + with: + subject-name: ${{ env.HASSFEST_IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e8fdce6fa15..b2165289ad8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -15,7 +15,7 @@ import tomllib from typing import Any from homeassistant.util.yaml.loader import load_yaml -from script.hassfest.model import Integration +from script.hassfest.model import Config, Integration # Requirements which can't be installed on all systems because they rely on additional # system packages. Requirements listed in EXCLUDED_REQUIREMENTS_ALL will be commented-out @@ -270,7 +270,9 @@ def gather_recursive_requirements( seen = set() seen.add(domain) - integration = Integration(Path(f"homeassistant/components/{domain}")) + integration = Integration( + Path(f"homeassistant/components/{domain}"), _get_hassfest_config() + ) integration.load_manifest() reqs = {x for x in integration.requirements if x not in CONSTRAINT_BASE} for dep_domain in integration.dependencies: @@ -336,7 +338,8 @@ def gather_requirements_from_manifests( errors: list[str], reqs: dict[str, list[str]] ) -> None: """Gather all of the requirements from manifests.""" - integrations = Integration.load_dir(Path("homeassistant/components")) + config = _get_hassfest_config() + integrations = Integration.load_dir(config.core_integrations_path, config) for domain in sorted(integrations): integration = integrations[domain] @@ -584,6 +587,17 @@ def main(validate: bool, ci: bool) -> int: return 0 +def _get_hassfest_config() -> Config: + """Get hassfest config.""" + return Config( + root=Path(".").absolute(), + specific_integrations=None, + action="validate", + requirements=True, + core_integrations_path=Path("homeassistant/components"), + ) + + if __name__ == "__main__": _VAL = sys.argv[-1] == "validate" _CI = sys.argv[-1] == "ci" diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index ea3c56200a2..b48871b4651 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -107,6 +107,12 @@ def get_config() -> Config: default=ALL_PLUGIN_NAMES, help="Comma-separate list of plugins to run. Valid plugin names: %(default)s", ) + parser.add_argument( + "--core-integrations-path", + type=pathlib.Path, + default=pathlib.Path("homeassistant/components"), + help="Path to core integrations", + ) parsed = parser.parse_args() if parsed.action is None: @@ -129,6 +135,7 @@ def get_config() -> Config: action=parsed.action, requirements=parsed.requirements, plugins=set(parsed.plugins), + core_integrations_path=parsed.core_integrations_path, ) @@ -146,12 +153,12 @@ def main() -> int: integrations = {} for int_path in config.specific_integrations: - integration = Integration(int_path) + integration = Integration(int_path, config) integration.load_manifest() integrations[integration.domain] = integration else: - integrations = Integration.load_dir(pathlib.Path("homeassistant/components")) + integrations = Integration.load_dir(config.core_integrations_path, config) plugins += HASS_PLUGINS for plugin in plugins: diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile new file mode 100644 index 00000000000..8921d92307e --- /dev/null +++ b/script/hassfest/docker/Dockerfile @@ -0,0 +1,22 @@ +ARG BASE_IMAGE=ghcr.io/home-assistant/home-assistant:beta +FROM $BASE_IMAGE + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +COPY entrypoint.sh /entrypoint.sh + +RUN \ + uv pip install stdlib-list==0.10.0 \ + $(grep -e "^pipdeptree" -e "^tqdm" /usr/src/homeassistant/requirements_test.txt) \ + $(grep -e "^ruff" /usr/src/homeassistant/requirements_test_pre_commit.txt) + +WORKDIR "/github/workspace" +ENTRYPOINT ["/entrypoint.sh"] + +LABEL "name"="hassfest" +LABEL "maintainer"="Home Assistant " + +LABEL "com.github.actions.name"="hassfest" +LABEL "com.github.actions.description"="Run hassfest to validate standalone integration repositories" +LABEL "com.github.actions.icon"="terminal" +LABEL "com.github.actions.color"="gray-dark" diff --git a/script/hassfest/docker/entrypoint.sh b/script/hassfest/docker/entrypoint.sh new file mode 100755 index 00000000000..33330f63161 --- /dev/null +++ b/script/hassfest/docker/entrypoint.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bashio +declare -a integrations +declare integration_path + +shopt -s globstar nullglob +for manifest in **/manifest.json; do + manifest_path=$(realpath "${manifest}") + integrations+=(--integration-path "${manifest_path%/*}") +done + +if [[ ${#integrations[@]} -eq 0 ]]; then + bashio::exit.nok "No integrations found!" +fi + +cd /usr/src/homeassistant +exec python3 -m script.hassfest --action validate "${integrations[@]}" "$@" \ No newline at end of file diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 736fb6874be..63e9b025ed4 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -29,6 +29,7 @@ class Config: root: pathlib.Path action: Literal["validate", "generate"] requirements: bool + core_integrations_path: pathlib.Path errors: list[Error] = field(default_factory=list) cache: dict[str, Any] = field(default_factory=dict) plugins: set[str] = field(default_factory=set) @@ -105,7 +106,7 @@ class Integration: """Represent an integration in our validator.""" @classmethod - def load_dir(cls, path: pathlib.Path) -> dict[str, Integration]: + def load_dir(cls, path: pathlib.Path, config: Config) -> dict[str, Integration]: """Load all integrations in a directory.""" assert path.is_dir() integrations: dict[str, Integration] = {} @@ -123,13 +124,14 @@ class Integration: ) continue - integration = cls(fil) + integration = cls(fil, config) integration.load_manifest() integrations[integration.domain] = integration return integrations path: pathlib.Path + _config: Config _manifest: dict[str, Any] | None = None manifest_path: pathlib.Path | None = None errors: list[Error] = field(default_factory=list) @@ -150,7 +152,9 @@ class Integration: @property def core(self) -> bool: """Core integration.""" - return self.path.as_posix().startswith("homeassistant/components") + return self.path.as_posix().startswith( + self._config.core_integrations_path.as_posix() + ) @property def disabled(self) -> str | None: diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index f3b008a6113..433e63d904c 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from script.hassfest.model import Integration +from script.hassfest.model import Config, Integration from script.hassfest.requirements import validate_requirements_format @@ -13,6 +13,13 @@ def integration(): """Fixture for hassfest integration model.""" return Integration( path=Path("homeassistant/components/test"), + _config=Config( + root=Path(".").absolute(), + specific_integrations=None, + action="validate", + requirements=True, + core_integrations_path=Path("homeassistant/components"), + ), _manifest={ "domain": "test", "documentation": "https://example.com", diff --git a/tests/hassfest/test_version.py b/tests/hassfest/test_version.py index bfe15018fe2..30677356101 100644 --- a/tests/hassfest/test_version.py +++ b/tests/hassfest/test_version.py @@ -1,5 +1,7 @@ """Tests for hassfest version.""" +from pathlib import Path + import pytest import voluptuous as vol @@ -7,13 +9,22 @@ from script.hassfest.manifest import ( CUSTOM_INTEGRATION_MANIFEST_SCHEMA, validate_version, ) -from script.hassfest.model import Integration +from script.hassfest.model import Config, Integration @pytest.fixture def integration(): """Fixture for hassfest integration model.""" - integration = Integration("") + integration = Integration( + "", + _config=Config( + root=Path(".").absolute(), + specific_integrations=None, + action="validate", + requirements=True, + core_integrations_path=Path("homeassistant/components"), + ), + ) integration._manifest = { "domain": "test", "documentation": "https://example.com",