148 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 8e43277aab sitedeploy self-provisions its project — first run no longer fails
Hanzo CI/CD / cicd (push) Successful in 56s
CI/CD / gate (push) Successful in 56s
Every never-before-deployed site died at the enqueue: POST /v1/projects/<slug>/deploy
404s when the project does not exist yet, so committing a workflow was never
enough — someone had to POST /v1/projects out of band first, holding a token most
sites' authors don't have. sitedeploy now creates the project itself before the
enqueue: idempotent (200/201 first run, 409 forever after), repo linked because
the git-source enqueue requires it, analytics on by default. A committed workflow
is now sufficient to stand a new site up. The plan/test seam sits above this, so
the 17 offline tests are untouched — verified green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1 v1.0.32
2026-08-04 18:54:01 -07:00
zeekayandClaude Fable 5 d3823df85a sitedeploy: one implementation of the Sites-plane publish
Hanzo CI/CD / cicd (push) Successful in 41s
CI/CD / gate (push) Successful in 42s
Every static surface on the estate was about to carry its own transcription of
enqueue → upload → complete: hanzo.ai, hanzo.app, hips, computer, and each one
after them. bin/imgver exists because that already happened once with image tags
— written eleven times, right ten times, and `sha-<short>` shipped for months.
This is that rule applied to the other half of the fleet.

The contract, once:

  POST /v1/projects/<slug>/deploy       -> 202 {id, bucket, prefix, upload}
  POST <upload.url> per object          -> the bytes, straight to S3
  POST .../deployments/<id>/complete    -> {status, files, bytes, keys}

The bytes never pass through the API: a real export is 128 MB against a 16 MiB
BodyLimit, so an artifact POST is refused by fasthttp before any handler runs and
fails as an opaque 400 that reads like a malformed payload rather than a size cap.

ONE credential. The 202 carries a presigned POST grant that is prefix-scoped
(S3 itself enforces starts-with $key), 30-minute, and size-bounded — so CI holds
no bucket key. Do not reintroduce SITES_S3_*: that was one long-lived key for a
bucket whose only tenant separation is the key prefix, so every repo holding it
could overwrite every org's site. Deletion moved server-side with it: `keys` is
the manifest cloud reconciles the prefix against, which is where
`aws s3 sync --delete` went, and both it and this script fail CLOSED on an empty
manifest rather than deleting a live site because a pipeline produced no lines.

Four defects found by running it end-to-end against hanzo.ai's real export
(8402 files, 105 MB, 54s) rather than by reading it:

  · the grant's `fields` already carries `key` — the starts-with PLACEHOLDER,
    not a destination. Forwarding it verbatim beside the real key posts `key`
    twice and S3 answers 400 for every object: 8402 files, 8402 failures.
  · `mapfile` is bash 4+; macOS ships 3.2, where it is `command not found`, the
    field array stays EMPTY, and the upload is rejected for a malformed
    X-Amz-Credential — an error pointing at the credential rather than at the
    array that was never built.
  · `[.\-_]` is how Go spells the fingerprint class; transcribed into `[[ =~ ]]`
    the backslash is literal inside a bracket expression, so it reads as the
    range \…_ and the shell rejects it. The `if` then merely evaluates false and
    every content-hashed asset silently loses `immutable`.
  · `stat -f%z || stat -c%s` cannot work: on GNU the first arm fails INSIDE a
    pipeline, awk still exits 0, the `||` never fires, and the byte count reports
    0. `wc -c` is the spelling both agree on.

Content-Type is sent per object because the policy carries no condition for it,
so what CI sends is what the object stores and what the edge serves — send
nothing and a browser downloads every page instead of rendering it. Cache-Control
mirrors cloud's apps/sites.CacheControlFor; sitedeploy_test.sh pins those exact
strings so the two cannot drift apart quietly.

Tests run offline: SITEDEPLOY_PLAN=1 stops before the first network call and
prints the manifest, so all 17 cases need no token, no bucket and no cluster.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:36:01 -07:00
zeekay b6b52b4a81 the gate runs before the image exists, not after it shipped
Hanzo CI/CD / cicd (push) Successful in 2m33s
CI/CD / gate (push) Successful in 2m32s
`Build & push images` sat above `test:`, guarded only by mode != delegate. So a
commit whose gates FAILED still published an image. Measured, not theorised:
hanzoai/base f9ec7067 pushed sha-f9ec706-amd64 on a run whose Test step then
went red, and hanzoai/id and hanzoai/base both show Build ahead of Test in every
run this week.

Deploy and Publish site already skip on a red gate, so nothing auto-shipped
those bytes. That is the reassuring half and also the trap: the TAG existed. A
tag in a registry is something a human pins by hand at 2am, and its existence
reads as a claim the commit passed. It did not. An artifact should not be
producible from a commit that failed its own gates — that is the same "green
that means nothing" this file spent the day removing, one step lower down.

Order is now toolchains -> Test -> Build -> Deploy. Nothing in Build depends on
the toolchains it now follows, and Deploy still follows Build, so the single
behavioural change is that a red gate produces no image at all.

Costs one thing worth naming: a repo with a broken Dockerfile and a slow suite
now waits for the suite before learning the build is broken. That is the right
trade — a late failure is cheaper than a false artifact.
v2 v1.0.31
2026-08-04 18:21:44 -07:00
zeekay 331e05625f push to registry.hanzo.ai by its own name, with a credential from KMS
Hanzo CI/CD / cicd (push) Successful in 2m34s
CI/CD / gate (push) Successful in 2m35s
THE 401 WAS NOT A WRONG REGISTRY. registry.hanzo.ai and oci.hanzo.ai answer the
SAME catalog behind the SAME token realm (iam.hanzo.ai/v1/iam/registry/token) —
measured, both hosts, byte-identical repository lists. They are one store with
two branded names, exactly as ~/work/CLAUDE.md describes.

What broke is narrower and stupider: docker keys credentials by HOSTNAME. This
workflow logged in to oci.hanzo.ai and pushed to whatever hanzo.yml named, so
the moment a repo repointed at the canonical registry.hanzo.ai the daemon had no
credential for that host and the push 401'd — against a registry it was already
authenticated to under its other name. So: log in to the canonical name.

CREDENTIAL FROM KMS, which is the rule and was the actual blocker. The step had
two sources, and for most repos neither fires: REGISTRY_USER/PASSWORD as GitHub
secrets (unset), else a kubeconfig read of the `registry-credentials` secret —
and the CI kubeconfig identity cannot read it (`ci-deploy-bot` grants
apps/deployments and replicasets, no secrets at all, and is bound to a service
account left over from the retired ARC). Measured consequence: the mirror step
logs "registry-credentials not readable from this kubeconfig" and MIRROR_OK is
never set, so registry.hanzo.ai holds cloud, commerce, iam, git — and NONE of
app, chat, gui, ui, design, sites, superbase, base. Those names answer
NAME_UNKNOWN. The names are now fetched from KMS beside S3_ADMIN_ACCESS_KEY,
which already works this way; the kubeconfig read stays as the fallback.

ONE NAMESPACE MAPPING, STATED ONCE. The GHCR org and the registry org are not
the same word, and the old line assumed they were —
`mrepo="oci.hanzo.ai/${repo#*/}"` mirrored ghcr.io/hanzoai/x to .../hanzoai/x.
The mapping is hanzoai->hanzo, zooai->zoo, everything else to itself:

  ghcr.io/hanzoai/chat      -> registry.hanzo.ai/hanzo/chat
  ghcr.io/zooai/app         -> registry.hanzo.ai/zoo/app
  ghcr.io/bootnode/node     -> registry.hanzo.ai/bootnode/node
  ghcr.io/lux-private/gpu   -> registry.hanzo.ai/lux-private/gpu

It lives in the push path itself so the table and the path cannot disagree —
two copies of a name mapping is how a pin and a push end up naming different
images. The ~30 repositories already mirrored under the old `hanzoai/` prefix
are left where they are; nothing pins registry.hanzo.ai yet (universe pins
ghcr.io), so they are orphaned mirror copies, not live references.

