Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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: 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:
enable-cache: true

- name: Run Python pytest
run: uv run --directory client-sdk/python/voice-dialogue-agent --frozen python -m pytest
Original file line number Diff line number Diff line change
@@ -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

Loading
Loading