#!/usr/bin/env bash set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ENV_FILE="${HA_ADDON_ENV_FILE:-$SCRIPT_DIR/.env}" [[ -f "$ENV_FILE" ]] || { echo "Missing $ENV_FILE. Copy $SCRIPT_DIR/.env.example to $SCRIPT_DIR/.env and edit it." >&2 exit 1 } set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a required=(REGISTRY IMAGE_NAME DISTRO RUST_VERSION BUILD_PLATFORMS BUILDER HA_REPO_URL ADDON_DIR INTEGRATION_DOMAIN) for name in "${required[@]}"; do [[ -n "${!name:-}" ]] || { echo "Missing $name in $ENV_FILE" >&2; exit 1; } done case "$DISTRO" in trixie|alpine) ;; *) echo "DISTRO must be one of: trixie, alpine" >&2; exit 2 ;; esac ACTION="${1:-push}" VERSION="$(awk -F '"' '/^version = "/ {print $2; exit}' "$PROJECT_ROOT/Cargo.toml")" [[ -n "$VERSION" ]] || { echo "Cannot determine version from Cargo.toml" >&2; exit 1; } IMAGE_REPO="${REGISTRY%/}/${IMAGE_NAME#/}" IMAGE_TAG="${IMAGE_REPO}:${VERSION}" CONFIG="$SCRIPT_DIR/repository/$ADDON_DIR/config.yaml" INTEGRATION_MANIFEST="$SCRIPT_DIR/home-assistant/custom_components/$INTEGRATION_DOMAIN/manifest.json" REPOSITORY_CONFIG="$SCRIPT_DIR/repository/repository.yaml" RUN_SCRIPT="$SCRIPT_DIR/run.sh" DOCKERFILE="$SCRIPT_DIR/Dockerfile" [[ -f "$CONFIG" ]] || { echo "Missing add-on config: $CONFIG" >&2; exit 1; } [[ -f "$INTEGRATION_MANIFEST" ]] || { echo "Missing integration manifest: $INTEGRATION_MANIFEST" >&2; exit 1; } [[ -f "$REPOSITORY_CONFIG" ]] || { echo "Missing repository config: $REPOSITORY_CONFIG" >&2; exit 1; } render_metadata() { command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; } python3 - "$CONFIG" "$INTEGRATION_MANIFEST" "$REPOSITORY_CONFIG" "$VERSION" "$IMAGE_REPO" "$HA_REPO_URL" <<'PY' from pathlib import Path import sys config = Path(sys.argv[1]) manifest = Path(sys.argv[2]) repository = Path(sys.argv[3]) version = sys.argv[4] image = sys.argv[5] repo_url = sys.argv[6] lines = config.read_text(encoding="utf-8").splitlines() out = [] for line in lines: if line.startswith("version:"): out.append(f'version: "{version}"') elif line.startswith("image:"): out.append(f'image: "{image}"') elif line.startswith("url:"): out.append(f'url: "{repo_url}"') else: out.append(line) config.write_text("\n".join(out) + "\n", encoding="utf-8") import json data = json.loads(manifest.read_text(encoding="utf-8")) data["version"] = version manifest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") repo_lines = repository.read_text(encoding="utf-8").splitlines() repo_out = [f"url: {repo_url}" if line.startswith("url:") else line for line in repo_lines] repository.write_text("\n".join(repo_out) + "\n", encoding="utf-8") PY printf 'Synced version %s; image %s\n' "$VERSION" "$IMAGE_REPO" } validate_addon_port_contract() { local ingress_port ingress_port="$(awk '/^ingress_port:/ {print $2; exit}' "$CONFIG")" [[ "$ingress_port" =~ ^[0-9]+$ ]] || { echo "Invalid ingress_port in $CONFIG" >&2; exit 1; } grep -Fqx "readonly HA_HTTP_PORT=${ingress_port}" "$RUN_SCRIPT" || { echo "HA port contract mismatch: ingress_port=${ingress_port}, run.sh differs" >&2 exit 1 } grep -Fq "watchdog: \"http://[HOST]:[PORT:${ingress_port}]/api/health\"" "$CONFIG" || { echo "HA port contract mismatch: watchdog must use [PORT:${ingress_port}]" >&2 exit 1 } grep -Fqx "hassio_api: true" "$CONFIG" || { echo "HA add-on contract mismatch: hassio_api must be enabled for Supervisor runtime discovery" >&2 exit 1 } local expose_count expose_count="$(grep -Ec "^EXPOSE ${ingress_port}/tcp$" "$DOCKERFILE" || true)" (( expose_count >= 1 )) || { echo "HA port contract mismatch: Dockerfile must expose ${ingress_port}/tcp" >&2 exit 1 } } ensure_builder() { command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 1; } docker buildx version >/dev/null 2>&1 || { echo "docker buildx is required" >&2; exit 1; } if docker buildx inspect "$BUILDER" >/dev/null 2>&1; then docker buildx use "$BUILDER" else args=(create --name "$BUILDER" --driver docker-container --use) [[ -z "${BUILDER_CONFIG:-}" ]] || args+=(--config "$BUILDER_CONFIG") docker buildx "${args[@]}" >/dev/null fi docker buildx inspect --bootstrap >/dev/null } build_push() { ensure_builder docker buildx build \ --platform "$BUILD_PLATFORMS" \ -f "$SCRIPT_DIR/Dockerfile" \ --target "runtime-${DISTRO}" \ --build-arg "BUILD_VERSION=$VERSION" \ --build-arg "RUST_VERSION=$RUST_VERSION" \ -t "$IMAGE_TAG" \ --push \ "$PROJECT_ROOT" printf 'Published %s (%s, %s)\n' "$IMAGE_TAG" "$BUILD_PLATFORMS" "$DISTRO" } build_load() { local arch="${2:-amd64}" platform case "$arch" in amd64) platform="linux/amd64" ;; aarch64|arm64) platform="linux/arm64" ;; *) echo "Usage: $0 load [amd64|aarch64]" >&2; exit 2 ;; esac ensure_builder docker buildx build \ --platform "$platform" \ -f "$SCRIPT_DIR/Dockerfile" \ --target "runtime-${DISTRO}" \ --build-arg "BUILD_VERSION=$VERSION" \ --build-arg "RUST_VERSION=$RUST_VERSION" \ -t "${IMAGE_REPO}:local" \ --load \ "$PROJECT_ROOT" printf 'Loaded %s:local for %s (%s)\n' "$IMAGE_REPO" "$arch" "$DISTRO" } render_metadata validate_addon_port_contract case "$ACTION" in render) ;; push) build_push ;; load) build_load "${2:-amd64}" ;; *) echo "Usage: $0 [render|push|load [amd64|aarch64]]" >&2 exit 2 ;; esac