COREPACK_NPM_REGISTRY on the job. corepack fetches the pnpm/yarn tarball ITSELF
from registry.npmjs.org and does not read .npmrc to do it, so all 317 repos that
pin `packageManager` reach npmjs on every install however carefully their
registry is configured — the dependencies came from our mirror and the tool
fetching them did not. Set here for BYO runners; the git-runner fleet gets the
same value from its own config so jobs that never import this reusable are
covered too.
v1.0.30
2026-08-04 17:57:07 -07:00
zeekay 44186bea06 conflictmarkers: the whole shape, not the first line of it
Hanzo CI/CD / cicd (push) Successful in 49s
CI/CD / gate (push) Successful in 48s
Shipped an hour ago matching a start marker alone, and hanzoai/app went red on
code that is perfectly fine. Its builder tests carry, as literal fixture data:

  <<<<<<< START_TITLE index.html >>>>>>> END_TITLE

Seven '<' and a space at column 1 — and not a merge conflict. Both markers sit
on ONE line, which is a shape git never writes.

So the gate now requires the whole conflict, in order, each part on its own
line: a start marker that does NOT also carry an end marker, a bare seven-equals
divider, and a separate end line. app's fixture fails the very first of those
and passes; a real conflict satisfies all three.

Proven against four cases before pushing, not after:

  base 42b97c9c types.d.ts  RED   23807 <<<<<<< HEAD / 23849 ======= /
                                  23850 >>>>>>> upstream/master
  app  format-pages.test.ts GREEN the fixture above
  synthetic git-shaped      RED   the exact bytes git writes on a conflict
  hanzoai/ci itself         GREEN including this file, which spells the
                                  markers out in prose, and a .rst heading
                                  underlined with seven equals signs

The divider is still only ever matched as part of that conjunction and never on
its own, because seven bare equals at column 1 is also how reStructuredText and
Setext Markdown underline a heading.

The lesson is the one this gate exists to teach, turned on itself: a check that
fires on a fragment of a pattern is not a cheap approximation of one that fires
on the pattern, it is a different check with a different, larger false-positive
surface — and the repo it fails first is the one that did nothing wrong.
v1.0.29
2026-08-04 17:26:43 -07:00
zeekay 0e1a56bd3c the test step has never run, and the gate that would have said so never ran either
CI/CD / gate (push) Successful in 55s
Hanzo CI/CD / cicd (push) Successful in 55s
git.hanzo.ai resolves every `type: boolean` workflow_call input to false. Not
just an omitted one falling back to its default -- an explicitly passed `true`
resolves to false too. Measured in isolation (z/inputprobe runs 27365, 27382):

  type: boolean, default: true, caller omits it   -> inputs.tests = false
  type: boolean, caller passes with: {tests: true} -> inputs.tests = false
  type: string,  default: 'true', caller omits it  -> inputs.tests = true
  type: string,  default: '["hanzo-build-linux-amd64"]' -> resolves in full

So `if: inputs.mode != 'delegate' && inputs.tests` has been false on every run
this pipeline has ever served, and the `test:` block was parsed and skipped. On
2026-08-04 the last two cicd.yml runs of hanzoai/cloud, commerce, console,
gateway, hanzo.ai and this repo were all completed/success with
`Test (per hanzo.yml)` = skipped. Every test gate in the fleet was declared,
read, and never once executed -- a green over zero tests, which is the exact
failure the step's own assertion exists to refuse when a SUITE is empty, one
level up where nothing was watching.

tests: becomes a string compared against 'false'. A string sidesteps the forge
bug (proven above) and means the same thing on both planes, and comparing
against 'false' rather than testing truthiness keeps hanzoai/cloud's existing
`tests: ${{ github.ref_type != 'tag' }}` working untouched -- that expression
renders the strings "true"/"false".

The second half is what the first half was hiding. build-yml-is-one-file is a
`test:` gate, so it never ran either, and the two published copies had drifted
26 lines: .hanzo/workflows/build.yml -- the ONLY copy git.hanzo.ai executes --
was missing the Structural invariants step entirely. ignoretracked and modsize
have therefore never run on the plane that runs. The copies are one file again,
regenerated rather than hand-edited a second time, since hand-editing twice is
how the drift got in.

conflictmarkers joins that step, because it is the same defect class: `git add`
accepts a file still carrying git's own markers, and from then on they are
ordinary committed content -- status clean, diff empty, both sides of the merge
shipping. It hides in generated and vendored files that are large and
unreviewed, and no compiler objects: a .d.ts nothing imports is never parsed,
and in YAML, JSON and Markdown the markers are valid content. hanzoai/base has
carried `<<<<<<< HEAD` / `>>>>>>> upstream/master` at lines 23807 and 23850 of a
committed .d.ts. Proven red against that file and against a synthetic merge,
green on a clean tree, and it does not flag itself or a reStructuredText
underline -- the bare `=======` divider is deliberately not matched, because
seven equals signs at column 1 is also how RST and Setext underline a heading.
v1.0.28
2026-08-04 17:05:54 -07:00
hanzo-dev 23862e5c44 two structural refusals: the repo must agree with itself
Hanzo CI/CD / cicd (push) Successful in 1m31s
CI/CD / gate (push) Successful in 1m32s
A day of production archaeology kept finding one defect class wearing
different clothes: a declaration and the reality it describes disagreeing,
with nothing in the pipeline holding an opinion about it. The framework
already refuses this shape in three places — zip refuses middleware that
wraps nothing, zipdoc refuses an op whose address it cannot resolve, Go
refuses a module past its zip ceiling. This lands the same principle for
two disagreements nothing was catching:

bin/ignoretracked — content the repo's OWN .gitignore matches but the index
still tracks. An ignore rule does not untrack what is already tracked, so
the bulk ships in every release while `git status` stays clean. The causal
story from the real outage is in the script header: the rule was removed at
12:22, 855 files landed at 12:51 inside the unguarded window, the rule came
back at 16:48. Every step was reasonable; the combined state shipped 361 MB
of orphaned cargo output in every release for 6 days.

bin/modsize — every Go module tree in the repo measured against Go's hard
500 MiB module-zip ceiling, nested modules separately. Past it, the module
is not merely large: `go get` fails in every consumer at every version that
carries it. cloud crossed at v1.801.416 and was unfetchable for nine
consecutive releases, which is why production sat pinned five releases back.

Wired as one early step in the reusable workflow, before anything builds or
pushes. Both FAIL the run — a warning nobody reads is the same silence one
level up.

Proof, not assertion: both RED against cloud@v1.801.424 (ignoretracked:
361.1 MB tracked-yet-ignored; modsize: 528.8 MiB, past the ceiling, nested
apps/platform/design measured separately at 0.2 MiB) and GREEN against the
repaired v1.801.440, hanzoai/iam, and this repo.

Scripts authored by the structural-guards lane; proofs re-run and wiring
added before landing. Callers pin @v1 — this activates when that tag moves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 16:13:28 -07:00
hanzo-dev 74c176639b build: every published image names the commit it was built from
Hanzo CI/CD / cicd (push) Successful in 2m4s
CI/CD / gate (push) Successful in 2m4s
Images built by this reusable carried org.opencontainers.image.revision=unknown,
which means an image published anywhere in the fleet could not be traced to a
commit. cloud's Dockerfile declares `ARG REVISION=unknown` and stamps the label
from it; nothing here ever passed REVISION, so the default won every time.

That is cosmetic right up until two builders race for one tag — and they did.
Two images sat behind ghcr.io/hanzoai/cloud:v1.801.410, one labelled 1b8b76ed
and one labelled `unknown`, and "which of these is the release" had no answer
short of diffing layers, while the wrong one was already pinned. A version is a
receipt only if the image can name its own commit.

Passed BOTH ways, deliberately: --label sets it from outside for any Dockerfile,
--build-arg feeds `ARG REVISION` for Dockerfiles that stamp it themselves. A
Dockerfile with no ARG REVISION ignores the build-arg (buildkit warns, never
fails), so this is a no-op for every repo that has not asked for it. version and
source labels come along for the same reason.

