Files
openapi/publish.py
T
zeekayandHanzo Dev c63f667452 gate: refute the published document against the deployment, in the lane that runs
The drift gate this repo already had could not run, so nothing did.

`publish.py --check` is the right question — is hanzo.yaml what its own pinned
input projects to — and it needs a hanzoai/cloud CHECKOUT. hanzoai/cloud is
private, hanzoai is on the GitHub Free plan (where an org secret resolves to the
empty string inside a private repo), and this repo's one repo-level secret is
SDK_DISPATCH_TOKEN. On top of that, 3cd60cb moved both callers to
.hanzo/workflows, which only git.hanzo.ai collects, and
git.hanzo.ai/hanzoai/openapi is a 404 — no mirror. `gh workflow list` has
returned NOTHING since 2026-07-30: not CI/CD, not `spec sync`, no `test:` block.

So on main, --check exited 1, --current exited 1 and test_publish.py failed —
three red gates, none of them running anywhere, while the document they guard
shipped 19 unserved operations to seven SDKs.

  publish.py --served   every operation this repo PUBLISHES must be one
                        api.hanzo.ai answers for. Cloud serves its own emission
                        unauthenticated at /v1/openapi.json, from the binary
                        that is running, so the deployment refutes a dead route
                        by itself. No checkout, no token, no ref to trust.

Deliberately the WEAKER question, because it needs nothing and therefore runs.
One direction only: served-and-unpublished means the pin is behind, which is
--current's question and not a lie about the API; published-and-unserved is the
lie, and it is what reaches an agent as a SKILL.md instructing it to call a dead
endpoint. Never fails on an unreachable API — same rule as hanzo.ai's
scripts/audit-catalog.mjs, which gates the same document: a gate that goes red
for somebody else's outage gets switched off, and then it gates nothing.

Wired into hanzo.yml's `test:` block, the one file both hanzoai/ci and
platform.hanzo.ai read, and test_publish.py now asserts that wiring — deleting
the gate has to delete its own test, in the same diff, on purpose.

The callers move back to .github/workflows, which is the path the forge this
repo LIVES ON collects, with the `uses:` path following them; `spec sync` gets
a token on its hanzoai/cloud checkout, since the automatic one reads a private
repo as an anonymous caller and gets a 404 that reads like a missing repo.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:44:33 -07:00

473 lines
22 KiB
Python

