Files
hanzo-dev e5f37a0734 the gate reads the syntax tree, and now it has a runner to read it on
3.2.0 shipped silent data loss to PyPI. O11yGettableAgentCheckIn declared
`integration_config` twice and `removed_at` twice; in Python the second
annotation rebinds the name, so the model advertised fields it did not have and
the value arriving under each shadowed name was read and dropped. 3.2.1 fixed
the models. Nothing yet fixed the reason it got out.

The `cloud-client` gate imports every generated module and calls that the build
step. A class body with a duplicate field IMPORTS CLEAN — measured here: with
the exact 3.2.0 model restored into the tree, `cloud-client` exits 0 and prints
"models: 2172 modules imported". The comment above it claimed the import caught
"a name collision". It never could. That claim is corrected rather than left to
mislead the next reader.

`duplicate-fields` reads the AST instead: repeated AnnAssign targets in one
class body. Verified both directions on the real defect, not a fixture — exit 1
naming both collisions with the 3.2.0 model in place, exit 0 with 3.2.1, 2362
modules scanned in both. It refuses a zero it did not earn: scanning no modules
fails rather than passes, because "found nothing" and "looked at nothing" print
the same otherwise.

And the gate now has somewhere to run. `.github/workflows/cicd.yml` asks for
`hanzo-build-linux-amd64`, which on github.com is served by nothing since the
ARC pool retired — every caller since has queued 86402s, GitHub's 24h timeout,
and reported "cancelled". That is why this repo's suite has not run in weeks
while looking merely flaky. The git-runner fleet serving that label lives on
git.hanzo.ai, which sync-from-github.yml already mirrors onto every ten
minutes, and where publish-pypi.yml already reads its token from KMS. So the
caller goes there, beside the publish it guards, like the other sixteen repos.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:39:55 -07:00

180 lines
9.0 KiB
YAML

# 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 CI owes is a gate, not an artifact. Publishing
# stays where it already is — .hanzo/workflows/publish-pypi.yml on our own
# runners, which is the canonical path because it reads the PyPI token from KMS
# like every other publish credential in the fleet. Adding a second publish here
# would be one too many; this file only gates.
#
# 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".
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)
'
- 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 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.
#
# KNOWN CEILING, so nobody reads this gate for more than it says: an import
# resolves the names in the `from … import` line and NOTHING ELSE. A method
# name is looked up at call time, so a renamed OPERATION passes here and
# fails in a user's app. Measured: when the `<svc>_` prefix left every
# operationId, `money` and `tools` went on passing this gate while every
# call in them named a method that no longer existed. Four flows failed on
# their imports and two were silently broken — the gate saw four.
#
# `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 compileall, importlib, sys
ok = compileall.compile_dir("examples", quiet=1, maxlevels=3)
bad = []
for flow in ("hello", "money", "store", "agent", "tools"):
try:
importlib.import_module(f"examples.{flow}.__main__")
print(f"examples.{flow}: OK")
except Exception as e:
bad.append(f"examples.{flow}: {e!r}")
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` — smaller (1058 paths), emitted from the
# code by each app's own router, and gated on every cloud release. A smaller true
# document beats a larger unverified one.
client:
generate: ./scripts/generate.sh
version: "pyproject.toml:grep -m1 '^version' pyproject.toml | sed -E 's/.*\"(.*)\".*/\\1/'"