Applied to both published copies, which are byte-identical by contract.

This retires the image -> commit holds that universe's scripts/ci/receipt.sh
currently records for iam, id and console — all three are "built by hanzoai/ci
build.yml, which does not pass REVISION", and all three are one shared fix.
v1.0.27
2026-08-04 02:00:12 -07:00
hanzo-dev b323a1189d legal: license original work under Apache-2.0
This repo is original Hanzo work and shipped with no LICENSE, which left
it all-rights-reserved by default. Apache-2.0 per the estate policy that
original work is Apache-2.0 or BSD-3-Clause.

Verified original before licensing: not a GitHub fork, no upstream
project identity in the manifests, no third-party copyright headers in
the tree, and the root commit is Hanzo-authored.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 02:00:12 -07:00
zeekay 5750426c73 merge: fail closed on a declared build secret that cannot be read
Hanzo CI/CD / cicd (push) Successful in 3m28s
CI/CD / gate (push) Successful in 3m28s
An unreadable declared build_secret was a warning that baked an EMPTY value and
let the run go green — the same silent shape that shipped keyless bundles across
the fleet. A repo that declares a secret asserts the image needs it, so an
unreadable one is a failure, not a note.

Resolved against the embedded-KMS read: kept both, since one decides WHERE the
secret is read from and the other decides what happens when it is not there.
Repos declaring nothing are byte-for-byte unaffected.
2026-08-04 01:24:41 -07:00
zeekay 093b912e51 apply the embedded-KMS read to the .github copy too
Hanzo CI/CD / cicd (push) Successful in 32s
CI/CD / gate (push) Successful in 33s
There are two build.yml, and the earlier fix reached only .hanzo/workflows.
Consumers split across them: hanzo.ai imports the .hanzo copy and resolves its
key, docs imports this one and did not — which is exactly the difference
between the lane that works and the lane that ships an empty key.

Same change: embedded shape first (/v1/kms/secrets/<path>/<name>, flat .value),
standalone as fallback, default endpoint api.hanzo.ai.
2026-08-04 00:24:23 -07:00
zeekayandHanzo Dev 8edb99bb19 give both copies of the reusable the same KMS contract
The two files are one reusable and eleven repos import them by path, but only
the .hanzo copy was moved onto the embedded KMS. The .github copy — the one
docs, gateway, tasks, deploy and openapi import — still defaulted to the
standalone host and still read /v1/kms/orgs/<org>/... with .secret.value, a
shape the embedded service answers with nothing. Every one of those builds
resolved an empty credential, or the stale value the standalone held, and said
nothing about it.

They are byte-identical again: the embedded url and response shape, the
standalone fallback, and the refusal to build with an unreadable declared
build secret.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:04:59 -07:00
zeekayandHanzo Dev 9f6bfb8cff fail the build when a declared build_secret is unreadable
A repo that lists `build_secrets` in hanzo.yml has asserted its image is not
valid without them. The fetch treated every failure as survivable and exported
nothing, so buildx baked an empty value and the run went green — the artifact
was broken and only production showed it, as silence.

The declaration is now read before the two short-circuits, so an unconfigured
or unreachable KMS is an error for a repo that declares secrets instead of an
empty result that looks like "not in KMS". An unresolvable name is an error at
fetch, and the build step refuses a name that is still unset when it assembles
--build-arg.

A repo that declares no build_secrets keeps the best-effort path exactly as it
was: KMS stays optional, GHCR push still works on the workflow token.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 00:00:12 -07:00
zeekay a2d50bce29 fix: read build secrets from the embedded KMS
Hanzo CI/CD / cicd (push) Successful in 44s
CI/CD / gate (push) Successful in 45s
KMS is embedded in hanzoai/cloud now (HIP-0106, cloud/apps/kms). This reusable
still spoke the standalone contract, and both differences fail SILENTLY -- the
result is an empty string, which every caller here already treats as "not in
KMS", so a build that lost its secret looked exactly like one that never
declared it.

  url   /v1/kms/secrets/<path>/<name>  -- the org comes from the TOKEN, not the
        url. The old /v1/kms/orgs/<org>/... form 404s, and `curl -sf` turns that
        into empty output.
  body  {"name","env","value"} -- flat. `.secret.value` selects nothing.

Reading the OLD host is worse than reading nothing: the two instances hold
DIFFERENT values under the same name. The standalone copy of
deploy/EVENT_INGEST_KEY is stale, and cloud rejects it at ingest with 403 --
so a build that "found" a key still shipped one that cannot write, and every
pageview from that property was filed under the reserved $public tenant, which
our own org cannot read.

Tries the embedded shape first and falls back to the standalone one, so a repo
still pointed at the old host by vars.KMS_ENDPOINT keeps working while the fleet
moves. Default endpoint flipped to api.hanzo.ai.
2026-08-03 21:25:21 -07:00
hanzo-dev d31d20f679 sync: record the forge lineage as landed, since it carries nothing this does not
Hanzo CI/CD / cicd (push) Successful in 3m1s
CI/CD / gate (push) Successful in 3m2s
forge/main sat 43 commits off this history and could not fast-forward, so every
push to the mirror was rejected and the two hosts have been drifting apart while
saying nothing about it.

Nothing on it is unlanded. `git cherry origin/main forge/main` finds 42 of 42
commits already applied here by patch equivalence and none unique; no file exists
on that branch and not on this one; and the whole textual difference is prose this
side has since rewritten — the registry.hanzo.ai mirror leg, crane, corepack
provisioning and the delegate lane are all present here in their later wording. It
is this repo's own history, absorbed once already at a43f5a3 and then left behind
by 89 commits.

So this takes the lineage and NOT the tree (-s ours): the merge is a no-op on
content — verified, the resulting tree object is byte-identical to its first
parent — and its only effect is that the mirror can fast-forward again. The
alternative was a force-push, which would delete 43 commits of real history to
assert something a merge can state without deleting anything.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:03:27 -07:00
hanzo-dev f01beb8f6f ci: a repo that declares no test gate says so on the run
df6c025 made a gate that reports success without running a test fail the build.
It asks each gate that question by looping over hanzo.yml's `test:` block — so it
cannot ask the repo that declares no block at all. There the loop simply does not
run, and the build goes green having tested nothing: the exact shape that commit
exists to end, reached by declaring less rather than more.

23 repos in the fleet are in that state today, so failing here would paint the
fleet red without fixing one of them. It warns instead, into the ::warning::
annotation and the step summary this build already writes, which puts the absence
ON THE RUN rather than in a file only a reader of hanzo.yml would ever open.

The gate list is now read ONCE and asked both questions — "is it empty" and "run
each" — instead of yq being invoked twice for one fact.

Both copies of build.yml, byte-identical as aeb6adf requires (one document, two
paths, one tag serving both forges). Checked: the two files compare equal after
the edit, both parse, the step's shell passes `bash -n`, and the three shapes
behave — absent `test:` warns, `test: []` warns, a populated block runs its gates
and does not warn.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:01:56 -07:00
hanzo-dev 07a8c94b14 Merge remote-tracking branch 'origin/wip/ci-local-edits' into HEAD
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-02 11:00:42 -07:00
Claude c1bd70ca94 fix: provision Node for JS gates that live in a subtree, not just at the root
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
hanzoai/commerce ran 49 ginkgo suites green and then died on its last gate:

    ::group::test admin-typecheck
    bash: line 3: pnpm: command not found
    exitcode 127
    skipping post step for "Provision Node toolchain (JS test gates)";
      main step was skipped

That last line is the whole story. The step exists precisely to stop a JS gate
dying at 127 on a runner image with no Node — and it had SKIPPED, so it could
not.

Its guard was `hashFiles('package.json')`, which only ever looks at the repo
ROOT. commerce is a Go repo whose front ends live in app/, and its gate is
`cd app && pnpm install --frozen-lockfile && ... turbo run typecheck`. No root
package.json, so the guard said "not a JS caller" about a repo with four JS
workspaces.