#!/usr/bin/env python3
"""Publish `hanzo.yaml` — a PROJECTION of hanzoai/cloud's emitted document.
python3 publish.py # re-pin to hanzoai/cloud origin/main, derive, write
python3 publish.py --ref v1.801.383 # re-pin to one release
python3 publish.py --check # THE GATE: re-derive at the pinned ref and diff
python3 publish.py --current # is the pin still cloud's origin/main?
python3 publish.py --served # THE GATE with no checkout: does the deployment answer for every published operation?
THIS REPO DOES NOT DECIDE WHAT THE API IS. It never did well, and it no longer
claims to. hanzoai/cloud emits `openapi.yaml` by projecting its own routers, and
gates the emission by regenerating from source and failing on any diff — so it
cannot describe a route the binary does not serve, and cannot miss one it does.
That is the only description of this API with that property. Everything here is
DOWNSTREAM of it.
What went before: 52 hand-authored `<svc>/openapi.yaml` files were merged with
cloud's document, cloud winning on collision. Measured at [email protected], that
master carried 185 operations the document does not have, and the document
carried 425 the master did not — so the file every SDK generated from described
a router 18% smaller and 8% wrong. 95 of the 185 were addressed at nothing a
probe could find, 36 of those refuted outright (real address 404, nonsense
sibling 404). And an authored operation nothing serves is not a harmless
placeholder: `skills.py` turns it into a SKILL.md, which is a live instruction to
an agent to call a dead endpoint.
WHAT THIS FILE IS ALLOWED TO DO, and it is a short list. A projection may not
add an operation, may not remove one that is served, and may not invent prose.
It may only make the ONE document generatable — see `project()`, where every
rule carries the generator failure that demands it. The measurement that says
the projection is necessary at all: openapi-generator 7.14.0 refuses cloud's
document with **1012 errors**, one per operation the weave publishes with an
address and no `responses`. Every one of those rules is a candidate to move
UPSTREAM into cloud's emitter, and this file shrinks the day one does.
`.spec-lock` is the receipt — repo, path, ref, sha256 — in the same four keys
hanzoai/ci's client lane writes into every SDK repo. It is what lets anyone ask
`hanzo.yaml` "which release are you?" without running anything.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.request
import yaml
ROOT = os.path.dirname(os.path.abspath(__file__))
LOCK = os.path.join(ROOT, ".spec-lock")
TARGET = os.path.join(ROOT, "hanzo.yaml")
INDEX = os.path.join(ROOT, "CAPABILITIES.md")
REGISTRY = os.path.join(ROOT, "capabilities.yaml")
CLOUD = os.environ.get("CLOUD_DIR") or os.path.join(os.path.dirname(ROOT), "cloud")
SPEC_REPO = "hanzoai/cloud"
SPEC_PATH = "openapi.yaml"
# Cloud SERVES its own emission, unauthenticated, from the binary that is
# actually running. It is the only input that can refute a published operation
# without a checkout, a credential or a release tag to take on trust.
SERVED = os.environ.get("SERVED_DOCUMENT", "https://api.hanzo.ai/v1/openapi.json")
HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
# What a route with no declared response shape says for itself. Not a schema:
# the weave publishes an untyped route's address and invents nothing, and
# inventing one here would be the same lie one repo further downstream.
UNTYPED = ("The route answers; its response shape is not declared at the source "
"(not a typed op).")
# ---------------------------------------------------------------- the input
def git(repo, *args):
r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
if r.returncode:
sys.exit(f"publish: git {' '.join(args)} failed in {repo}\n{r.stderr.strip()}")
return r.stdout
def checkout(path):
if subprocess.run(["git", "-C", path, "rev-parse", "--git-dir"],
capture_output=True).returncode:
sys.exit(f"publish: no {SPEC_REPO} checkout at {path}\n"
f" git clone [email protected]:{SPEC_REPO} {path}\n"
f" (or point --cloud / CLOUD_DIR at one)")
return path
def lock():
"""`.spec-lock` as a dict, or {} when the repo has never published."""
if not os.path.exists(LOCK):
return {}
out = {}
for line in open(LOCK):
k, _, v = line.strip().partition("=")
if k:
out[k] = v
return out
def document(repo, ref):
"""The document at one ref, with its digest. Fetches first, so a stale
worktree can never be mistaken for the branch it is behind."""
git(repo, "fetch", "--quiet", "origin")
raw = git(repo, "show", f"{ref}:{SPEC_PATH}")
return raw, hashlib.sha256(raw.encode()).hexdigest()
# ------------------------------------------------------------ the deployment
def operations(doc):
"""{(METHOD, path)} — a document as the set of things a caller can call."""
return {(m.upper(), p) for p, item in (doc.get("paths") or {}).items()
for m, op in item.items() if m in HTTP_METHODS and isinstance(op, dict)}
def refuted(url=SERVED):
"""Which PUBLISHED operations the deployment does not answer for.
`--check` proves hanzo.yaml is the projection of a git ref. That is the
right question and it needs a hanzoai/cloud checkout to ask, so it can only
run where a credential for a private repo exists — and this repo has none.
So it has never run, and while it did not, this file went 81 commits stale
and shipped `POST /v1/admin/credits` to every SDK for a mint hanzoai/cloud
had deleted. A published operation for a route nothing serves is not a stale
document: `skills.py` turns it into a SKILL.md, which is a live instruction
to an agent to call a dead endpoint.
This asks the weaker question that needs NOTHING — no checkout, no token, no
ref — and therefore actually runs: every operation this repo publishes must
be one `api.hanzo.ai` answers for. One direction only. Served-and-unpublished
means the pin is behind, which `--current` already reports and which is not a
lie about the API; published-and-unserved is the lie.
Returns (refuted, published, served), or None when the deployment cannot be
reached — no network, no verdict, exit 0, said loudly. Same rule as
hanzo.ai's scripts/audit-catalog.mjs: a gate that fails on someone else's
outage gets switched off, and then it gates nothing.
"""
try:
with urllib.request.urlopen(url, timeout=120) as r:
live = operations(json.load(r))
except Exception as e: # noqa: BLE001
print(f"publish: {url} did not answer ({e}) — NO VERDICT. "
f"This gate refutes published operations against the running "
f"deployment; it cannot do that from an unreachable one.")
return None
mine = operations(yaml.safe_load(open(TARGET)))
return sorted(mine - live), mine, live
# ------------------------------------------------------------ the projection
def product(path):
"""The `/v1/<product>` a path belongs to, or None for the unprefixed few."""
segs = path.strip("/").split("/")
return segs[1] if len(segs) > 1 and segs[0] == "v1" else None
def genid(s):
"""An identifier's identity to a GENERATOR: punctuation stripped, case folded.
OpenAPI says operationIds are unique as STRINGS, and they are —
`get_v1_pricing-policy` and `get_v1_pricing_policy` differ by one character.
Every generator then strips the punctuation and camel-cases what is left, so
both arrive as `GetV1PricingPolicy` and the Go client declares one request
type twice. Uniqueness has to be checked under the identity that breaks.
"""
return re.sub(r"[^a-z0-9]", "", s.lower())
def project(doc, groups):
"""cloud's document -> the generatable one. SIX rules, each with its failure.
1. TRACE is dropped (26 ops). They are cloud's wildcard relays projecting
every method their router matches — a fact about the router, not a client
surface — and no SDK should hand a caller a method every edge disables
(Cross-Site Tracing). It is also the one method openapi-generator cannot
emit for the JVM: it writes `RequestMethod.TRACE` beside a `RequestMethod`
enum that stops at PUT, so no Java or Kotlin client compiles.
2. `compat`-tagged operations are dropped (23). The SERVING BINARY declares
that tag for a legacy address it keeps reachable for consumers pinned to
it. Dropping it here respects the declaration rather than overruling it —
the served surface is unchanged, and the published one says each thing
once instead of putting two spellings of one operation in every SDK.
3. Two tags become one (23 ops). A tag is a grouping hint to a reader and
the emitted CLASS to a generator, so a two-tag operation is emitted
TWICE under one identifier: `ApiPricingGetFullPricingRequest redeclared`
in Go, and quieter — worse — elsewhere. The first tag is the primary by
OpenAPI convention and what every doc site groups under.
4. No tag becomes the owning app (44 ops). Untagged is not ungrouped, it is
grouped somewhere nobody named: every generator files it under
`DefaultApi`. `x-app` is the emitter's own answer to who serves it, so it
is the tag; the `/v1/<product>` segment answers for the rest.
5. No `responses` gets a `default` (1012 ops). THE BIG ONE, and the whole
reason this projection exists: `openapi-generator validate` counts each
one an ERROR and refuses the document entire, so cloud's emission cannot
generate a client in ANY language as it stands. A `default` with no
content says exactly what is known — the route answers, its shape is not
declared at the source — which is what the weave already says by silence.
This is the rule to move upstream: the day cloud's emitter writes that
`default` itself, this rule and 90% of this function go.
6. operationIds are made unique under `genid` (1 collision today,
`deleteSession` vs `DeleteSession`). The later one by path order takes a
suffix, which is what a generator does for the duplicates it can see.
Nothing else. No operation is added, no served operation is removed, no
prose is written — every summary and description below came out of a handler
doc comment in hanzoai/cloud.
"""
paths, dropped_trace, dropped_compat, defaults, retagged = {}, 0, 0, 0, 0
for path, item in sorted((doc.get("paths") or {}).items()):
out = {}
for method, op in item.items():
if method not in HTTP_METHODS:
out[method] = op
continue
if not isinstance(op, dict):
continue
if method == "trace": # 1
dropped_trace += 1
continue
tags = list(op.get("tags") or [])
if "compat" in tags: # 2
dropped_compat += 1
continue
op = dict(op)
if len(tags) > 1: # 3
op["tags"] = tags[:1]
elif not tags: # 4
op["tags"] = [op.get("x-app") or product(path) or "root"]
retagged += 1
if not op.get("responses"): # 5
op["responses"] = {"default": {"description": UNTYPED}}
defaults += 1
out[method] = op
if any(m in HTTP_METHODS for m in out):
paths[path] = out
taken, renamed = set(), 0 # 6
for path in sorted(paths):
for method in HTTP_METHODS:
op = paths[path].get(method)
if not isinstance(op, dict):
continue
base = oid = op["operationId"]
n = 2
while genid(oid) in taken:
oid, n = f"{base}_{n}", n + 1
renamed += oid != base
taken.add(genid(oid))
op["operationId"] = oid
used = []
for item in paths.values():
for method, op in item.items():
if method in HTTP_METHODS and isinstance(op, dict):
for t in op["tags"]:
if t not in used:
used.append(t)
tag_groups, grouped = [], {}
for domain in groups:
members = [t for t in domain["tags"] if t in used]
if members:
tag_groups.append({"name": domain["title"], "tags": members})
for t in domain["tags"]:
grouped.setdefault(t, domain["title"])
prose = {t["name"]: t.get("description") for t in (doc.get("tags") or [])
if isinstance(t, dict) and t.get("name")}
tags = [{"name": t, "description": prose[t]} if prose.get(t) else {"name": t}
for group in tag_groups for t in group["tags"]]
out = dict(doc)
out["paths"] = paths
out["tags"] = tags
out["x-tagGroups"] = tag_groups
# Cloud's emission declares no security scheme, so a generated client sends
# no Authorization header and every call 401s. One bearer JWT from Hanzo IAM
# authenticates every route; saying so is not a second opinion about the API.
components = dict(out.get("components") or {})
components.setdefault("securitySchemes", {
"bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT",
"description": "A JWT issued by Hanzo IAM (https://hanzo.id)."}})
out["components"] = components
out["security"] = [{"bearerAuth": []}]
return out, {
"paths": len(paths), "ops": len(taken), "tags": len(tags),
"described_tags": sum(1 for t in tags if t.get("description")),
"groups": len(tag_groups), "trace": dropped_trace, "compat": dropped_compat,
"default": defaults, "retagged": retagged, "renamed": renamed,
"ungrouped": sorted(set(used) - set(grouped)),
"unused": sorted(t for t in grouped if t not in used),
}
# ---------------------------------------------------------------- the index
def index(groups, doc):
"""CAPABILITIES.md — the human index, DERIVED, never hand-edited.
One row per tag the document carries, with the operation count and the
prose the owning Go package's doc comment supplied. A capability is on this
page because the router registered it, which is the only reason a capability
was ever supposed to be on it."""
counts, described = {}, {}
for item in (doc.get("paths") or {}).values():
for method, op in item.items():
if method in HTTP_METHODS and isinstance(op, dict):
counts[op["tags"][0]] = counts.get(op["tags"][0], 0) + 1
for t in doc.get("tags") or []:
if isinstance(t, dict) and t.get("description"):
described[t["name"]] = " ".join(t["description"].split())
out = ["<!-- GENERATED by publish.py from hanzo.yaml — DO NOT EDIT. "
"Change the API in hanzoai/cloud; group a new capability in "
"capabilities.yaml; run `python3 publish.py`. -->", "",
"# Hanzo Capability Manifest", "",
f"Every capability the API serves, at "
f"`{doc['info'].get('x-spec', {}).get('ref', '?')}`. GENERATED from "
f"`hanzo.yaml` — which is itself generated from hanzoai/cloud's own "
f"emission — so a name is on this page if and only if a router "
f"registered operations under it. Nothing here is authored, and there "
f"is nothing to author: a capability appears the release it starts "
f"being served and leaves the release it stops.", ""]
for group in doc.get("x-tagGroups") or []:
domain = next((d for d in groups if d["title"] == group["name"]), {})
out += [f"## {group['name']}", ""]
if domain.get("role"):
out += [f"*{domain['role']}*", ""]
out += ["| Capability | Operations | What it is |", "|---|---:|---|"]
for t in group["tags"]:
out.append(f"| `{t}` | {counts.get(t, 0)} | {described.get(t, '')} |")
out.append("")
return "\n".join(out).rstrip() + "\n"
# ---------------------------------------------------------------- the command
def derive(raw, ref, sha):
"""(hanzo.yaml text, CAPABILITIES.md text, counters) from one document."""
groups = (yaml.safe_load(open(REGISTRY)) or {}).get("domains") or []
doc = yaml.safe_load(raw)
out, n = project(doc, groups)
if n["ungrouped"]:
sys.exit(f"publish: the document serves capabilities capabilities.yaml does "
f"not group: {n['ungrouped']}\n"
f" A new product is a name somebody has to place. Add each "
f"to a domain's `tags:` and re-run.")
if n["unused"]:
sys.exit(f"publish: capabilities.yaml groups names the document does not "
f"carry: {n['unused']}\n"
f" Nothing serves them, so nothing can be filed under them. "
f"Remove each from capabilities.yaml and re-run.")
out["info"] = dict(out["info"])
# The release generation of the PUBLICATION. Three versions, three meanings,
# and they must not be collapsed: the API's is `/v1` (immutable, in the
# path), the emitting release's is `x-spec.ref`, and this one names the shape
# of the published projection — which is what every SDK's package version is
# cut from, so it may only move forward.
out["info"]["version"] = "8.0.0"
out["info"]["x-spec"] = {"repo": SPEC_REPO, "path": SPEC_PATH,
"ref": ref, "sha256": sha}
text = yaml.dump(out, default_flow_style=False, sort_keys=False, width=120)
return text, index(groups, out), n
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cloud", default=CLOUD, help=f"{SPEC_REPO} checkout (default {CLOUD})")
ap.add_argument("--ref", help="the release to publish (default: the pinned one, "
"or origin/main when re-pinning)")
ap.add_argument("--check", action="store_true",
help="re-derive at the PINNED ref and diff; write nothing")
ap.add_argument("--current", action="store_true",
help="report whether the pin is still cloud's origin/main")
ap.add_argument("--served", action="store_true",
help=f"THE GATE that needs no checkout: refute every published "
f"operation against {SERVED}")
a = ap.parse_args()
have = lock()
# First, and before `checkout`: this is the one question that needs no
# hanzoai/cloud on disk, which is the whole reason it is the one in
# `hanzo.yml`'s `test:` block.
if a.served:
answer = refuted()
if answer is None:
return 0
gone, mine, live = answer
if gone:
print(f"publish: hanzo.yaml publishes {len(gone)} operation(s) "
f"{SERVED} does not answer for.\n"
f" Every SDK, doc page and agent skill generated from this "
f"file advertises them. Fix: `python3 publish.py` to re-pin to the "
f"release that is deployed.")
for method, path in gone:
print(f" {method:7} {path}")
return 1
print(f"all {len(mine)} published operations are served "
f"({len(live - mine)} more are served and not yet published — "
f"`--current` is the question about the pin)")
return 0
repo = checkout(a.cloud)
if a.current:
head = git(repo, "rev-parse", "origin/main").strip()
at = git(repo, "rev-parse", f"{have.get('ref', 'origin/main')}^{{commit}}").strip()
if head == at:
print(f"hanzo.yaml is {SPEC_REPO}@{have.get('ref')} — cloud's origin/main")
return 0
_, sha = document(repo, "origin/main")
if sha == have.get("sha256"):
print(f"hanzo.yaml is {SPEC_REPO}@{have.get('ref')} — cloud has moved "
f"({head[:8]}) but the document has not")
return 0
print(f"hanzo.yaml is {SPEC_REPO}@{have.get('ref')}; cloud's origin/main is "
f"{head[:8]} with a different document — run `python3 publish.py`")
return 1
ref = a.ref or (have.get("ref") if a.check else None) or "origin/main"
if a.check and not have.get("ref"):
sys.exit("publish: --check needs a .spec-lock naming the document this "
"artifact is a projection of; run `python3 publish.py` first")
raw, sha = document(repo, ref)
if a.check and have.get("sha256") and sha != have["sha256"]:
sys.exit(f"publish: {SPEC_REPO}@{ref}:{SPEC_PATH} hashes to {sha}, but "
f".spec-lock says {have['sha256']} — the ref moved under this "
f"projection")
text, md, n = derive(raw, ref, sha)
if a.check:
stale = [name for name, want in ((TARGET, text), (INDEX, md))
if (open(name).read() if os.path.exists(name) else None) != want]
if stale:
print(f"publish: {', '.join(os.path.basename(f) for f in stale)} is not "
f"what {SPEC_REPO}@{ref} projects to.\n"
f" This artifact is GENERATED — an edit to it describes a "
f"release nobody shipped. Change the API in hanzoai/cloud, then run "
f"`python3 publish.py`.")
return 1
print(f"hanzo.yaml is the projection of {SPEC_REPO}@{ref} "
f"({n['paths']} paths, {n['ops']} operations)")
return 0
open(TARGET, "w").write(text)
open(INDEX, "w").write(md)
open(LOCK, "w").write(f"ref={ref}\nsha256={sha}\nrepo={SPEC_REPO}\npath={SPEC_PATH}\n")
print(f"hanzo.yaml <- {SPEC_REPO}@{ref}:{SPEC_PATH} ({sha[:12]})")
print(f"{n['paths']} paths, {n['ops']} operations, {n['tags']} capabilities "
f"({n['described_tags']} described) in {n['groups']} domains")
print(f"projected: {n['trace']} TRACE and {n['compat']} compat operations dropped, "
f"{n['default']} given a `default` response, {n['retagged']} tagged by their "
f"app, {n['renamed']} operationIds suffixed to stay distinct in codegen")
return 0
if __name__ == "__main__":
sys.exit(main())