# Hanzo CI — the ONE way this repo is gated, read by hanzoai/ci. # # No `images:` and no `deploy:`: these are libraries. A consumer installs them # from PyPI at a tag, so what this repo delivers is a distribution, declared # below, and the rest of the file is the gate in front of it. # # Scope is the generated cloud client and the flows that exercise it. The other # 64 packages under pkg/ are hand-written and carry their own tests; pulling # them in here would make a red gate mean "something, somewhere" instead of # "the client the spec just produced is broken". # The root project is `hanzoai`, the generated cloud client; everything under # pkg/ is a distribution of its own. A glob rather than 65 names, so a package # added under pkg/ publishes without anyone remembering to list it — which the # retired per-repo workflow's hardcoded lists could not do. # # A `v*` tag releases whatever moved (twine skips the versions already on the # index); `hanzo-mcp-v1.2.3` releases that one. hanzoai/ci reads the name out of # each pyproject, so the tag and the distribution cannot disagree. pypi: [., pkg/*] test: - name: cloud-client # Import every generated module. For generated code this IS the build step: # there is no compiler to catch a bad $ref or a model that references a class # the generator declined to emit — the import is what turns those into a # failure instead of an AttributeError in a user's app six months later. # # IT DOES NOT CATCH A NAME COLLISION, and this comment used to claim it did. # A class body that declares the same field twice is valid Python: the second # binding replaces the first and the module imports clean. 3.2.0 shipped to # PyPI that way — O11yGettableAgentCheckIn declared `integration_config` and # `removed_at` twice each, so the agent's value was read and dropped — and # this gate was green the whole time. `duplicate-fields` below is what sees # it, because it reads the syntax tree rather than the import. # # The arc runner image promises no interpreter, so provision one with uv # when it is missing. A gate that silently no-ops is worse than no gate. run: | set -e if command -v uv >/dev/null 2>&1; then UV=uv; else curl -fsSL https://astral.sh/uv/install.sh | sh UV="$HOME/.local/bin/uv" fi PYTHONPATH=pkg "$UV" run --no-project --python 3.12 \ --with pydantic --with python-dateutil --with urllib3 --with typing-extensions \ python -c ' import pkgutil, importlib, sys import hanzoai.cloud.api as A, hanzoai.cloud.models as M bad = [] for pkg, label in ((A, "api"), (M, "models")): n = 0 for m in pkgutil.iter_modules(pkg.__path__): try: importlib.import_module(f"{pkg.__name__}.{m.name}"); n += 1 except Exception as e: bad.append(f"{pkg.__name__}.{m.name}: {e!r}") print(f"{label}: {n} modules imported") for b in bad[:20]: print("FAIL", b, file=sys.stderr) sys.exit(1 if bad else 0) ' # Which packages import something they do not declare. # # REPORT ONLY, and the reason is a limit of reading rather than a soft # opinion: from the manifest alone, an import that works because a sibling # dependency happens to pull it in is indistinguishable from one that will # fail on a clean install. hanzo-zap imports `zap` and declares `zap-proto`, # which provides it. hanzo-tools-iam imports `mcp` and declares # hanzo-tools-core, which depends on it. Neither is broken; both look # identical to a parser. Failing on 92 of these on day one is how a gate gets # switched off. # # The authoritative check is in .github/workflows/publish-pypi.yml, which # installs each built wheel alone into a clean venv and imports it before # twine sees it. That one can tell the difference, because it tries. # # This still earns its place: relying on a transitive dependency is a break # waiting for the day that sibling drops it, and 92 rows is a map of where # that risk sits. Read it, do not gate on it. # # It runs under the interpreter the steps beside it fetch. The runner has no # `python` on its PATH, so the bare name exited 127 and a step that is report # only stopped a release. - name: declared-imports run: | set -e if command -v uv >/dev/null 2>&1; then UV=uv; else curl -fsSL https://astral.sh/uv/install.sh | sh UV="$HOME/.local/bin/uv" fi "$UV" run --no-project --python 3.12 python - <<'PY' import ast, pathlib, sys, tomllib # import name -> distribution name, where they differ. ALIAS = { "yaml": "pyyaml", "PIL": "pillow", "dotenv": "python-dotenv", "jwt": "pyjwt", "dateutil": "python-dateutil", "sklearn": "scikit-learn", "cv2": "opencv-python", "bs4": "beautifulsoup4", "magic": "python-magic", "google": "google-api-python-client", "OpenSSL": "pyopenssl", "zoneinfo": "backports.zoneinfo", "pkg_resources": "setuptools", "attr": "attrs", "serial": "pyserial", "usb": "pyusb", # Ours: the distribution and the import name differ. "zap": "zap-proto", } norm = lambda s: s.lower().replace("_", "-") def declared(doc): out = set() proj = doc.get("project", {}) for d in proj.get("dependencies", []) or []: out.add(norm(d.split(";")[0].strip().split("[")[0] .split("==")[0].split(">=")[0].split("<")[0] .split("~=")[0].split("!")[0].strip())) for group in (proj.get("optional-dependencies") or {}).values(): for d in group: out.add(norm(d.split(";")[0].strip().split("[")[0] .split("==")[0].split(">=")[0].split("<")[0] .split("~=")[0].split("!")[0].strip())) poetry = doc.get("tool", {}).get("poetry", {}) for name in (poetry.get("dependencies") or {}): out.add(norm(name)) for group in (poetry.get("group") or {}).values(): for name in (group.get("dependencies") or {}): out.add(norm(name)) for group in (poetry.get("extras") or {}).values(): for name in group: out.add(norm(name)) out.discard("python") return out def tops(path): try: tree = ast.parse(path.read_text(encoding="utf-8", errors="replace")) except SyntaxError: return set() seen = set() for node in ast.walk(tree): if isinstance(node, ast.Import): for a in node.names: seen.add(a.name.split(".")[0]) elif isinstance(node, ast.ImportFrom): # level > 0 is relative and belongs to the package itself. if not node.level and node.module: seen.add(node.module.split(".")[0]) return seen missing, unmapped = [], [] for manifest in sorted(pathlib.Path("pkg").glob("*/pyproject.toml")): pkg = manifest.parent with manifest.open("rb") as fh: doc = tomllib.load(fh) have = declared(doc) used = set() for py in pkg.rglob("*.py"): parts = py.parts if any(p in ("test", "tests", "examples", "build", ".venv") for p in parts): continue used |= tops(py) for name in sorted(used): if name in sys.stdlib_module_names or name.startswith("_"): continue # First party: this repo's own packages, and the package itself. if name.startswith("hanzo") or norm(name) == norm(pkg.name): continue if (pkg / name).exists() or (pkg / f"{name}.py").exists(): continue dist = ALIAS.get(name, norm(name)) if dist in have or norm(name) in have: continue (unmapped if name not in ALIAS and "." not in name and len(name) <= 2 else missing).append(f"{pkg.name}: imports {name!r}, declares no {dist!r}") for line in sorted(missing + unmapped): print("UNDECLARED", line) total = len(list(pathlib.Path("pkg").glob("*/pyproject.toml"))) print(f"{total} packages checked, {len(missing) + len(unmapped)} imports not declared") print("Not a failure: the publish lane installs each wheel clean and imports it,") print("which is the check that can tell a transitive dependency from a missing one.") PY - name: duplicate-fields # Read the syntax tree, because the import above cannot see this. # # `x: int` twice in one class body is legal Python. The second annotated # assignment rebinds the name, the first is gone, and nothing complains — # not the interpreter, not pydantic, not the import gate. The model then # advertises a field it does not have, and the value that arrives on the # wire under the shadowed name is read and dropped in silence. That is a # data-loss bug that presents as no bug at all. # # This is not hypothetical and it is not rare: it is what the generator does # every time two distinct spec properties normalise to one Python # identifier. hanzoai 3.2.0 is on PyPI with exactly that — two collisions in # O11yGettableAgentCheckIn — and the gate above was green for it. # # Stdlib `ast` only, no deps: a scan that needs an install is a scan that # can fail to run for reasons unrelated to the code it is reading. run: | set -e if command -v uv >/dev/null 2>&1; then UV=uv; else curl -fsSL https://astral.sh/uv/install.sh | sh UV="$HOME/.local/bin/uv" fi "$UV" run --no-project --python 3.12 python -c ' import ast, pathlib, sys root = pathlib.Path("pkg/hanzoai/cloud") hits, n = [], 0 for p in sorted(root.rglob("*.py")): n += 1 try: tree = ast.parse(p.read_text(encoding="utf-8", errors="replace")) except SyntaxError as e: hits.append("%s: does not parse: %r" % (p, e)) continue for node in ast.walk(tree): if not isinstance(node, ast.ClassDef): continue seen = {} for s in node.body: # AnnAssign is `name: Type = ...`, how every field is declared if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name): if not s.target.id.startswith("__"): seen.setdefault(s.target.id, []).append(s.lineno) for f, lines in sorted(seen.items()): if len(lines) > 1: hits.append("%s: %s.%s declared %d times, lines %s" % (p, node.name, f, len(lines), lines)) print("duplicate-fields: scanned %d generated modules" % n) # A zero here must mean "looked and found nothing", never "looked at # nothing". If the tree moves, this fails loudly instead of passing empty. if n == 0: print("FAIL scanned no modules under", root, file=sys.stderr) sys.exit(1) for h in hits: print("FAIL", h, file=sys.stderr) sys.exit(1 if hits else 0) ' - name: examples # The canonical flows, imported against the client that was just generated. # Each `from hanzoai.cloud import X` is an assertion that the symbol still # exists, so a spec change that renames or drops a MODEL or an API class # goes red here rather than in a user's app. # # Importing is enough for the CLASS names BECAUSE each flow guards its call # behind `if __name__ == "__main__"` — the module body resolves every name # without opening a socket, so the gate needs no API key and no network. # # It is NOT enough for the METHOD names, and that gap has shipped twice. An # import resolves the `from … import` line and nothing else; a method is # looked up at call time, so a renamed OPERATION passes an import and fails # in a user's app. Measured: when the `_` prefix left every operationId, # four flows failed on their imports and `money` and `tools` went on passing # while every call in them named a method that no longer existed — the gate # saw four of six. # # So the check below reads the syntax tree for attribute accesses on anything # bound to a `*Api` and asks the class whether it has them. Ten names resolve # today. The receiver is resolved through its assignment, so it catches # `KeysApi.get_tools` as readily as a stale `get_v1_tools`. # # `chat` is NOT in this list, and its absence is a measurement rather than a # decision. cloud declares POST /v1/chat/completions with no `requestBody` # and no `responses` at all, so the generated method takes no body and # returns None: the one call a chat example exists to make cannot be # expressed. Inventing the type here, or hand-rolling the HTTP inside a # generated client, is the exact drift these SDKs exist to prevent, and # hanzoai/js-sdk dropped its own chat flow at 2.0.7 for this same reason. # Restoring it is one step and the test is one line: when # paths['/v1/chat/completions'] in cloud's openapi.yaml carries a # requestBody, add examples/chat back and put "chat" back in the tuple. run: | set -e if command -v uv >/dev/null 2>&1; then UV=uv; else curl -fsSL https://astral.sh/uv/install.sh | sh UV="$HOME/.local/bin/uv" fi PYTHONPATH=pkg "$UV" run --no-project --python 3.12 \ --with pydantic --with python-dateutil --with urllib3 --with typing-extensions \ python -c ' import ast, compileall, importlib, pathlib, sys import hanzoai.cloud as C ok = compileall.compile_dir("examples", quiet=1, maxlevels=3) bad = [] for flow in ("models", "hello", "money", "store", "agent", "tools"): try: importlib.import_module(f"examples.{flow}.__main__") except Exception as e: bad.append(f"examples.{flow}: {e!r}") continue tree = ast.parse(pathlib.Path(f"examples/{flow}/__main__.py").read_text()) bound = {} for n in ast.walk(tree): if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Call) and isinstance(n.value.func, ast.Name) and n.value.func.id.endswith("Api")): bound.update({t.id: n.value.func.id for t in n.targets if isinstance(t, ast.Name)}) names = 0 for n in ast.walk(tree): if not isinstance(n, ast.Attribute): continue v = n.value cls = bound.get(v.id) if isinstance(v, ast.Name) else ( v.func.id if isinstance(v, ast.Call) and isinstance(v.func, ast.Name) and v.func.id.endswith("Api") else None) if not cls: continue names += 1 if not hasattr(getattr(C, cls), n.attr): bad.append(f"examples.{flow}: {cls} has no {n.attr}") print(f"examples.{flow}: OK, {names} method name(s) resolved") for b in bad: print("FAIL", b, file=sys.stderr) sys.exit(1 if (bad or not ok) else 0) ' # THE CLIENT LANE — this repo is a PROJECTION of one API document at one version. # # hanzoai/cloud's release sends `repository_dispatch: spec-update` carrying # (version, sha, spec_sha256); hanzoai/ci fetches openapi.yaml AT THAT SHA, # REFUSES if the bytes hash to anything else, runs `generate:`, writes # `.spec-lock` beside the code, and — only after the `test:` block below has # passed over exactly those bytes — commits and cuts a patch. # # The document moved from hanzoai/openapi `hanzo.yaml` (hand-merged, 1742 paths, # fed by nothing, and NOTHING HAS EVER SENT the spec-update this repo listens # for) to hanzoai/cloud `openapi.yaml` — emitted from the code by each app's own # router, and gated on every cloud release. It was 1058 paths when it took over # and is 1814 now, which is the point: it grows from the routers rather than from # an editor, so a true document overtook the unverified one on its own. client: # WHICH cloud, said out loud. hanzoai/ci defaults this to `hanzoai/cloud`, and # that repo is now the re-rooted OSS core — a short line sharing no ancestor # with the product. So the default resolved to a tree that does not contain the # ref this projection is pinned to, and every run of the client lane, check or # move, died on `curl: (22) ... 404` fetching a document that was never there. # # It reads as a credential problem and is not one: the same token fetches the # same ref from hanzo-inc/cloud with a 200. The product line is the one whose # openapi.yaml this SDK is a projection of, so it is the one named here. # # This is also the field `.spec-lock`'s own `repo=` line does NOT set — ci reads # the repo from here and uses the lock's copy only for a commit message, so the # two can disagree silently. They are one fact; keep them equal. spec: repo: hanzo-inc/cloud generate: ./scripts/generate.sh version: "pyproject.toml:grep -m1 '^version' pyproject.toml | sed -E 's/.*\"(.*)\".*/\\1/'"