The guard now also matches a nested one. Both patterns are passed rather than
relying on `**/` to match zero segments: this reusable has 13 callers, and a
guard that stopped matching a root package.json would break every JS repo at
once. Widening a provisioning step is safe in the other direction — a repo that
gains a Node toolchain it does not use loses nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.0.26
2026-08-02 04:13:14 -07:00
hanzo-dev c9b7153152 ci: name the fleet that actually serves the default, and fix a delegate URL that 404s
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
The `runner` default has always been '["hanzo-build-linux-amd64"]', and every
runner carrying that label is StatefulSet/git-runner in ns hanzo, registered to
hanzo-git.hanzo.svc. The prose called it "the Hanzo cloud arc pool" anyway. That
is not a cosmetic mismatch: arc (arcd) is being retired, and a default described
in terms of a dead system is how the dead system comes back — the next reader
provisions one because the doc says the pipeline needs it. 156 files across three
orgs ask for this label and not one of them was ever served by arcd, which
advertised 48 labels and zero amd64.

The delegate lane was worse than stale, it was broken. `mode: delegate` POSTs to
PLATFORM_ENQUEUE_URL, defaulting to platform.hanzo.ai/v1/arcd/enqueue. Platform
renamed that route to /v1/runner; the old path now falls through to the catch-all,
which answers 401 {"message":"Unauthorized"} — distinguishable from the real route
only by the body, since /v1/runner answers 401 {"message":"Invalid enqueue token"}
and so does a path that never existed. A delegated build has therefore been
failing auth rather than enqueueing. Nothing noticed because no repo passes
`mode: delegate`, which is also why this was safe to correct in place.

Both published copies edited identically; the byte-equality gate passes at 1321
lines. The remaining occurrence of the word is deliberate — a one-line tombstone
in the input description, so the next person to look for the arc pool is told it
was retired instead of concluding the doc is merely out of date.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.25
2026-08-01 21:52:03 -07:00
Claude 7cb8c69316 ci: hand the resolved spec credential to the generator too
Hanzo CI/CD / cicd (push) Successful in 36s
CI/CD / gate (push) Successful in 37s
The guard now resolves SPEC_TOKEN-or-GH_PAT, but the generator runs in a child
process and reads SPEC_TOKEN itself:

    generate.sh: no hanzoai/openapi checkout at .openapi and no SPEC_TOKEN to
    clone one

So resolving the fallback only in the guard moved the failure one line down
instead of fixing it. Exporting the resolved value under the name the script
already reads means one credential is chosen once and every consumer in the lane
sees the same one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.0.24
2026-08-01 20:06:43 -07:00
Claude 81eee4e18d ci: the client lane falls back to GH_PAT, and says which credential it used
Hanzo CI/CD / cicd (push) Successful in 34s
CI/CD / gate (push) Successful in 35s
The lane failed every run with

    ::error::the client: lane needs SPEC_TOKEN (contents:read on hanzoai/cloud)

and there was nothing to do about it: SPEC_TOKEN is provisioned on NO org and NO
repo on this forge. Measured — orgs/hanzoai holds GHCR_USER, GHCR_TOKEN, GH_PAT,
OCI_USER, OCI_TOKEN, KMS_CLIENT_ID, KMS_CLIENT_SECRET, REGISTRY_TOKEN, and
repos/hanzoai/cli holds nothing. A missing org secret is "", not an error, so the
guard fired on every build and named a secret that does not exist anywhere.

SPEC_TOKEN stays PREFERRED: it is the least-privilege choice, a fine-grained
token scoped contents:read on one private repo, and that is worth keeping when
someone mints it. GH_PAT is the fallback because it is provisioned and already
reads private hanzoai repos.

The step now prints `spec credential: SPEC_TOKEN|GH_PAT`. A fallback that does
not say which credential it took is how a wrong one goes unnoticed — the same
defect that made luxfi/trader read as a registry problem for weeks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.0.23
2026-08-01 20:04:26 -07:00
Claude d9c8917e18 ci: default GITHUB_WORKFLOW_REF before expanding it — set -u killed the step
Hanzo CI/CD / cicd (push) Successful in 49s
CI/CD / gate (push) Successful in 50s
Ground truth from the run, once the step was made to report:

    GITHUB_WORKFLOW_REF=<unset>
      Failure - Main Check out this reusable`s own tools

Nothing between them. The step died on the very next line, before any clone:

    ref="${GITHUB_WORKFLOW_REF##*@}"

This forge does not set GITHUB_WORKFLOW_REF, and since bash 4.4 a `##`
expansion of an unset variable under `set -u` is an unbound-variable error. So
the step exited 1 while every host still looked blameless, and the fallback that
would have produced the right answer -- `ref="${ref:-v1}"`, two expansions later
-- was unreachable.

Reading `${GITHUB_WORKFLOW_REF:-}` into a local first makes the whole chain safe
and still derives v1, which is what every caller pins.

