From 21b6a5ec2df94e68eb9fe71287fc2ce9b2f6f5e0 Mon Sep 17 00:00:00 2001 From: shuyu777 <38770763+hattori7243@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:47:24 +0800 Subject: [PATCH 1/4] ci: add comprehensive PR validation workflow for all SDK demos --- .github/workflows/ci.yml | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d46862e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + catalog-and-scripts: + name: Catalog & Script Tests + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Verify catalog + run: node scripts/verify-catalog.mjs + + - name: Run script unit tests + run: node --test scripts/*.test.mjs + + ts-demos: + name: TypeScript Demos + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + client-sdk/ts/browser-conversation-agent/package-lock.json + client-sdk/ts/electron-conversation-agent/package-lock.json + + - name: Test browser key-file runner + run: node --test client-sdk/ts/browser-conversation-agent/scripts/*.test.mjs + + - name: Test electron key-file runner + run: node --test client-sdk/ts/electron-conversation-agent/scripts/*.test.mjs + + - name: Install browser demo dependencies + run: npm ci --prefix client-sdk/ts/browser-conversation-agent + + - name: Build browser demo + run: npm run build --prefix client-sdk/ts/browser-conversation-agent + + - name: Install electron demo dependencies + run: npm ci --prefix client-sdk/ts/electron-conversation-agent + + - name: Build electron demo + run: npm run build --prefix client-sdk/ts/electron-conversation-agent + + flutter-scripts: + name: Flutter Runner Scripts + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Test flutter runner scripts + run: node --test client-sdk/flutter/conversation-agent/scripts/*.test.mjs + + python-demo: + name: Python Demo (Voice Agent) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Run Python pytest + run: uv run --directory client-sdk/python/voice-dialogue-agent --frozen python -m pytest From bf031fa6498a2428cb32e79158f864e6c1d00543 Mon Sep 17 00:00:00 2001 From: shuyu777 <38770763+hattori7243@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:48:38 +0800 Subject: [PATCH 2/4] ci: install portaudio19-dev for python pyaudio dependency --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d46862e..7e09f6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,11 @@ jobs: - name: Check out repository uses: actions/checkout@v4 + - name: Install system audio dependencies + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev + - name: Set up uv uses: astral-sh/setup-uv@v5 with: From 7c4fcb9964443b7dc602293580b87bd38c1b73fd Mon Sep 17 00:00:00 2001 From: shuyu777 <38770763+hattori7243@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:50:19 +0800 Subject: [PATCH 3/4] test(python): skip registry verifier test if uncommitted agent skill is absent --- .../voice-dialogue-agent/tests/test_registry_verifier.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py b/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py index 5274aa3..d778237 100644 --- a/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py +++ b/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py @@ -11,6 +11,12 @@ Path(__file__).parents[4] / ".agents/skills/verify-eva-python-demo-registry/scripts/verify_registry_install.py" ) +if not SCRIPT.is_file(): + pytest.skip( + f"Optional agent skill verifier script not present: {SCRIPT}", + allow_module_level=True, + ) + SPEC = importlib.util.spec_from_file_location("verify_registry_install", SCRIPT) assert SPEC is not None and SPEC.loader is not None verifier = importlib.util.module_from_spec(SPEC) From 29a8c2b1cc43ed12d604da4e31f709bc2d95a328 Mon Sep 17 00:00:00 2001 From: shuyu777 <38770763+hattori7243@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:56:34 +0800 Subject: [PATCH 4/4] refactor(python): move verify_registry_install.py into tracked scripts/ and update test path --- .../scripts/verify_registry_install.py | 289 ++++++++++++++++++ .../tests/test_registry_verifier.py | 11 +- 2 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 client-sdk/python/voice-dialogue-agent/scripts/verify_registry_install.py diff --git a/client-sdk/python/voice-dialogue-agent/scripts/verify_registry_install.py b/client-sdk/python/voice-dialogue-agent/scripts/verify_registry_install.py new file mode 100644 index 0000000..16555bc --- /dev/null +++ b/client-sdk/python/voice-dialogue-agent/scripts/verify_registry_install.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import platform +import re +import sys +import tempfile +import tomllib +import urllib.request +import zipfile +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +DEMO_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +CONTRACT_PATH = DEMO_ROOT / "native-aec-contract.json" +PROJECT_PATH = DEMO_ROOT / "pyproject.toml" +LOCK_PATH = DEMO_ROOT / "uv.lock" +PACKAGE = "autoark-eva-client-sdk" + + +def fail(message: str) -> None: + raise RuntimeError(message) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_release() -> dict[str, Any]: + contract = json.loads(CONTRACT_PATH.read_text(encoding="utf-8")) + if contract != {"schemaVersion": 1, "descriptorId": "eva-webrtc-aec3", "abi": 2}: + fail("native-aec-contract.json drifted") + + project = tomllib.loads(PROJECT_PATH.read_text(encoding="utf-8")) + dependencies = project.get("project", {}).get("dependencies", []) + matches = [ + re.fullmatch(rf"{re.escape(PACKAGE)}\[[^]]+\]==([0-9A-Za-z.!+-]+)", item) + for item in dependencies + if isinstance(item, str) + ] + matches = [match for match in matches if match is not None] + if len(matches) != 1: + fail(f"{PACKAGE}: exact SDK dependency is required") + version = matches[0].group(1) + + lock = tomllib.loads(LOCK_PATH.read_text(encoding="utf-8")) + packages = [item for item in lock.get("package", []) if item.get("name") == PACKAGE] + if len(packages) != 1 or packages[0].get("version") != version: + fail("uv.lock SDK identity drifted from pyproject.toml") + locked = packages[0] + registry = str(locked.get("source", {}).get("registry", "")).rstrip("/") + if registry not in {"https://pypi.org/simple", "https://test.pypi.org/simple"}: + fail(f"unsupported registry: {registry}") + + wheels = [] + for item in locked.get("wheels", []): + filename = str(item["url"]).rsplit("/", 1)[-1] + system, machine = wheel_platform(filename) + wheels.append( + { + "system": system, + "machine": machine, + "filename": filename, + "sha256": str(item["hash"]).removeprefix("sha256:"), + } + ) + return { + "registry": "testpypi" if "test.pypi.org" in registry else "pypi", + "package": PACKAGE, + "version": version, + "nativeAec": { + "descriptorId": contract["descriptorId"], + "abi": contract["abi"], + }, + "wheels": wheels, + } + + +def wheel_platform(filename: str) -> tuple[str, str]: + if "macosx" in filename and filename.endswith("_arm64.whl"): + return ("Darwin", "arm64") + if "manylinux" in filename and filename.endswith("_x86_64.whl"): + return ("Linux", "x86_64") + if "manylinux" in filename and filename.endswith("_aarch64.whl"): + return ("Linux", "aarch64") + fail(f"unsupported SDK wheel platform: {filename}") + + +def select_expected_wheel( + release: Mapping[str, Any], system: str, machine: str +) -> Mapping[str, Any]: + normalized_machine = machine.lower() + if normalized_machine in {"amd64", "x64"}: + normalized_machine = "x86_64" + if normalized_machine == "arm64" and system == "Linux": + normalized_machine = "aarch64" + selected = [ + wheel + for wheel in release.get("wheels", []) + if wheel.get("system") == system and wheel.get("machine") == normalized_machine + ] + if len(selected) != 1: + fail(f"expected one wheel for {system}/{normalized_machine}, found {len(selected)}") + return selected[0] + + +def validate_remote_release( + release: Mapping[str, Any], payload: Mapping[str, Any] +) -> dict[str, str]: + info = payload.get("info") + if not isinstance(info, Mapping) or info.get("version") != release.get("version"): + fail("registry metadata version drifted") + expected = { + str(wheel["filename"]): str(wheel["sha256"]) + for wheel in release.get("wheels", []) + } + actual: dict[str, str] = {} + for item in payload.get("urls", []): + if item.get("packagetype") != "bdist_wheel": + fail(f"unexpected non-wheel registry artifact: {item.get('filename')}") + filename = str(item.get("filename")) + digest = str(item.get("digests", {}).get("sha256")) + actual[filename] = digest + if actual != expected: + fail(f"registry wheel set/SHA drifted: expected={expected}, actual={actual}") + return { + str(item["filename"]): str(item["url"]) + for item in payload.get("urls", []) + } + + +def fetch_json(url: str) -> Mapping[str, Any]: + request = urllib.request.Request(url, headers={"User-Agent": "eva-sdk-examples-verifier/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def download_and_hash(url: str, destination: Path) -> str: + request = urllib.request.Request(url, headers={"User-Agent": "eva-sdk-examples-verifier/1"}) + with urllib.request.urlopen(request, timeout=60) as response, destination.open("wb") as stream: + while chunk := response.read(1024 * 1024): + stream.write(chunk) + return sha256(destination) + + +def inspect_wheel_native(path: Path) -> dict[str, str]: + with zipfile.ZipFile(path) as archive: + matches = [ + item + for item in archive.namelist() + if "/_native/libeva_aec" in item and not item.endswith("/") + ] + if len(matches) != 1: + fail(f"expected one packaged native AEC library, found {matches}") + member = matches[0] + return { + "nativeLibrary": Path(member).name, + "nativeSha256": hashlib.sha256(archive.read(member)).hexdigest(), + } + + +def inspect_installed(release: Mapping[str, Any], native: Mapping[str, str]) -> dict[str, Any]: + package = str(release["package"]) + version = str(release["version"]) + distribution = importlib.metadata.distribution(package) + if distribution.version != version: + fail(f"installed distribution is {distribution.version}, expected {version}") + expected_prefix = (DEMO_ROOT / ".venv").resolve() + if Path(sys.prefix).resolve() != expected_prefix: + fail(f"verifier is not running from the demo .venv: {sys.prefix}") + + package_root = Path(distribution.locate_file("eva_client_sdk")).resolve() + if not package_root.is_relative_to(expected_prefix): + fail(f"eva_client_sdk was not imported from the demo .venv: {package_root}") + direct_urls = [item for item in distribution.files or [] if item.name == "direct_url.json"] + if direct_urls: + fail("installed SDK contains direct_url.json; local/path installation is forbidden") + + native_path = package_root / "_native" / native["nativeLibrary"] + if not native_path.is_file(): + fail(f"installed native AEC library is missing: {native_path.name}") + native_digest = sha256(native_path) + if native_digest != native["nativeSha256"]: + fail( + f"installed native AEC SHA drifted: expected {native['nativeSha256']}, got {native_digest}" + ) + + import eva_client_sdk + from eva_client_sdk.media import NativeAecProcessor + + if eva_client_sdk.__version__ != version: + fail(f"imported version is {eva_client_sdk.__version__}, expected {version}") + processor = NativeAecProcessor(sample_rate=48_000) + try: + descriptor = processor.descriptor + metadata = dict(descriptor.metadata) + finally: + processor.release() + expected_identity = release["nativeAec"] + if descriptor.id != expected_identity["descriptorId"]: + fail(f"native AEC descriptor drifted: {descriptor.id}") + if metadata.get("abi") != expected_identity["abi"]: + fail(f"native AEC ABI drifted: {metadata.get('abi')}") + if not isinstance(metadata.get("native_version"), str) or not metadata["native_version"]: + fail("native AEC version is missing") + + return { + "distributionVersion": distribution.version, + "importedVersion": eva_client_sdk.__version__, + "packageRoot": str(package_root), + "nativeLibrary": native_path.name, + "nativeSha256": native_digest, + "nativeDescriptor": descriptor.id, + "nativeMetadata": metadata, + } + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--report", type=Path, required=True) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + if args.report.exists(): + fail(f"refusing to overwrite report: {args.report}") + release = read_release() + wheel = select_expected_wheel(release, platform.system(), platform.machine()) + package = release["package"] + version = release["version"] + registry_host = "test.pypi.org" if release["registry"] == "testpypi" else "pypi.org" + registry_url = f"https://{registry_host}/pypi/{package}/{version}/json" + remote_payload = fetch_json(registry_url) + urls = validate_remote_release(release, remote_payload) + with tempfile.TemporaryDirectory(prefix="eva-python-registry-") as directory: + snapshot = Path(directory) / str(wheel["filename"]) + remote_digest = download_and_hash(urls[str(wheel["filename"])], snapshot) + if remote_digest != wheel["sha256"]: + fail(f"downloaded wheel SHA drifted: expected {wheel['sha256']}, got {remote_digest}") + native = inspect_wheel_native(snapshot) + + report = { + "schemaVersion": 1, + "status": "PASS", + "registry": release["registry"], + "package": package, + "version": version, + "wheel": wheel["filename"], + "wheelSha256": remote_digest, + "installed": inspect_installed(release, native), + "automatedChecks": { + "registryIdentity": "PASS", + "installedIdentity": "PASS", + "nativeAecIdentity": "PASS", + }, + "manualRegression": { + "status": "NOT_RUN", + "boundaries": [ + "Gateway text/voice turn", + "Emotion event", + "TTS playback", + "open-speaker microphone AEC effect", + "camera image turn and resource release", + ], + }, + } + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, ValueError) as error: + raise SystemExit(f"registry verification failed: {error}") from None + diff --git a/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py b/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py index d778237..6edf5e5 100644 --- a/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py +++ b/client-sdk/python/voice-dialogue-agent/tests/test_registry_verifier.py @@ -7,16 +7,7 @@ import pytest -SCRIPT = ( - Path(__file__).parents[4] - / ".agents/skills/verify-eva-python-demo-registry/scripts/verify_registry_install.py" -) -if not SCRIPT.is_file(): - pytest.skip( - f"Optional agent skill verifier script not present: {SCRIPT}", - allow_module_level=True, - ) - +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/verify_registry_install.py" SPEC = importlib.util.spec_from_file_location("verify_registry_install", SCRIPT) assert SPEC is not None and SPEC.loader is not None verifier = importlib.util.module_from_spec(SPEC)