⚠️ Not reproducible on macOS: /bin/bash is 3.2, where the old form survives with
an empty result. Testing it there says the code is fine. The forge`s log is the
only thing that settled this, which is why the previous commit made the step
print what it derived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.0.22
2026-08-01 19:36:00 -07:00
Claude 31817412ec ci: the tools checkout says what it tried
CI/CD / gate (push) Successful in 33s
Hanzo CI/CD / cicd (push) Successful in 32s
The step sent both clone attempts to /dev/null and printed only

    ::error::could not fetch hanzoai/ci@$ref (bin/imgver)

which names neither the ref it derived nor which host refused, so the cause can
only be guessed at from source — and guessing produced one wrong fix already.
Now it echoes GITHUB_WORKFLOW_REF, the derived ref, and the outcome of each host
in its own group.

Also tries git.hanzo.ai/hanzoai/ci as a third host. The two org axes on that
forge are orthogonal: `hanzo` is the tenant org and `hanzoai` is the mirror
namespace, and this reusable lives under BOTH. Only the tenant path was listed.

Keeps the refs/tags/ strip from v1.0.20: every caller pins @v1, so the imported
ref is refs/tags/v1 and `--branch refs/tags/v1` names no branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.0.21
2026-08-01 19:33:22 -07:00
Claude 97d22d6a43 ci: the tools checkout must accept a tag ref, not only a branch
Hanzo CI/CD / cicd (push) Successful in 1m38s
CI/CD / gate (push) Successful in 1m38s
Every caller pins `@v1`, so GITHUB_WORKFLOW_REF ends in `@refs/tags/v1`. The ref
derivation stripped only `refs/heads/`, leaving `refs/tags/v1` — and
`git clone --branch refs/tags/v1` is not a valid branch name, so BOTH clone
attempts failed and the step exited:

    ::error::could not fetch hanzoai/ci@refs/tags/v1 (bin/imgver)

It worked for a branch caller and could never work for a tag one, which is every
caller this reusable actually has. The clone itself was fine: github.com/hanzoai/ci
is public and an anonymous `--branch v1` clone returns bin/imgver.

Carried at both paths, which this file requires — git.hanzo.ai resolves only
.hanzo/workflows and github.com only .github/workflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.0.20
2026-08-01 19:31:40 -07:00
hanzo-dev 14314da268 gover: the build context decides which go.mod, not where the file sits
Hanzo CI/CD / cicd (push) Failing after 1m4s
CI/CD / gate (push) Failing after 1m5s
The first cut walked up from the Dockerfile to the nearest go.mod. That is
right for a subdirectory that is its own module built from its own directory,
and wrong for the shape hanzoai/s3 actually ships: Dockerfiles under
test/kafka/ built with `context: ../..` that `COPY go.mod go.sum ./` and
compile the ROOT module. Judged by test/kafka/go.mod the floor reads 1.25.0;
the truth is 1.26.5. That is the difference between "fine" and "cannot
build", and the gate was reporting the first.

So when a Dockerfile copies the context's own go.mod — `COPY go.mod ...`, the
overwhelmingly common shape — the context's module is what gets compiled and
its floor is the one that counts. The walk-up stays as the fallback, because
it is the correct answer for s3's own s3-rdma-sidecar/ and telemetry/server/,
which are separate modules built from their own directories.

Found by checking the gate against every Dockerfile in the orgs rather than
trusting it: re-running the 461-file corpus flags 57 where the first cut
flagged 54, catches both s3 test/kafka cases that had been identified by hand,
and un-flags nothing. A gate that mis-attributes is worse than no gate,
because the number it prints looks like an answer.

Three cases pinned in the suite: context-wins-when-copied, own-directory
context keeps its own floor, and a Dockerfile copying a SUBDIRECTORY's go.mod
is still judged by the nearest one (otherwise every multi-module repo reports
false alarms).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.19
2026-08-01 16:45:53 -07:00
hanzo-dev bd016f7a80 build: gate the delegated lane too, not only buildx
Hanzo CI/CD / cicd (push) Failing after 54s
CI/CD / gate (push) Failing after 54s
mode=delegate moves WHERE an image is built — platform.hanzo.ai's arcd pool
instead of the runner — but not whether the base image can compile the
module. A mismatch enqueued to platform still dies with "go.mod requires go
>= X (running Y; GOTOOLCHAIN=local)", on a machine whose logs the GitHub run
never shows, minutes after this job reported success.

The check is cheap exactly where the checkout already is, and the delegate
lane resolves the same $df and $ctx the buildx lane does, so it is the same
one line at the other call site.

Coverage boundary, stated plainly: this reaches callers that declare an
`images:` block. Repos whose images are built outside this reusable —
luxfi/node (platform reads its hanzo.yml directly over the webhook) and
hanzoai/cloud (its own cicd.yml `image` job, kept single-owner on purpose) —
do not pass through either lane and are NOT gated by this.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.18
2026-08-01 16:15:44 -07:00
hanzo-dev 41947eca48 build: refuse a Go builder image older than the module it compiles
The official golang images set GOTOOLCHAIN=local, so a go.mod requiring a
newer Go than the base image does not degrade to a download — it dies
mid-build:

  go: go.mod requires go >= 1.26.5 (running go 1.26.4; GOTOOLCHAIN=local)

visor v1.108.16 shipped that failure. The reason it reached a release build
is that nothing local can see it: a dev box runs GOTOOLCHAIN=auto and simply
fetches what the module asks for, so `go build` is green right up until the
image build. The break is introduced by editing go.mod, a file with nothing
to do with Docker.

It is not one repo's problem. Sweeping every Dockerfile across the orgs —
all variants, all subdirectories, not just repo roots — found 54 builder
stages already below their own go.mod in 23 repos, and only 7 of 223 Go
stages setting GOTOOLCHAIN=auto. Fixing those 54 fixes today; this makes the
55th impossible, which is the part worth having.

bin/gover is that check, run by the build lane before any build work so a
mismatch costs seconds instead of a binfmt install and a layer cache. It
refuses only what cannot build: an image BELOW the module floor. Pinning
ahead of go.mod is valid and stays silent, because a newer toolchain
compiling an older directive always works. A floating tag against a
patch-pinned go.mod warns and passes — it builds today, and a gate that
fails what builds is a gate people learn to skip.

The error names the remediation rather than just saying no: pin the base to
the version go.mod asks for, and add ENV GOTOOLCHAIN=auto so the next bump
downloads its toolchain instead of failing.

This repo's own module and image move to go1.26.5 and adopt that same
GOTOOLCHAIN=auto line, so the pipeline holds itself to the rule it enforces.

bin/gover_test.sh pins both directions — the refusals and the allowances
(newer-than-floor, alpine suffix vs Go patch, ARG defaults, and a
multi-module repo judged by its nearest go.mod). Offline, no registry.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 16:11:53 -07:00
hanzo-dev ec658b8dea build: the tools step reads a GitHub-only variable, so every forge build died on line 1
Hanzo CI/CD / cicd (push) Failing after 57s
CI/CD / gate (push) Failing after 57s
GITHUB_WORKFLOW_REF is set by GitHub Actions and by nothing else. The step runs
under `set -euo pipefail`, so on the forge the bare expansion aborted before the
clone loop it was written for — "unbound variable", exit 1, and with it every
step the pipeline had left: build, test, image, deploy, all reported as skipped.
A caller saw only that the reusable's own tools could not be checked out, which
reads like a missing repo and is not one.

Defaulting the variable is the whole fix. Where it IS set the behaviour is
unchanged — a caller on a branch still gets that branch's imgver, a caller on a
tag still gets the tag's. Where it is not, the fallback that was already written
(`${ref:-v1}`) is finally the thing that runs, and the GitHub clone ahead of it
in the loop carries bin/imgver.

The forge fallback URL named the `hanzo` org; the repo is `hanzoai/ci`. Its
first line was never reached, so the wrong name never surfaced.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.17
2026-08-01 15:32:09 -07:00
hanzo-dev 53d4a378d7 rip: the per-repo sync dies — the NATIVE table already names this repo
Hanzo CI/CD / cicd (push) Successful in 1m48s
CI/CD / gate (push) Successful in 1m49s
Two mechanisms did one job, and the one that lived here could never do it. The
forge's copy of this repo predated the file, so the workflow that would pull
GitHub's commits was itself one of the commits it had not pulled. A syncer that
has to already be synced to run is not a mechanism, it is a deadlock — and it
held: the forge sat 18 commits behind with `v1` frozen on a pipeline from months
earlier, while this file sat on GitHub looking like the answer.

hanzoai/mirrors owns "the forge is current", for every repo, from ONE table.
`ci` is in it now, and sync.py carries branches AND tags — including the `vN`
channels this file was the only place to force. Copying a workflow into fifty
repos is fifty things to keep in step; the table is one.

What survives is hanzoai/mirrors' own copy, and only because a syncer cannot
sync itself into existence — the same bootstrap exception, stated once, in the
repo it applies to.

One consequence to watch rather than hide: this file dispatched build.yml after
a fast-forward, because a push made with the WORKFLOW token does not fire other
workflows. sync.py pushes as the instance admin, which is a different identity
and not subject to that loop prevention. If it turns out commits arrive on the
forge and no build fires, the fix is one dispatch call in sync.py — named here
so it is looked for, not discovered.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.16
2026-08-01 12:36:40 -07:00
hanzo-dev a43f5a33fe Merge remote-tracking branch 'forge/main'
Hanzo CI/CD / cicd (push) Successful in 1m4s
CI/CD / gate (push) Successful in 1m4s
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.15
2026-08-01 12:29:02 -07:00
hanzo-dev 108e4f4987 ci: one reusable at two paths, and a channel that cannot be frozen by a branch
The fleet's whole pipeline is published twice — `.github/workflows/build.yml`
for github.com and `.hanzo/workflows/build.yml` for git.hanzo.ai, because each
forge resolves only its own directory. Two copies of one artifact, and nothing
compared them. The `.hanzo` copy had drifted nine lines: a truncated second
`on: workflow_call: inputs: runner: description:` block and a second top-level
`name:` key, wedged between line 7 and the real body. Invisible, because the
only reader of that file is a forge no push from here reaches.

They are one file now, and `build-yml-is-one-file` in hanzo.yml says so: it
normalises the ONE spelling that may legitimately differ — the path each copy
names for itself — and demands byte equality of the other 1,251 lines. Proven
both ways locally: PASS on this tree, FAIL on the tree as committed an hour ago.

And the channel. `sync-from-github.yml` carried the tag mirror as the LAST STEP
of the fast-forward job, under `set -euo pipefail`. So the moment main diverged
— two empty commits pushed straight to the forge — the job exited 1 at the
ancestry check and the tag step never ran again. A branch nobody could merge
froze `@v1` for every caller in three orgs, and a stale pipeline runs green, so
no caller could tell.

Measured while writing this:

  github.com  hanzoai/ci  v1 -> 830171c   client: lane present, Rust toolchain present
  git.hanzo.ai hanzoai/ci v1 -> 522aa9e   neither

  forge run hanzoai/cli #21 (a6ddd9f, main): step list has no "Client —
  regenerate from the release document" at all, and `Test (per hanzo.yml)`
  dies at `bash: line 1: cargo: command not found`, exit 127.

That is D1 in its final form: hanzoai/cli HAS the drift gate, HAS hanzo.yml,
HAS .spec-lock, and the gate still cannot run, because the pipeline the forge
hands it predates the lane that would run it. Same for all eight client repos.

A branch and a tag are independent facts, so they are two jobs with no `needs:`
between them. A diverged branch is now one red job about that branch, and the
channels keep moving.

The merge below carries the forge's two empty commits so the fast-forward has
somewhere to go.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 12:29:02 -07:00
hanzo-dev 830171c4c7 ci: the client lane checks on a push and moves on a release — one fetch, one document
A projection can stop being true two ways and both needed the same document, so
they are one step with two modes rather than two implementations.

  push / pull_request  CHECK the committed projection against the document its
                       OWN .spec-lock names, writing nothing.
  spec-update          MOVE it onto the document the release named, then commit
                       and cut.

The check half replaces the hand-rolled codegen-drift-check / spec-drift-check
step that four repos each carried a copy of, and fixes what none of those copies
could see: they regenerated from whatever hanzoai/openapi's main happened to be,
so two runs of one commit could disagree, and a change nobody in that lineage
made turned a client red. A pinned ref plus a pinned digest cannot.

And on a check the LOCK is itself a gate: the ref is pinned, so the bytes behind
it must be too. A digest that moved under a pinned ref means someone moved a tag,
and no amount of regenerating makes that safe.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 11:27:50 -07:00
hanzo-dev 8cf87a4082 ci: version: says WHERE a client's version lives — file, tag, or nowhere
Three languages, three honest answers, one key. A file to rewrite (npm, cargo,
cmake, pyproject); the tag itself, because a Go module has nothing to rewrite;
or nothing at all, for a repo whose version is not x.y.z and from which no patch
can be derived. Its projection still lands and is still gated — only the cut
waits for a human, instead of this lane tagging bytes under a number nobody
chose.

Inventing a VERSION file for the Go case, or a fake 0.0.1 for the gradle case,
would each be a second place a version could be wrong.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 11:21:13 -07:00
hanzo-dev eb9e7cbb96 ci: the client lane gates with the repo's own test: block, and tags last
Two corrections to the shape, both about ordering and both the same mistake:
declaring an assertion twice, and acting before the assertions ran.

NO build: KEY. Every client repo already says how it proves itself, in test: —
compiling the client and its examples is exactly what those blocks do. A build:
here would be that assertion written a second time, free to drift from the one a
plain push runs, and only one of the two would gate anything.

THE COMMIT AND THE TAG MOVED AFTER test:. They were running before it, so the
lane pushed a tag — which starts a publish, the one artifact in this workflow
nobody can take back — while the gate that would have refused it had not run.

version: is now optional. A Go module's version IS its tag; there is nothing to
rewrite, and inventing a VERSION file for those repos would be a second place a
version could be wrong. With no version: the current one is read from the tags.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 11:19:13 -07:00
hanzo-dev 5f66b28973 ci: every image build publishes a version, not just the tagged ones
Hanzo CI/CD / cicd (push) Successful in 1m9s
CI/CD / gate (push) Successful in 1m9s
A branch build published `sha-<short7>` and nothing else, so an image only
earned a semver when a human remembered to cut a git tag. That is why 14 of
the fleet's 117 universe pins name a commit instead of a release — not drift,
just the only tag CI ever offered them.

bin/imgver is that number, derived and never typed, monotonic against two
floors: the repo's own manifest (package.json / Cargo.toml / VERSION /
pyproject.toml, or an explicit `version:`) and the highest semver already at
the registry for that image. max + a patch, so one tag can never come to cover
two digests — deriving from the manifest alone re-publishes the same number
until someone edits the file, and a node on imagePullPolicy: IfNotPresent
never picks up the second one. universe's images.yml learned that rule on
iam-secret-sync; this is that rule, for every repo.

It is a SCRIPT, not inline shell, because the fleet has two build front doors:
this reusable, and the hand-rolled .hanzo/workflows/deploy.yml that 11 repos
carry instead of importing it. Both need the identical number, and written
twice it would be right twice and then wrong once. .github/actions/imgver is
the composite action those 11 call; build.yml calls the same script.

Also: the semver tag is proven resolvable before the run goes green (buildx
can exit 0 while the manifest is not yet servable, and a pin onto a phantom
tag is an ImagePullBackOff), and the run summary prints tag and digest
TOGETHER — universe pins repo:tag@digest and the kubelet honours the digest,
so a new tag beside an old digest reports the new version while serving the
old bytes.

22 cases in bin/imgver_test.sh, wired into hanzo.yml's test gate so it runs.
Both build.yml copies (.github/ for GitHub, .hanzo/ for the forge) stay
byte-identical.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.14
2026-08-01 11:15:36 -07:00
hanzo-dev 7e1de4c35f ci: the client lane fires on the release, not on a push
Two lanes were asking one question twice and the second could never answer it:
generate rewrites the tree, so a test: gate running after it judges the bytes
the lane just wrote, not the bytes the repo committed. Split them by event —
the projection moves when the DOCUMENT moves (repository_dispatch spec-update),
and a plain push runs the repo's own drift gate against the document its
.spec-lock already names.

Same reason the manual re-run no longer defaults to main: pressing 'run
workflow' must not drag a client forward onto an undeployed document. With no
payload it re-asks the document this tree already names, which makes the run a
no-op instead of a release.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 10:55:40 -07:00
hanzo-dev a3f14fbf4c ci: the client lane — one document, eight generated clients
A generated SDK is a projection of one document at one version, and until now
the fleet had no place that said so. Seven repos in seven languages either
hand-rolled the same eight lines (java, kotlin, cpp: codegen-drift-check) or had
nothing at all (python, js, go, rust had a generate.yml that fired twice ever,
both red) — and none of them could answer which document they were generated
from.

client: is that place. It fires on repository_dispatch spec-update, fetches
openapi.yaml AT THE SHA THE RELEASE NAMES, and refuses if the bytes hash to
anything but the digest the release published. That refusal is the gate: every
projection of one release comes from one document. Reading a live host instead
would be a lie about which deploy the client describes — at fanout time the
document is a git object and the host is whatever it happens to be serving.

The lane then compiles the client AND its examples (a regeneration that builds
but breaks the example flows has changed the surface out from under every
consumer), writes .spec-lock beside the code so anyone can ask a repo which
document it is without running a generator, and on a delta commits, bumps the
PATCH — derived from the current version, never typed — and pushes the tag. The
repo's own tag lane publishes, so the registry credential stays where the
publish is.

Two toolchain holes closed with it, both the same class this workflow already
refuses everywhere else: no rustup on a stock arc runner, so hanzoai/cli's
genspec --check gate would have been exit 127 — a declared gate that never
runs; and no JDK, without which openapi-generator cannot start.

Credential: SPEC_TOKEN, contents:read on the spec repo. The three existing
generate.yml already name this exact secret for the repo this lane replaces.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 10:46:58 -07:00
zandHanzo Dev f2066f1333 ci: confirm the lane with one hook left on the forge
Hanzo CI/CD / cicd (push) Successful in 55s
CI/CD / gate (push) Successful in 56s
The 2,591 per-repo hooks are gone; webhook 3035 is the only row in the forge's
webhook table. This delivery therefore has exactly one possible source.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 07:42:33 -07:00
zandHanzo Dev 7243683ee8 ci: exercise the forge build lane through the system webhook
Hanzo CI/CD / cicd (push) Successful in 1m40s
CI/CD / gate (push) Successful in 1m40s
Empty on purpose: this push carries no tree change, only the delivery. The
forge's per-repo hooks all named cloud's /v1/git/webhook, which answers 204
without dispatching, so a push here has never built. Forge-wide system
webhook 3035 now delivers to platform's /v1/git-webhook instead.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 07:34:02 -07:00
hanzo-dev ec73a49f84 checkout: a submodule the caller needs is a submodule it can ask for
actions/checkout defaults to fetching no submodules, and this workflow took that
default, so a caller whose source includes submodules built without them. That
failure is silent by construction: the directory is empty, every other file
compiles, and the artifact ships with a hole where a section used to be. Nothing
in the run says so.

hanzo-docs/docs is the case that surfaced it. Doc sections live in submodules
under apps/docs/content/docs/ — studio, from the public hanzo-docs/studio-docs —
and its export gate names those pages, so on this lane the build fails the gate
every time while the forge lane, which has passed `submodules: recursive` to its
own checkout all along, builds the same commit clean. Two lanes, two different
checkouts of one repo; the gate was right and the checkout was wrong.

An input rather than always-on. A build that omits part of its own source is
wrong, but flipping the default turns a submodule the job token cannot read into
a checkout failure for a caller that builds today — and this workflow cannot know
which of those any given repo has. The default is '', which is checkout's own
(false, and false for nested), so every existing caller keeps byte-for-byte the
checkout it has now; the repo that needs recursive says recursive.

Both copies, since aeb6adf carries this file at .github/ and .hanzo/ and every
tag serves both forges. Bodies stay byte-identical — only the headers differ.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-31 22:34:14 -07:00
hanzo-dev 2c918f5368 wip: preserve in-flight work
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-29 16:30:04 -07:00
hanzo-dev 66d4f21ba8 sync: move the channel tags, or a caller pinned to @v1 is pinned to 2026
Hanzo CI/CD / cicd (push) Successful in 1m7s
CI/CD / gate (push) Successful in 1m8s
`vX.Y.Z` is a release and `vX` is a channel, and the mirror only ever handled the
first. `git push --tags` cannot move a tag — repointing one is a non-fast-forward,
the push is rejected, and the `|| true` on that line swallows it — so the forge's
v1 and v2 froze at 522aa9e / ddf1234 the day they were first pushed, while
GitHub's v1 moved on. A channel that cannot move is a pin wearing a channel's
name, and the 12 repos that import `.hanzo/workflows/build.yml@v1|@v2` (cloud,
commerce, git, console, tasks, this repo) have been running a months-old pipeline
without one of them being able to see it from their own config.

Concretely today: they do not have the assertion that fails a gate reporting
success over ZERO tests (df6c025). Putting a fix in "one place" only works if the
one place is the ref the callers actually resolve.

So push the channels explicitly, forced, by name. `git tag -l | grep -xE 'v[0-9]+'`
is the whole rule: vX moves, vX.Y.Z never does. Releases keep the exact behaviour
they had (the unforced --tags push above still skips any that exists), and there
is one way to move a channel instead of none.

Also corrects this file's sibling claim in build.yml's header, which is now false
in a way that costs a run: it said the `uses:` PATH is part of the version and
`.github/...@v1` / `.hanzo/...@v2` must be paired. aeb6adf carries build.yml at
BOTH paths, so every tag since serves both forges and all four combinations
resolve the same pipeline. The real hazard is the one it named and misplaced — a
`uses:` that does not resolve is a SILENT no-run on this plane, the same failure
shape as a green gate over zero tests.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.13
2026-07-28 23:30:00 -07:00
hanzo-dev 19cc5d61c9 merge: reconcile the forge, which the fast-forward sync could not
git.hanzo.ai/hanzoai/ci and github.com/hanzoai/ci had both grown commits since
0b976af, so sync-from-github's ff-only check failed every 10 minutes with
"DIVERGED: resolve by hand" — by design, it refuses to force-push either side.
Nothing had resolved it, so the sync had carried NOTHING across in the meantime:
neither main nor tags. The forge is missing v1.0.12 for the same reason (the tag
mirror runs after the ff step, which was exiting 1).

That is not a cosmetic drift. The forge's build.yml is the one 12 callers import
(`hanzoai/ci/.hanzo/workflows/build.yml@v1` x5, `@v2` x7 — cloud, commerce, git,
console among them), and it does NOT contain the non-zero-test assertion added in
df6c025. A fix that lives in "one place" reaches those repos only if the one
place is the one they actually resolve.

Resolved as a MERGE, not a rebase or a force-push: a rebase mints new SHAs, so
the forge's head would still not be an ancestor of GitHub's and the ff check
would keep failing. A merge makes it one, which is exactly what the sync asks for
and lets the existing mechanism carry it the rest of the way.

Three conflicts, all from the same change landing on both sides
independently (8c54cfe here / 05b75b2 there, "scope on the verified org"):

  .gitignore, hanzo.yml — additive prose on the forge side only; kept both.
  render.go — the forge replaced the hand-copied :root block with
    <style>{{css}}</style> against the vendored @hanzo/brand sheet (7ad9222).
    Kept the forge's: the copy had already drifted off the house palette, and
    render_test.go (which arrives with it) pins the sheet's hash and fails any
    colour the page names for itself.
  ci — modify/delete: this side re-committed the 12MB darwin/arm64 binary in
    8c54cfe, the forge deleted it in fffae20. Honored the delete. The Dockerfile
    builds linux/amd64 from source (`go build -o /build/ci .`), so the tracked
    artifact was never an input, and /ci is now ignored.

Gate after the merge: go build, go vet, go test -count=1 ./... all green, 9 tests
(scope_test.go + the arriving render_test.go), up from 5.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 23:28:25 -07:00
hanzo-dev df6c025df0 ci: fail any gate that reports success without running a test
Hanzo CI/CD / cicd (push) Successful in 2m43s
CI/CD / gate (push) Successful in 2m44s
A green build over ZERO tests is the worst failure mode a gate has, because it is
indistinguishable from a healthy one. Two ways to reach it were live in this
fleet, both silent, both exit 0:

  $ go test ./...                    # nothing in the tree has a _test.go
  ?   example.com/notests   [no test files]
  $ echo $?
  0
  $ go test -tags skipCi ./...       # every _test.go is //go:build !skipCi
  ?   example.com/notests   [no test files]
  $ echo $?
  0

The second is how hanzoai/gateway hid: its whole fixture suite sits behind
`//go:build legacy` and its Makefile passed no -tags. When the tag was finally
passed, 12 subtests failed on an untouched main — including router_redirect
returning 404, which reproduced on the shipping config with the real binary.
hanzoai/iam runs `-tags skipCi` against files guarded `//go:build !skipCi` today.

Per-repo vigilance is not a mechanism, so the assertion lives here, once, and
every caller inherits it the moment it re-imports this workflow. The rule is the
runners' OWN words, not a heuristic: a gate fails when it SAYS it ran nothing
(Go `[no test files]` / `[no tests to run]`, pytest `collected 0 items` /
`no tests ran`, jest `No tests found` / `Tests: 0 total`, cargo `running 0 tests`,
mocha `0 passing`) AND nothing in its output shows a test having run. A gate that
is not a test gate — vet, lint, a build, a codegen-freshness check — says neither
and is untouched. The one way to satisfy it is to make the gate run a test.

Verified by pointing the step at suites that run nothing, not by reasoning about
it: a module with no test files FAILS, the same module with its tests excluded by
a build tag FAILS, `go vet ./...` and `go build ./...` PASS, a real suite PASSES,
and a genuinely failing suite still fails (the assertion masks nothing). Checked
against real fleet output too: this repo's own gate, hanzoai/git's
`go test ./modules/setting/...` (which legitimately prints `[no test files]` for
one sub-package while another runs), and hanzoai/gateway's full `make test`.

Carried at both workflow paths, since GitHub resolves only .github/workflows and
git.hanzo.ai only .hanzo/workflows; the bodies stay byte-identical.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v1.0.12
2026-07-28 22:55:04 -07:00
zeekayandHanzo Dev 7ad9222282 ci: take the design from @hanzo/brand instead of copying it
Hanzo CI/CD / cicd (push) Successful in 1m12s
CI/CD / gate (push) Successful in 1m18s
The dashboard carried its own :root block — a hand-copy of the house
palette, and being a copy it had already drifted off it. The status
colours were GitHub Primer's (#3fb950 / #f85149 / #d29922) where the
house says #10b981 / #ef4444 / #f59e0b. Every surface black was a shade
wrong and blue-tinted (#0b0b0d) against a scale that is strictly neutral
(--surface-0 #080808). The hairline border was an opaque #25252b where
the house hairline is a 6% white wash. The font stacks named neither
Geist Sans nor JetBrains Mono. Of nine colours exactly one — the accent
— still matched. That is what a second component source looks like a few
months in, and it is why this page had to stop being one.

The values now come from @hanzo/brand, and they arrive as that package's
own published artifact rather than as hex codes retyped here:
styles/variables.css, which it ships as a plain custom-property sheet
(exports["./styles/*"], documented for a bare <link>), vendored verbatim
and go:embed-ed. dashboard.css holds what is left over — layout: what is
a row, what sticks, what collapses on a phone — and names no colour,
radius or type size of its own.

Not a @hanzo/gui port, deliberately. gui is React over Tamagui and needs
a bundler, which would put npm and a JS build on the path that ships the
board you read when the builds are broken, and would trade one request
that returns the answer for a shell that fetches it a second later. What
this page ever needed from the design system was its token vocabulary,
not its components — and gui's own shell reads these same var(--hanzo-*)
names, so a Go binary and a React app now spend one vocabulary from one
source. go.mod stays empty; the image stays the binary and a CA bundle.

Two offline gates keep it honest, because vendoring alone would only
move the copy rather than end it:

  - TestBrandCSSIsUpstreamBytes pins the sheet to the sha256 of the
    version it claims to be. Without it, "just darken that one border"
    is a one-character local edit that silently rebuilds the second
    palette and nothing ever catches it. This is go.sum's argument.
  - TestDashboardCSSNamesNoColours fails on any hex or rgb() the page
    writes for itself. The old :root block did not arrive wrong; it
    arrived one reasonable exception at a time.

Neither gate touches the network, so proving we use one design system
costs the pipeline no npm and no registry.

TestRenderedPageShowsOnlyTheViewersOrg additionally pins at the HTML
layer what scope_test.go pins at the predicate layer: a lux viewer's
rendered page contains no other org's rows and no other org's name in
the nav. The renderer is where that leak lived, and it is now the
renderer that is asserted.

Rendered and checked in a browser, not just compiled: every token
resolves (surface-0 #080808, text-primary #fafafa, accent-muted #a78bfa,
success/error/warning #10b981/#ef4444/#f59e0b, hairline rgba(255,255,255,
.06), radius-card 8px, JetBrains Mono), color-scheme comes out dark via
the sheet's own .dark hook, and header, chips, nav, table and footer now
sit on one --space-6 gutter instead of the table drifting 12px left.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
v0.3.0
2026-07-28 17:00:53 -07:00
zeekayandHanzo Dev fffae20a9e ci: stop tracking the built binary
`go build ./...` writes the binary into the package directory under the
package's own name, so a routine build followed by `git add -A` committed
12MB of darwin/arm64 Mach-O to a repo whose image is built linux/amd64
from source by the Dockerfile. It has been dead weight in the build
context ever since, and `COPY . .` was shipping it to the builder only
for `go build` to overwrite it.

The artifact is never an input. Only the source is.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 17:00:32 -07:00
hanzo-dev f8ab325ace sync: mirror release tags to the forge, so tagged versions publish
The sync fast-forwarded main from GitHub but never carried tags, and a git
tag is the ONLY trigger for every publish workflow (twine/npm/go release).
So every release this session — cloud v1.801.299, cli v1.9.5, python
hanzoai-v3.1.3, js v2.0.1 — was tagged on GitHub and never reached the
forge that runs the publish: tagged, never published. Mirror tags in the
same job that syncs the branch. Idempotent; only a new tag fires a publish.

NOTE: this is the code half. The runner fleet must also be up — js-sdk
reported 716/717 hanzo-build-linux-amd64 runners offline, which no code
change fixes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:47:32 -07:00
zeekayandHanzo Dev 31ea5caf86 ci: build itself with its own pipeline
hanzoai/ci published the reusable workflow every other repo imports but
had no way to build its own image. build.yml is workflow_call-only, so
ghcr.io/hanzoai/ci:v0.1.0 was produced out of band and there was no
repeatable path to a second one — a CI system that cannot build itself
is not finished.

Adds the two pieces every other caller already has: a root hanzo.yml
(image + go vet + go test ./...) and a ~7-line caller pinned to
hanzoai/ci/.hanzo/workflows/build.yml@v2. Self-referential on purpose,
and pinned to the TAG rather than the working tree — that is what stops
a broken edit to build.yml from also breaking the build that would have
caught it.

The test gate is load-bearing here rather than decorative: scope_test.go
asserts the dashboard refuses a request carrying no X-Org-Id and that
`?org=` can only narrow. Those properties were absent once and the
service disclosed every org's build metadata to the internet, so a red
gate must block the image.

No `deploy:` — rollout stays a reviewed tag pin in hanzoai/universe
(crs/ci.yaml), the rule cloud and git follow. A pipeline that builds AND
rolls itself out can put an unreviewed image on a public host, and
cd.hanzo.ai's selfHeal reverts a direct patch regardless.

Also corrects build.yml's own header, which documented the @v1 form
(`.github/workflows/build.yml`) from inside the v2 file. Both forms are
valid — the path is part of the version, since the file moved between
tags — so the header now says which is which and who is on each. Pairing
them wrongly 404s the reference, and on this plane that is a silent
no-run.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:33:07 -07:00
zeekayandHanzo Dev 8c54cfea8e ci: scope on the verified org, not on a query parameter
This service shipped with /v1/runs answering 200 to anyone on the
internet, disclosing repo names, workflow names, branches, commit SHAs,
actor logins and pass/fail across EVERY org. The cause was a category
error, not a missing check: `?org=` narrowed what was rendered and read
like tenancy, so it looked like the surface had one. A query parameter
is a request for a view. It can never be the authority for one.

The authority is now X-Org-Id, minted by admin-guard from the
IAM-verified `owner` claim and written onto the request by the ingress
middleware's authResponseHeaders (Traefik overwrites any client-sent
value, so it cannot be forged on the wired path).

Three properties, each with a test that fails without it:

  - ABSENCE IS FATAL. No X-Org-Id => 403, never "no filter". Defaulting
    an absent scope to "everything" is precisely the bug; absence means
    the request did not come through the gate, so refusing is the only
    honest answer. The refusal body carries no repo names.
  - THE PARAMETER CAN ONLY NARROW. Permission is applied first, then
    `?org=` selects within it. A lux viewer asking ?org=hanzo gets an
    empty list, not hanzo's builds.
  - THE ORG LIST IS SCOPED TOO. A tenant sees only its own org in the
    nav. Hiding the runs but listing every org still discloses the set of
    orgs that build on the platform.

The admin org keeps the cross-tenant fleet view, matched to
admin-guard's IAM_ADMIN_ORG via CI_ADMIN_ORG — the guard decides who
gets in, this decides who sees everything, and the two must name the
same org or the fleet view silently collapses (or, set too wide,
promotes a tenant into it).

renderDashboard now takes the viewer and is handed only rows that
already passed v.visible. A template that can see everything is one edit
away from showing it.

Mutation-verified: restoring `visible` to the old filter-as-gate
behaviour fails TestTenantCannotWidenWithQueryParam and the end-to-end
handler test.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:33:07 -07:00
hanzo-dev aeb6adf4d5 ci: carry build.yml at BOTH paths, so one tag serves both forges
GitHub Actions resolves a reusable workflow ONLY from .github/workflows/ —
that is a platform rule, not a preference. git.hanzo.ai reads .hanzo/workflows/.
Callers are split across both, so a single tag can only serve everyone if the
file exists at both paths. This is what lets v1 be the one tag and v2 go away.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:31:44 -07:00