Compare commits

...
Author SHA1 Message Date
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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
zeekayandhanzo-dev 92143e5e8b ci: build itself with its own pipeline
Hanzo CI/CD / cicd (push) Successful in 1m40s
CI/CD / gate (push) Successful in 1m40s
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-27 23:39:43 -07:00
zeekayandhanzo-dev 05b75b2fd2 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-27 23:37:17 -07:00
hanzo-dev 3445d7acfe binaries: publish to a bucket, and gate the bits once instead of twice
`binaries:` published only to a GitHub release, which is storage on a quota we
do not own — 400MiB per cloud release, hundreds of releases. A top-level
`bucket:` now sends the same artifacts and the same binaries.json to hanzoai/s3
instead. Nothing about the format changes; only the url the index carries.

Credentials are the S3_ADMIN_* names the services already read (clients/s3admin),
pulled from KMS at run time — not a CI-only copy nobody remembers to rotate.
A declared bucket with no credential fails the publish rather than shipping an
index whose artifacts are absent, and the lane proves the index is readable
UNAUTHENTICATED before calling it published: a host fetches it with no
credentials, so a private object is an index that resolves every app to a 403.
Granting that read is the bucket's job, one policy once, which is why this
checks rather than sets it. Artifacts upload first and the index last, so it
never names an object that is not there yet.

Also adds `tests:` (default true, so every existing caller is byte-identical).
false asserts the gate already ran on this exact commit — it does not mean ship
untested. hanzoai/cloud mints its v* tag only after that SHA passed the gate on
main and built and smoked (clients/platform/release.go), so a tag build re-tests
a proven commit and pays a 3108-package link storm for no new information. The
publish-after-gating invariant is unchanged: it is enforced once, not twice.

Verified end to end against a real hanzoai/s3: both platforms cross-compiled,
signed SigV4 PUTs, index last, anonymous GET 200, digest of the fetched bits
equal to the index. Four paths exercised — non-tag builds without publishing,
missing credential refuses, private bucket refuses naming the remedy, public
bucket publishes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:08:41 -07:00
hanzo-dev 844e746c32 feat(ci): binaries: could only mean go build, so half the contract had no toolchain
`binaries:` is the artifact half of hanzo.yml — the lane that ships an executable
a running host installs, as opposed to `images:`, which ships an OCI image a
cluster runs. It read exactly one recipe shape: `main:` + `platforms:` + a
hardcoded `go build`. So a repo whose artifact is an npm tarball, a wheel, or a
Rust binary could declare nothing at all, and the honest answer to "how do I
publish this" was "you cannot, write a Dockerfile and ship an image instead" —
which is a different artifact with a different consumer.

Two optional fields close that, in the SAME block, with no second format:

  run:  the command that builds — any toolchain
  out:  the glob of what it produced

`main:` is unchanged and stays the zero-config Go lane, so every existing repo
builds byte-for-byte as before. A `run:` entry indexes as os/arch "any", because
an npm tarball is not per-platform and an index entry that claimed one would be a
lie a host acts on. Declaring `run:` without `out:` fails the build rather than
publishing an empty index — nothing else names what the command produced.

This is the GitHub half of a contract now implemented on both sides: hanzoai/
cloud's POST /v1/runner reads the identical block and builds it in-cluster, one
initContainer per entry in the toolchain image `image:` names (the one field this
lane reads past — here the toolchain IS the runner). Same recipe, same
binaries.json, same URL; two front doors, as with `images:`.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:04:33 -07:00
zooqueenandhanzo-dev c4e3445839 ci: pull main from GitHub — the forge had drifted 3 commits with no transport
ci is one of 13 non-mirror repos in the hanzoai org, and non-mirror is the gap
between the two mechanisms that keep git.hanzo.ai current: hanzoai/mirrors
reconcile.py only creates pull-mirrors and leaves existing repos alone, so a
repo already present as a NON-mirror is skipped forever; the per-repo pull
covers repos that are native-canonical and must not be mirrors. A non-mirror
without the file has neither.

Measured across the 12 non-empty non-mirror hanzoai repos: every one carrying
the file sits 0-1 commits behind GitHub; ci and cloud, the two without it, sat
3 and 10 behind. None was ever AHEAD.

No credential — this repo is public, so the fetch is anonymous. Fast-forward
only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 19:37:59 -07:00
hanzo-dev 58ed7ea7b4 feat(ci): binaries: — build a plugin once, and every host installs the same bits
`images:` ships an OCI image a CLUSTER runs. There was no lane for the other
artifact we ship: an executable a RUNNING host installs, which is what a zip
plugin is — zip.Plugin{URL, Sum} fetches it, verifies the SHA-256 BEFORE the
file is made executable, and caches it by digest. Every repo that needed one
grew its own release.yml, and no two agree on where the digest comes from.

One block in the hanzo.yml a repo already has:

  binaries:
    - name: billing
      main: ./cmd/billing
      platforms: [linux/amd64, linux/arm64]

Same job, same runner, same KMS token ladder — not a second pipeline. Two rules
of its own: BUILT on every push, so an arm64 cross-compile that breaks fails the
PR that broke it rather than the release; PUBLISHED on a tag and AFTER the test
gate, because a host installs an artifact unattended (an image is rolled out by
a reviewed pin — an artifact is not). CGO_ENABLED=0 -trimpath is not taste: the
host runs these bits on whatever base image the host is, and the digest must be
a function of the source, not of the checkout path.

binaries.json ships with them — {name,os,arch,url,sha256} per artifact — so the
bits and the digest that authorizes them are one release and a host reads both
from one place. The job summary prints the zip.Load() to paste: a digest a
human retypes is a digest a human gets wrong.

The forge URL comes from GITHUB_SERVER_URL/GITHUB_API_URL rather than a
hardcoded hostname, so one lane serves github.com and a forge front door.

Run end to end against a stand-in release API before committing: three platforms
cross-compiled and published, a re-run of the same tag converging instead of
422ing, then a zip host installing the linux/arm64 artifact by URL+Sum and
serving its routes (200/201), refusing it when the digest is wrong, and
restarting from the digest cache with the release host down.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 16:01:51 -07:00
zeekayandhanzo-dev 0b976af3e1 feat: the ci.hanzo.ai dashboard — a view over Hanzo Git, not a second CI
cd.hanzo.ai has been the delivery surface for a while; there was no build
surface. ci.hanzo.ai 404'd and this repo held only the reusable workflow, so
"how is the fleet building" had no answer outside per-repo pages.

This owns no run state. Hanzo Git schedules every job and holds every log; this
reads that and presents it. A CI service with its own run database would put two
answers to "did the build pass" in the fleet, and the one users look at would be
the one that drifts. git.hanzo.ai is the store, ci.hanzo.ai is the view.

Reads /v1/repos/{owner}/{repo}/actions/runs (our Gitea drops the /api prefix).
Scan strategy is repo-search sorted by activity, windowed: the instance mirrors
~1400 repos and almost none built recently, so walking all of them would spend
the whole refresh budget confirming silence. One poller, one cache, bounded
fan-out — N open dashboards cost the forge the same as one, and a status page
must never be what degrades the system it reports on.

A failed poll keeps the last good rows and says so, rather than blanking: an
empty page reads as "nothing is building", which is the opposite of the truth
during an outage. Same reason /healthz is liveness-only and does not gate on
having a snapshot.

⚠ The bug this caught in itself, before shipping: Hanzo Git reports every
finished run as status=completed regardless of outcome, and carries the verdict
in a separate `conclusion`. Bucketing on status alone drew 15 of 20 live runs
red — every success and every cancellation shown as failing. Both fields are now
required to decide a colour. `cancelled` is its own bucket, not a failure:
superseded pushes cancel in-flight runs and they are the largest category on
this fleet, so folding them into red makes a board nobody trusts.

Verified against the live instance: buckets match the raw API exactly —
18 completed/success, 4 completed/cancelled, 3 in_progress + 5 queued = 8
running, 0 failing.

Tenancy is the org slug, the same value Hanzo Git namespaces repos by, IAM
issues in the `owner` claim, and Hanzo CD fences projects with. Filtering here
is that boundary, not a parallel notion of who-sees-what.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 15:23:12 -07:00
hanzo-dev 0b7a45a7d9 ci: drop the Gitea mirror-sync nudge
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.

One mechanism, in one place, instead of ~350 copies of a cron.
2026-07-27 10:27:15 -07:00
zeekayandhanzo-dev ddf123485a ci: publish the reusable from .hanzo/workflows, and leave GitHub only a sync
The forge resolves `uses:` only under WORKFLOW_DIRS, which is .hanzo/workflows.
This repo published its reusable from .github/workflows/build.yml, so every
caller -- cloud, commerce, console, git -- failed to resolve it:

  resolve uses "hanzoai/ci/.github/workflows/build.yml@v1":
  path ".github/workflows/build.yml" must be under a configured workflow directory

No build has run on any of those four repos since. Moving the file is the fix,
and it is also just the law: .hanzo/workflows is where CI lives, and GitHub gets
exactly one workflow that pushes nothing and only nudges canonical.

Callers move to hanzoai/ci/.hanzo/workflows/build.yml. Cutting a NEW tag rather
than force-moving v1 -- moving a floating tag is what put this repo's two heads
out of sync earlier today, and the same hazard is already on record from
luxfi/threshold@v1.9.4.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 16:55:03 -07:00
zeekay 522aa9e17b fix(ci): the release image tag IS the git tag, and no-deploy callers skip deploy
Two things that made a release a manual job.

The semver tag was published v-stripped: `git tag v1.26.19` produced
ghcr.io/hanzoai/git:1.26.19, while every universe CR pins v1.26.19. Nothing in
between reconciled the two, so releases were finished by hand — `crane copy`
sha-<sha7>-amd64 onto the semver a human typed, then bump the CR. Publish the
ref name verbatim instead: identity in, identity out, nothing to remember at
the seam. The v-stripped alias stays for CRs already pinned that way (world
2.4.51) and is skipped when a repo tags without a v. `<ver>-amd64` is deleted —
no CR in the fleet pinned it, it was a third name for one digest.

The deploy step read .deploy.services on a caller that declares no `deploy:`;
`jq '.[]'` over null aborts the step. The deploy.on gate hid it on branch
pushes and is bypassed on a tag, so it would have surfaced only on the release
it was blocking. Guard on `.deploy` being a map, the same shape the build step
already uses for `images:`.
2026-07-26 11:23:23 -07:00
hanzo-dev 6a755f27b5 ci: publish images to oci.hanzo.ai, and say so when we do not
Two changes, both about getting images off GitHub:

Use the canonical registry name. oci.hanzo.ai and registry.hanzo.ai are
the same registry behind the same auth, so existing pulls by either name
keep resolving -- this only settles which name we build against.

Stop skipping quietly. Every path that gives up on publishing to our own
registry logged a ::notice:: and moved on, so a build that shipped to
GHCR alone looked identical to one that shipped to both. That silence is
why the fleet still pulls from GitHub. These are warnings now: the repos
missing a registry credential name themselves in their next run, which
is the list we need before any deploy can be moved over.
2026-07-25 13:44:40 -07:00
hanzo-dev a66bd46cb2 fix(ci): universe push retry is shallow-clone-safe (fetch+reset+reapply, not rebase)
v1.0.7's `git pull --rebase` retry never worked: the universe clone is
`--depth 1` shallow, so rebase has no merge base and fails immediately →
push stayed rejected under concurrent rolls, hard-failing the Deploy step
with the expired kubeconfig (hanzoai/world v2.4.44 stuck at 2.4.43).

Replace rebase with a shallow-safe loop: on a non-fast-forward reject,
`git fetch --depth 1 origin main` + `reset --hard FETCH_HEAD`, re-apply the
one-file CR tag change, re-commit, retry (6×). A no-op after reset (remote
already carries our tag) counts as recorded. Never force-push. Verified
against a real shallow clone with a concurrent racing push: our CR change
lands, the racer's other-file commit is preserved.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-22 04:03:22 -07:00
hanzo-dev f19e157205 fix(ci): universe push retry is shallow-clone-safe (fetch+reset+reapply, not rebase)
v1.0.7's `git pull --rebase` retry never worked: the universe clone is
`--depth 1` shallow, so rebase has no merge base and fails immediately →
push stayed rejected under concurrent rolls, hard-failing the Deploy step
with the expired kubeconfig (hanzoai/world v2.4.44 stuck at 2.4.43).

Replace rebase with a shallow-safe loop: on a non-fast-forward reject,
`git fetch --depth 1 origin main` + `reset --hard FETCH_HEAD`, re-apply the
one-file CR tag change, re-commit, retry (6×). A no-op after reset (remote
already carries our tag) counts as recorded. Never force-push. Verified
against a real shallow clone with a concurrent racing push: our CR change
lands, the racer's other-file commit is preserved.
2026-07-22 04:03:22 -07:00
hanzo-dev fe00b22aaf fix(ci): universe push rebases+retries on non-fast-forward — concurrent deploys no longer stall the roll
Every service's deploy commits the desired image tag to universe/main, so
when several land at once a plain `git push` loses the race with a
non-fast-forward reject. Before, that set recorded=0 and (with an expired
runner kubeconfig) hard-failed the Deploy step, leaving the CR un-updated —
exactly why hanzoai/world stuck at 2.4.41 while 2.4.42 deploys "failed".

Wrap the push in a rebase-and-retry loop (up to 6): our one-file CR change
replays cleanly onto the moved remote (other services touch other CRs). A
genuine same-CR conflict (a concurrent roll of THIS service) aborts the
rebase and stays recorded=0 — never force, so no silent backward roll.
Verified against a live racing remote: clean race → rebased + pushed,
recorded=1; the racer's commit preserved.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-22 00:19:25 -07:00
hanzo-dev 9a5ba3d5c7 fix(ci): universe push rebases+retries on non-fast-forward — concurrent deploys no longer stall the roll
Every service's deploy commits the desired image tag to universe/main, so
when several land at once a plain `git push` loses the race with a
non-fast-forward reject. Before, that set recorded=0 and (with an expired
runner kubeconfig) hard-failed the Deploy step, leaving the CR un-updated —
exactly why hanzoai/world stuck at 2.4.41 while 2.4.42 deploys "failed".

Wrap the push in a rebase-and-retry loop (up to 6): our one-file CR change
replays cleanly onto the moved remote (other services touch other CRs). A
genuine same-CR conflict (a concurrent roll of THIS service) aborts the
rebase and stays recorded=0 — never force, so no silent backward roll.
Verified against a live racing remote: clean race → rebased + pushed,
recorded=1; the racer's commit preserved.
2026-07-22 00:19:25 -07:00
hanzo-dev 123a72df80 fix(ci): deploy step is GitOps-authoritative — an expired runner kubeconfig no longer false-fails a live deploy
The Deploy step records the desired image tag durably in universe (the
in-cluster operator reconciles it every ~5min) and THEN runs runner-side
kubectl to accelerate + smoke-test the roll. Those kubectl calls were
unguarded, so when the short-lived DOKS kubeconfig from KMS expires (~7d)
every call fails "You must be logged in to the server" and the step reports
failure — even though the operator already rolled the image (hanzoai/world
v2.4.33/34/35 all showed a red Deploy while serving the new build live).

Track `recorded`: once the universe record is pushed (or already pins the
tag), runner kubectl is best-effort (warn, don't fail) because the operator
owns the rollout. With no durable record (bare-Deployment repos) kubectl is
the only path and stays fatal — the real deploy gate is preserved. Verified
under `set -euo pipefail`: recorded=1 → green on auth failure; recorded=0 → fatal.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-21 17:02:48 -07:00
hanzo-dev ceb720a5b5 fix(ci): deploy step is GitOps-authoritative — an expired runner kubeconfig no longer false-fails a live deploy
The Deploy step records the desired image tag durably in universe (the
in-cluster operator reconciles it every ~5min) and THEN runs runner-side
kubectl to accelerate + smoke-test the roll. Those kubectl calls were
unguarded, so when the short-lived DOKS kubeconfig from KMS expires (~7d)
every call fails "You must be logged in to the server" and the step reports
failure — even though the operator already rolled the image (hanzoai/world
v2.4.33/34/35 all showed a red Deploy while serving the new build live).

Track `recorded`: once the universe record is pushed (or already pins the
tag), runner kubectl is best-effort (warn, don't fail) because the operator
owns the rollout. With no durable record (bare-Deployment repos) kubectl is
the only path and stays fatal — the real deploy gate is preserved. Verified
under `set -euo pipefail`: recorded=1 → green on auth failure; recorded=0 → fatal.
2026-07-21 17:02:48 -07:00
hanzo-dev eb8eac8c54 feat(ci): source build_secrets from KMS as --build-arg; honor static images[].args
The build loop read name/ctx/df/repo/sfx/plats but never passed images[].args,
so a repo's declared build args (e.g. sentry's required SENTRY_IMAGE pin) were
silently dropped. Now assemble --build-arg from BOTH static args and KMS-sourced
build_secrets: a repo declares 'build_secrets: [NAME]' per image; the KMS step
fetches each NAME from the same org/path/env and exports it (masked) for the
build step. Undeclared => empty => buildx line byte-for-byte unchanged.

Unblocks world Satellite/Terrain: VITE_MAPBOX_TOKEN now bakes into the Vite SPA
from KMS (hanzo/deploy, env=prod) at build. Key name IS the build-arg name.
2026-07-18 12:29:43 -07:00
hanzo-dev 15e7f01e01 feat(ci): source build_secrets from KMS as --build-arg; honor static images[].args
The build loop read name/ctx/df/repo/sfx/plats but never passed images[].args,
so a repo's declared build args (e.g. sentry's required SENTRY_IMAGE pin) were
silently dropped. Now assemble --build-arg from BOTH static args and KMS-sourced
build_secrets: a repo declares 'build_secrets: [NAME]' per image; the KMS step
fetches each NAME from the same org/path/env and exports it (masked) for the
build step. Undeclared => empty => buildx line byte-for-byte unchanged.

Unblocks world Satellite/Terrain: VITE_MAPBOX_TOKEN now bakes into the Vite SPA
from KMS (hanzo/deploy, env=prod) at build. Key name IS the build-arg name.
2026-07-18 12:29:43 -07:00
hanzo-dev 7f02e0645b fix(ci): upgrade GHCR login to the KMS write:packages token (unblocks cms)
A per-job GITHUB_TOKEN or package-less GH_PAT can't push a package linked to
another repo (ghcr.io/hanzoai/cms 403). New step reads the org ghcr push token
(buildx-ghcr-auth, admin write:packages) from the cluster via the KMS kubeconfig
and re-logs ghcr with it — pushes/creates ANY org package. Fail-safe: public
forks with no KMS keep their repo-linked GITHUB_TOKEN. Native creds from KMS.
2026-07-18 10:36:58 -07:00
hanzo-dev 4c55afe813 fix(ci): upgrade GHCR login to the KMS write:packages token (unblocks cms)
A per-job GITHUB_TOKEN or package-less GH_PAT can't push a package linked to
another repo (ghcr.io/hanzoai/cms 403). New step reads the org ghcr push token
(buildx-ghcr-auth, admin write:packages) from the cluster via the KMS kubeconfig
and re-logs ghcr with it — pushes/creates ANY org package. Fail-safe: public
forks with no KMS keep their repo-linked GITHUB_TOKEN. Native creds from KMS.
2026-07-18 10:36:58 -07:00
hanzo-dev 51b2286b79 fix(ci): GHCR login prefers KMS-backed GH_PAT — push any org package (unblocks cms)
The automatic per-job GITHUB_TOKEN only writes a package linked to THIS repo, so
it 403s on a package created/linked elsewhere (ghcr.io/hanzoai/cms). Use the
KMS-backed GH_PAT (admin:org + write:packages) when present — it pushes/creates
any <org> package — and fall back to the automatic token for public forks that
have no GH_PAT. Native creds from KMS, not GitHub's scoped token.
2026-07-18 10:30:32 -07:00
hanzo-dev 2127fdf7dc fix(ci): GHCR login prefers KMS-backed GH_PAT — push any org package (unblocks cms)
The automatic per-job GITHUB_TOKEN only writes a package linked to THIS repo, so
it 403s on a package created/linked elsewhere (ghcr.io/hanzoai/cms). Use the
KMS-backed GH_PAT (admin:org + write:packages) when present — it pushes/creates
any <org> package — and fall back to the automatic token for public forks that
have no GH_PAT. Native creds from KMS, not GitHub's scoped token.
2026-07-18 10:30:32 -07:00
hanzo-dev 7077cec764 fix(ci): mirror-credential login is best-effort — a registry.hanzo.ai hiccup must not skip the build
Both `docker login registry.hanzo.ai` calls ran unguarded under bash -e, so a
login FAILURE (registry down / transient) aborted the step and SKIPPED the whole
build — the image never reached GHCR (the primary). A missing cred was already
fail-safe; a failed login now is too: warn + skip the mirror, push GHCR-only.
Same best-effort principle as the cloud release lane.
2026-07-17 23:46:54 -07:00
hanzo-dev 25548f34a0 fix(ci): mirror-credential login is best-effort — a registry.hanzo.ai hiccup must not skip the build
Both `docker login registry.hanzo.ai` calls ran unguarded under bash -e, so a
login FAILURE (registry down / transient) aborted the step and SKIPPED the whole
build — the image never reached GHCR (the primary). A missing cred was already
fail-safe; a failed login now is too: warn + skip the mirror, push GHCR-only.
Same best-effort principle as the cloud release lane.
2026-07-17 23:46:54 -07:00
hanzo-dev 1aae2b65a3 ci: pin universe CRs to canonical semver on tag releases (not sha-<short>-amd64)
Deploy roll now pins spec.image.tag to the BARE semver (VER = ref_name w/o v)
on a tagged release — matching world 2.4.10 / cloud v1.801.62 — instead of the
sha-<short>-amd64 it hardcoded on every build. Branch/main pushes keep their
per-commit sha tag (continuous dev path), now arch-matched (bare sha for
multi-arch, fixing a latent -amd64 mismatch).

Build step also publishes the bare semver tag on tag builds (kept the -amd64
alias for back-compat). New semver backward-clobber guard: a branch build never
overwrites a service already pinned to a semver release — complements the
sha-ancestry guard (which only sees sha- tags), so neither kind of pin rolls
backward.

Emitter of the 'deploy(<svc>): sha-...' hanzo-ci commits. Consumed as @v1.
2026-07-17 23:16:48 -07:00
hanzo-dev fcdb02af5f ci: pin universe CRs to canonical semver on tag releases (not sha-<short>-amd64)
Deploy roll now pins spec.image.tag to the BARE semver (VER = ref_name w/o v)
on a tagged release — matching world 2.4.10 / cloud v1.801.62 — instead of the
sha-<short>-amd64 it hardcoded on every build. Branch/main pushes keep their
per-commit sha tag (continuous dev path), now arch-matched (bare sha for
multi-arch, fixing a latent -amd64 mismatch).

Build step also publishes the bare semver tag on tag builds (kept the -amd64
alias for back-compat). New semver backward-clobber guard: a branch build never
overwrites a service already pinned to a semver release — complements the
sha-ancestry guard (which only sees sha- tags), so neither kind of pin rolls
backward.

Emitter of the 'deploy(<svc>): sha-...' hanzo-ci commits. Consumed as @v1.
2026-07-17 23:16:48 -07:00
Hanzo AI 93eafe4f55 roll: never backward, sweep every same-repo image in the CR
Builds finish out of order — a slow build of an older commit overwrote newer
rolls (observed live: studio pinned back one merge). The roll now skips when
the CR's sha is a descendant of the builder's. And the tag field alone left
same-image sidecars on stale tags every roll (reconciled by hand four times
tonight); every same-repo image reference in the CR now moves together.
2026-07-17 13:02:41 -07:00
Hanzo AI bd4d78c3a2 roll: never backward, sweep every same-repo image in the CR
Builds finish out of order — a slow build of an older commit overwrote newer
rolls (observed live: studio pinned back one merge). The roll now skips when
the CR's sha is a descendant of the builder's. And the tag field alone left
same-image sidecars on stale tags every roll (reconciled by hand four times
tonight); every same-repo image reference in the CR now moves together.
2026-07-17 13:02:41 -07:00
Hanzo AI 4fcc2e02ce fix(ci): provision Node + corepack for JS test gates (exit-127 corepack-not-found on bare arc runners)
The reusable workflow provisions Go and C toolchains for hanzo.yml test
gates but never Node — any JS caller's gate (pnpm install && pnpm lint)
died at 'corepack: command not found' before reading package.json.
Mirror the Go step: guarded to package.json callers, setup-node 22 +
corepack enable so the repo's pinned packageManager shims resolve.
2026-07-16 10:58:04 -07:00
Hanzo AI d9d6a0f265 fix(ci): provision Node + corepack for JS test gates (exit-127 corepack-not-found on bare arc runners)
The reusable workflow provisions Go and C toolchains for hanzo.yml test
gates but never Node — any JS caller's gate (pnpm install && pnpm lint)
died at 'corepack: command not found' before reading package.json.
Mirror the Go step: guarded to package.json callers, setup-node 22 +
corepack enable so the repo's pinned packageManager shims resolve.
2026-07-16 10:58:04 -07:00
Hanzo AI 2866795e2d fix(ci): deploy rollout-timeout configurable, default 600s (180s failed mid-pull on GB-scale images → studio deploys silently failed since 0.15.8); set-image on repo-matching containers only (never '*' wildcard clobbering rclone sidecars on bare deployments) 2026-07-15 21:59:57 -07:00
Hanzo AI e4509f4a8b fix(ci): deploy rollout-timeout configurable, default 600s (180s failed mid-pull on GB-scale images → studio deploys silently failed since 0.15.8); set-image on repo-matching containers only (never '*' wildcard clobbering rclone sidecars on bare deployments) 2026-07-15 21:59:57 -07:00
zeekay 3083524803 ci: mirror via crane (IAM token realm vs buildx multi-scope) 2026-07-15 02:59:43 -07:00
zeekay c5df463e34 ci: mirror via crane (IAM token realm vs buildx multi-scope) 2026-07-15 02:59:43 -07:00
zeekay 313180e201 ci: mirror prefers direct REGISTRY_USER/PASSWORD (private repos can't see org KMS secrets on Free); kubeconfig fallback retained 2026-07-15 02:43:31 -07:00
zeekay ca618492db ci: mirror prefers direct REGISTRY_USER/PASSWORD (private repos can't see org KMS secrets on Free); kubeconfig fallback retained 2026-07-15 02:43:31 -07:00
zeekay 76cf840edb ci: remove bisect artifacts (root cause: caller-org default workflow permissions must be write for permissions: packages: write reusables) 2026-07-14 23:37:05 -07:00
zeekay fb7f460370 ci: remove bisect artifacts (root cause: caller-org default workflow permissions must be write for permissions: packages: write reusables) 2026-07-14 23:37:05 -07:00
zeekay 28f1664092 ci: min5/6/7 single-variable bisect 2026-07-14 23:35:39 -07:00
zeekay 120983607c ci: min5/6/7 single-variable bisect 2026-07-14 23:35:39 -07:00
zeekay f3acdc8cf7 ci: min3/min4 bisect 2026-07-14 23:33:00 -07:00
zeekay 76324c93b6 ci: min3/min4 bisect 2026-07-14 23:33:00 -07:00
zeekay 02370735a0 ci: min2 header bisect 2026-07-14 23:32:16 -07:00
zeekay cb113a3cee ci: min2 header bisect 2026-07-14 23:32:16 -07:00
zeekay 39553ee364 ci: min reusable (cross-org bisect) 2026-07-14 23:31:29 -07:00
zeekay 0737508323 ci: min reusable (cross-org bisect) 2026-07-14 23:31:29 -07:00
zeekay 5ee1b857de fix(ci): test-only callers (no images:) skip the build step cleanly 2026-07-14 23:28:00 -07:00
zeekay 4900364b02 fix(ci): test-only callers (no images:) skip the build step cleanly 2026-07-14 23:28:00 -07:00
zeekay e628b788c4 feat(ci): dual-host image push — ghcr.io + registry.hanzo.ai mirror
Public identity stays ghcr.io (GitHub imports keep working); every built tag
is also mirrored server-side (imagetools create) to OUR fleet registry so the
cluster never depends on GHCR to deploy. Credential = the cluster-synced
registry-credentials dockerconfig read via the KMS-fetched kubeconfig;
gracefully skips (GHCR-only) when unavailable. No rebuild, no extra minutes.
2026-07-14 23:26:48 -07:00
zeekay 1edfdfbb1c feat(ci): dual-host image push — ghcr.io + registry.hanzo.ai mirror
Public identity stays ghcr.io (GitHub imports keep working); every built tag
is also mirrored server-side (imagetools create) to OUR fleet registry so the
cluster never depends on GHCR to deploy. Credential = the cluster-synced
registry-credentials dockerconfig read via the KMS-fetched kubeconfig;
gracefully skips (GHCR-only) when unavailable. No rebuild, no extra minutes.
2026-07-14 23:26:48 -07:00
hanzo-dev 56e83218ca fix(ci): deploys record desired state in universe, then accelerate
gitops-reconcile re-applies universe CRs every ~5min, so a CR patch (or
set-image) alone is reverted on the next cycle. The deploy step now
bumps infra/k8s/operator/crs/<svc>.yaml in universe (no-op-safe commit,
same KMS git token) and keeps the CR patch/set-image only to make the
roll immediate.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:26:57 -07:00
hanzo-dev 3e04067597 fix(ci): deploys record desired state in universe, then accelerate
gitops-reconcile re-applies universe CRs every ~5min, so a CR patch (or
set-image) alone is reverted on the next cycle. The deploy step now
bumps infra/k8s/operator/crs/<svc>.yaml in universe (no-op-safe commit,
same KMS git token) and keeps the CR patch/set-image only to make the
roll immediate.
2026-07-14 17:26:57 -07:00
hanzo-dev b5df8baca7 fix(ci): deploy patches the operator Service CR, not the Deployment
The hanzo operator reconciles Deployments from the Service CR — a bare
'kubectl set image' gets reverted on the next reconcile (observed on
world: rolled, served, reverted minutes later). Patch the CR's
spec.image when one exists; keep the Deployment fallback for
non-operator services.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:22:08 -07:00
hanzo-dev ece186fab6 fix(ci): deploy patches the operator Service CR, not the Deployment
The hanzo operator reconciles Deployments from the Service CR — a bare
'kubectl set image' gets reverted on the next reconcile (observed on
world: rolled, served, reverted minutes later). Patch the CR's
spec.image when one exists; keep the Deployment fallback for
non-operator services.
2026-07-14 17:22:08 -07:00
hanzo-dev 3668bb9ebd fix(ci): provision static kubectl for the deploy step
Bare arc runners ship no kubectl; the deploy step died with exit 127
right after the image push. Same sudo-free static-binary pattern as
jq/yq.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:16:46 -07:00
hanzo-dev e462b8bf81 fix(ci): provision static kubectl for the deploy step
Bare arc runners ship no kubectl; the deploy step died with exit 127
right after the image push. Same sudo-free static-binary pattern as
jq/yq.
2026-07-14 17:16:46 -07:00
hanzo-dev 86428e2f8a fix(ci): survive apt mirror rot in the cgo provision step
arc snapshot images intermittently lose archive.ubuntu.com Release files
(apt-get update exit 100 → every Go repo's build dies before its gates).
Repoint to the DO mirror (sources.list + deb822) and retry once; still a
no-op when gcc is baked in.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:04:19 -07:00
hanzo-dev 887fa3962e fix(ci): survive apt mirror rot in the cgo provision step
arc snapshot images intermittently lose archive.ubuntu.com Release files
(apt-get update exit 100 → every Go repo's build dies before its gates).
Repoint to the DO mirror (sources.list + deb822) and retry once; still a
no-op when gcc is baked in.
2026-07-14 17:04:19 -07:00
hanzo-dev 9f4fd9a959 fix(ci): static yq provisioning — unblocks builds on locked-down arc nodes
PyYAML can't install on arc nodes (sudo blocked by no_new_privs, no pip). Parse
hanzo.yml with a curl-installed static yq binary + jq instead. Proven by the cms
build (past provision→GHCR→KMS). Robust on bare AND pre-baked nodes.
2026-07-13 10:39:40 -07:00
hanzo-dev b67fa75bbf fix(ci): static yq provisioning — unblocks builds on locked-down arc nodes
PyYAML can't install on arc nodes (sudo blocked by no_new_privs, no pip). Parse
hanzo.yml with a curl-installed static yq binary + jq instead. Proven by the cms
build (past provision→GHCR→KMS). Robust on bare AND pre-baked nodes.
2026-07-13 10:39:40 -07:00
f7426add1d fix(ci): provision Node for JS/TS test gates on bare arc runners (#9)
A hanzo.yml `test:` gate for a JS/TS repo (e.g. `corepack … && pnpm lint`) runs
directly on the runner, but the minimal arc runner image ships no user-PATH Node
(its bundled node is for the runner's own action execution only), so the gate died
`corepack: command not found` (exit 127) — e.g. hanzo.ai's lint gate.

Add a `Provision Node toolchain` step (the JS twin of the existing Go-toolchain
provision): `actions/setup-node@v4` pinned to LTS 22, guarded to repos with a
package.json (`hashFiles('package.json') != ''`) so non-JS callers are unaffected
and harmless if a future runner image bakes Node in. corepack then activates the
exact pnpm/npm the gate requests.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 11:20:08 -07:00
109c92392b fix(ci): provision Node for JS/TS test gates on bare arc runners (#9)
A hanzo.yml `test:` gate for a JS/TS repo (e.g. `corepack … && pnpm lint`) runs
directly on the runner, but the minimal arc runner image ships no user-PATH Node
(its bundled node is for the runner's own action execution only), so the gate died
`corepack: command not found` (exit 127) — e.g. hanzo.ai's lint gate.

Add a `Provision Node toolchain` step (the JS twin of the existing Go-toolchain
provision): `actions/setup-node@v4` pinned to LTS 22, guarded to repos with a
package.json (`hashFiles('package.json') != ''`) so non-JS callers are unaffected
and harmless if a future runner image bakes Node in. corepack then activates the
exact pnpm/npm the gate requests.


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 11:20:08 -07:00
0770a28962 fix(ci): make images: optional in hanzo.yml (lint-only / deploy-elsewhere repos) (#8)
The reusable hard-subscripted `hanzo.yml['images']` in the delegate, buildx, and
deploy steps, so a repo that ships no container image (e.g. a static site deployed
via its own Cloudflare Pages deploy.yml, importing this reusable only for the
`test:` lint gate) failed with a Python KeyError before any build ran.

Use `.get('images') or []` in all three places: absent → empty list → the build
loop runs zero times and no GHCR push is attempted. Backward-compatible (every
repo with `images:` is unchanged) and it stops a cross-org repo (e.g.
hanzo-apps/hanzo.ai) from hitting `denied: permission_denied` on a vestigial push.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 11:08:51 -07:00
5e19ed47ef fix(ci): make images: optional in hanzo.yml (lint-only / deploy-elsewhere repos) (#8)
The reusable hard-subscripted `hanzo.yml['images']` in the delegate, buildx, and
deploy steps, so a repo that ships no container image (e.g. a static site deployed
via its own Cloudflare Pages deploy.yml, importing this reusable only for the
`test:` lint gate) failed with a Python KeyError before any build ran.

Use `.get('images') or []` in all three places: absent → empty list → the build
loop runs zero times and no GHCR push is attempted. Backward-compatible (every
repo with `images:` is unchanged) and it stops a cross-org repo (e.g.
hanzo-apps/hanzo.ai) from hitting `denied: permission_denied` on a vestigial push.


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 11:08:51 -07:00
zeekayandClaude Opus 4.8 5106bdeada ci: authenticate runner git for private Go modules in the Test step
The Test step runs `go vet`/`go test` on the runner, so `go` fetches
private hanzoai/* modules (GOPRIVATE → direct) via the runner's git. Repos
that authenticate builds with the KMS `gh_token` (GIT_TOKEN) rather than an
org GH_PAT had no runner-git credential, so the gate failed with
`fatal: could not read Username for 'https://github.com'` on
hanzoai/dbx, hanzoai/tasks, hanzoai/pubsub-go, etc.

Add a guarded step (before Test) that configures git `insteadOf` with the
SAME token the image build uses — GIT_TOKEN (set by the KMS step), GH_PAT
fallback — so the runner's git can clone private modules. Gated on go.mod;
no-op when no token is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:49:15 -07:00
zeekayandhanzo-dev a9113929f9 ci: authenticate runner git for private Go modules in the Test step
The Test step runs `go vet`/`go test` on the runner, so `go` fetches
private hanzoai/* modules (GOPRIVATE → direct) via the runner's git. Repos
that authenticate builds with the KMS `gh_token` (GIT_TOKEN) rather than an
org GH_PAT had no runner-git credential, so the gate failed with
`fatal: could not read Username for 'https://github.com'` on
hanzoai/dbx, hanzoai/tasks, hanzoai/pubsub-go, etc.

Add a guarded step (before Test) that configures git `insteadOf` with the
SAME token the image build uses — GIT_TOKEN (set by the KMS step), GH_PAT
fallback — so the runner's git can clone private modules. Gated on go.mod;
no-op when no token is present.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 10:49:15 -07:00
zeekayandClaude Opus 4.8 0339fc7dee ci: provision Go + C toolchain before the Test step on bare arc runners
hanzo.yml `test:` gates run directly on the runner (not in a build
container), but the stock arc runner image ships no Go and no C compiler.
Any Go test gate therefore died with `go: command not found` (exit 127) —
and CGO_ENABLED=1 gates would next hit `cgo: gcc not found`. This was
latent because most callers never reached the Test step (image build failed
first); hanzoai/commerce is the first to build clean and reach a Go gate.

Add two guarded steps before Test, mirroring the existing jq/PyYAML
provisioning: `actions/setup-go@v5` pinned to the repo's own go.mod version,
and a guarded gcc install. Both gated on `hashFiles('go.mod')` so pure JS/TS
callers are unaffected, and both no-op when the toolchain is already present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:42:14 -07:00
zeekayandhanzo-dev 787e41007d ci: provision Go + C toolchain before the Test step on bare arc runners
hanzo.yml `test:` gates run directly on the runner (not in a build
container), but the stock arc runner image ships no Go and no C compiler.
Any Go test gate therefore died with `go: command not found` (exit 127) —
and CGO_ENABLED=1 gates would next hit `cgo: gcc not found`. This was
latent because most callers never reached the Test step (image build failed
first); hanzoai/commerce is the first to build clean and reach a Go gate.

Add two guarded steps before Test, mirroring the existing jq/PyYAML
provisioning: `actions/setup-go@v5` pinned to the repo's own go.mod version,
and a guarded gcc install. Both gated on `hashFiles('go.mod')` so pure JS/TS
callers are unaffected, and both no-op when the toolchain is already present.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 10:42:14 -07:00
hanzo-dev 0542e97a06 fix(ci): derive KMS org from GitHub owner so the deploy-cred fetch actually runs
The KMS deploy-cred step read the org only from hanzo.yml kms.org or the
KMS_ORG var. No repo sets either, so ORG was always empty and the step
short-circuited with 'KMS not configured' — /v1/kms/auth/login was never
called. Every private-dep build then fell back to the org GH_PAT, which
cannot read private hanzoai/cloud, so go mod tidy failed with git exit 128
(commerce/ai/chat images never built; #70 enforcement could not deploy).

Derive ORG from github.repository_owner (hanzoai->hanzo, luxfi->lux,
zooai->zoo; owner as-is otherwise) when unset. hanzo.yml kms.org and
KMS_ORG still override. One place, zero per-repo config. The full path
(login -> token -> GET deploy/GITHUB_TOKEN -> read hanzoai/cloud) is
verified live against kms.hanzo.ai.
2026-07-04 21:56:36 -07:00
hanzo-dev c2f31a8ab7 fix(ci): derive KMS org from GitHub owner so the deploy-cred fetch actually runs
The KMS deploy-cred step read the org only from hanzo.yml kms.org or the
KMS_ORG var. No repo sets either, so ORG was always empty and the step
short-circuited with 'KMS not configured' — /v1/kms/auth/login was never
called. Every private-dep build then fell back to the org GH_PAT, which
cannot read private hanzoai/cloud, so go mod tidy failed with git exit 128
(commerce/ai/chat images never built; #70 enforcement could not deploy).

Derive ORG from github.repository_owner (hanzoai->hanzo, luxfi->lux,
zooai->zoo; owner as-is otherwise) when unset. hanzo.yml kms.org and
KMS_ORG still override. One place, zero per-repo config. The full path
(login -> token -> GET deploy/GITHUB_TOKEN -> read hanzoai/cloud) is
verified live against kms.hanzo.ai.
2026-07-04 21:56:36 -07:00
265f807635 ci: add opt-in mode: delegate — hand the build to platform.hanzo.ai (#6)
The default `mode: buildx` path is unchanged: buildx → test → deploy ON the
arc runner. `mode: delegate` instead POSTs each hanzo.yml image to platform's
direct build webhook (POST /v1/arcd/enqueue, bearer PLATFORM_BUILD_CALLBACK_TOKEN,
body {repo, sha, image, ref, branch, dockerfile, context, os, arch}) and exits
in seconds — platform builds in-cluster with BuildKit on its own pool, pushes to
the registry, and rolls the operator Service CR. Same downstream as platform's
GitHub-App webhook (one build path, two front doors); no runner buildx, no KMS,
no runner-side deploy.

- new `mode` workflow_call input (default `buildx`) — delegation is opt-in, so
  existing repos are untouched.
- new "Delegate build to platform" step (mode == delegate): parses hanzo.yml,
  enqueues one build per (image, platform) with the buildx tag shape the deploy
  path expects (sha-<short>-<arch>[-<suffix>]); fails loud on a non-202.
- buildx/GHCR/KMS/test/deploy steps gated `mode != 'delegate'`; checkout + parse
  toolchain still run (needed to read hanzo.yml).
- endpoint override via the PLATFORM_ENQUEUE_URL repo/org var.
- README documents the delegate opt-in.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 21:11:03 -07:00
f79a400640 ci: add opt-in mode: delegate — hand the build to platform.hanzo.ai (#6)
The default `mode: buildx` path is unchanged: buildx → test → deploy ON the
arc runner. `mode: delegate` instead POSTs each hanzo.yml image to platform's
direct build webhook (POST /v1/arcd/enqueue, bearer PLATFORM_BUILD_CALLBACK_TOKEN,
body {repo, sha, image, ref, branch, dockerfile, context, os, arch}) and exits
in seconds — platform builds in-cluster with BuildKit on its own pool, pushes to
the registry, and rolls the operator Service CR. Same downstream as platform's
GitHub-App webhook (one build path, two front doors); no runner buildx, no KMS,
no runner-side deploy.

- new `mode` workflow_call input (default `buildx`) — delegation is opt-in, so
  existing repos are untouched.
- new "Delegate build to platform" step (mode == delegate): parses hanzo.yml,
  enqueues one build per (image, platform) with the buildx tag shape the deploy
  path expects (sha-<short>-<arch>[-<suffix>]); fails loud on a non-202.
- buildx/GHCR/KMS/test/deploy steps gated `mode != 'delegate'`; checkout + parse
  toolchain still run (needed to read hanzo.yml).
- endpoint override via the PLATFORM_ENQUEUE_URL repo/org var.
- README documents the delegate opt-in.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 21:11:03 -07:00
zandGitHub c06948b467 Merge pull request #7 from hanzoai/fix/buildx-ghpat-fallback
fix(ci): fall back buildx gh_token to GH_PAT when KMS token is empty
2026-07-04 21:09:07 -07:00
hanzo-dev 2ef5d47f94 fix(ci): fall back buildx gh_token to GH_PAT when KMS token is empty
The buildx `gh_token` secret (for reading private cross-org Go modules like
github.com/hanzoai/cloud during `go mod tidy`) was mounted ONLY from the
KMS-fetched GIT_TOKEN. When the KMS deploy-cred login fails (or KMS is
unconfigured), GIT_TOKEN is empty, the --secret is omitted, and the buildx
stage's `go mod tidy` hits the private repo unauthenticated -> git exit 128 ->
image build fails. This broke every repo with a private cross-org dep
(hanzoai/commerce, hanzoai/ai) while the KMS login was down.

Fall back to the existing valid GH_PAT org secret — the SAME BuildKit gh_token
cloud/release.yml already uses successfully. Guarded + exported; no-op for
public-only builds when both are empty. One credential path, proven working.
2026-07-04 21:08:47 -07:00
3a9e055c45 feat(ci): opt-in multi-arch builds (amd64+arm64) via hanzo.yml platforms (#5)
DOKS has no arm64 nodes. Add per-image opt-in `platforms:` in hanzo.yml:
default stays [linux/amd64] (every existing repo's -amd64 tag shape UNCHANGED,
zero behavior change). Set [linux/amd64, linux/arm64] → buildx emits a
multi-arch manifest list (one digest, both arches); binfmt/QEMU installed for
arm64 emulation, and pure-Go CGO_ENABLED=0 Dockerfiles honoring $TARGETARCH
cross-compile natively (fast). For native-speed arm64, register a bare-metal
arm64 host (spark/GB10) as the hanzo-build-linux-arm64 runner (values exist).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 19:47:09 -07:00
hanzo-dev 8ae1c46625 fix(ci): KMS step must set +e (GitHub wraps run in bash -e)
The step is documented BEST-EFFORT (GHCR push uses the workflow token;
deploy creds are optional), and its 'set -uo pipefail' deliberately omits
-e. But GitHub runs 'run:' under 'bash -eo pipefail', so errexit is active
regardless — an unguarded curl (a KMS secret 404 at the caller's org/path)
aborted the step (exit 22) and failed the whole build. Explicitly 'set +e'
so KMS degrades gracefully (missing kubeconfig -> deploy simply skipped).
2026-07-03 15:42:25 -07:00
hanzo-dev 9d18d4a6ba fix(ci): provision jq + PyYAML on the runner before parsing hanzo.yml
The stock arc runner image (ghcr.io/actions/actions-runner:latest) is
minimal and ships neither jq nor python3-yaml, but the build step parses
hanzo.yml with python3+PyYAML under 'set -euo pipefail' and fails:
  ModuleNotFoundError: No module named 'yaml'
(The KMS step swallowed the same error via '|| true'.)

Add a guarded setup step (apt-get python3-yaml jq) right after checkout —
idempotent, a no-op once a runner image bakes them in. Keeps the reusable
self-contained so any org can import it onto a bare runner.
2026-07-03 15:34:24 -07:00
ecc3da75f0 fix(ci): build.yml runner defaults to online ARC pool, not offline evo (same disease as .github#11) (#3)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 23:38:52 -07:00
z d425a88e9a docs(brand): add hero banner 2026-06-28 20:06:09 -07:00
z fb096091b3 chore(brand): dynamic hero banner 2026-06-28 20:06:08 -07:00
hanzo-dev aadb7a04c0 ci: drop blanket GOPRIVATE — public modules use the immutable proxy+sumdb
luxfi/hanzoai/zooai Go modules are public (verified: dex, precompile, authz, pq,
age, keys all resolve unauthenticated). Setting GOPRIVATE for the whole orgs
routed them 'direct' and bypassed sum.golang.org, so a force-moved tag silently
poisoned every downstream go.sum (the recurring 'checksum mismatch' build
breakage). Dropping it lets go resolve them via proxy.golang.org + sumdb —
canonical IMMUTABLE hashes a re-publish cannot break. The GH_PAT git credential
stays for authenticated direct fallback + any genuinely-private repo (re-add via
a NARROW GOPRIVATE, never whole-org).
2026-06-28 14:10:59 -07:00
hanzo-dev 599667e100 fix(ci): tag-suffix optional — clean image tags when absent
The reusable build read ."tag-suffix" unconditionally, so any repo without it
(every repo today) got broken tags like sha-xxx-amd64-null. Default to empty in
both build and deploy so single-variant repos get clean tags; multi-variant
repos still qualify (…-ce, ce-latest). Unblocks adopting the reusable workflow.
2026-06-28 05:38:06 -07:00
hanzo-devandGitHub 2bc3b2e1a9 build: KMS secret fetch over canonical /v1/kms + buildx gh_token secret (#2)
* build: KMS secret fetch over canonical /v1/kms + buildx gh_token secret

The Infisical /api/* surface was removed when KMS migrated to luxfi/kms, so the
old /api/v1/auth/universal-auth/login + /api/v3/secrets/raw calls now 404 and
every repo's CI fails to fetch GHCR_TOKEN/KUBECONFIG. Rewrite to /v1/kms:
- auth: POST /v1/kms/auth/login {clientId,clientSecret} (IAM client_credentials
  via the <org>-kms app) -> accessToken
- fetch: per-secret GET /v1/kms/orgs/<org>/secrets/<path>/<name>?env=<env>
- org from hanzo.yml kms.org or KMS_ORG var
Also fetch GIT_TOKEN and pass it to buildx as the gh_token BuildKit secret so
Dockerfiles can clone private Go modules (luxfi/dex, luxfi/precompile). Empty-safe.

* build: one GITHUB_TOKEN in KMS for GHCR push + private-module clone (DRY)

A single GitHub PAT with repo + write:packages does both jobs, so collapse the
split GHCR_TOKEN/GIT_TOKEN to one KMS key GITHUB_TOKEN. KUBECONFIG stays separate
(different concern). Shell var GHTOKEN avoids the reserved GITHUB_ env prefix.

* build: GHCR push via automatic workflow token; KMS only for cross-org clone + kubeconfig

GitHub injects a per-job GITHUB_TOKEN scoped to the running repo; with
permissions: packages: write it can push the repo's own GHCR package (same-org),
so the push path needs no stored credential. KMS is now best-effort, supplying
only what the automatic token can't: a cross-org PAT to clone other orgs' private
Go modules (luxfi/dex, luxfi/precompile) + KUBECONFIG for deploy. Public repos
with no cross-org deps build with zero KMS dependency.
2026-06-24 10:43:03 -07:00
hanzo-devandGitHub 572c712140 ci: private Go module access (luxfi/hanzoai/zooai), guarded by GH_PAT (#1)
* ci: configure private Go module access (luxfi/hanzoai/zooai)

Any go-based `test:` step in a consumer's hanzo.yml needs to fetch
private github.com/{luxfi,hanzoai,zooai}/* modules; without auth go
hits 'git ls-remote … exit 128'. Add a guarded step that wires GH_PAT
via git insteadOf + sets GOPRIVATE job-wide. No-op when GH_PAT is
absent, so non-Go consumers (bootnode etc.) are unaffected.

* ci: make private-module auth runner-state-immune (fresh GIT_CONFIG_GLOBAL)

Upgrade the naive 'git config --global' to the robust pattern proven in
hanzoai/iam: fresh per-job GIT_CONFIG_GLOBAL + GIT_CONFIG_NOSYSTEM=1, probe
from a neutral dir. A plain global config is overridden on shared arc
runners by stale ~/.gitconfig state and actions/checkout's persisted
extraheader. Still a no-op without GH_PAT.
2026-06-22 17:21:04 -07:00
21 changed files with 4829 additions and 47 deletions
+59
View File
@@ -0,0 +1,59 @@
name: imgver
description: The semver an image build publishes. We don't ship shas.
# For the repos that build images from a hand-rolled .hanzo/workflows/deploy.yml
# instead of importing hanzoai/ci's build.yml. Those workflows each carry their
# own `tag=sha-$(echo $GITHUB_SHA | cut -c1-7)` line, which is why 14 of the
# fleet's 117 pins named a commit rather than a release. Replace that line with:
#
# - id: ver
# uses: hanzoai/ci/.github/actions/imgver@v1
# with: { repo: ghcr.io/hanzoai/<name> }
# env: { GH_PAT: '${{ secrets.GH_PAT }}' }
# ...
# tags: ghcr.io/hanzoai/<name>:${{ steps.ver.outputs.version }}
#
# and keep the sha tag alongside if you want the forensics. The version is the
# one universe PINS. Same bin/imgver build.yml runs — one implementation.
inputs:
repo:
description: Image repository, e.g. ghcr.io/hanzoai/iam
required: true
context:
description: Build context, where the version manifest is looked for first
required: false
default: .
version:
description: >-
Override the declared version: a literal x.y.z, or "<file>:<command
printing it>". Defaults to the repo's package.json / Cargo.toml / VERSION
/ pyproject.toml.
required: false
default: ''
outputs:
version:
description: The semver to publish and to pin
value: ${{ steps.run.outputs.version }}
runs:
using: composite
steps:
- name: Fetch imgver
shell: bash
# The action ref is the script ref: an action pinned to @v1 runs v1's
# imgver. Both forges, because this repo is served from each.
run: |
set -euo pipefail
ref="${GITHUB_ACTION_REF:-v1}"
for url in https://github.com/hanzoai/ci https://git.hanzo.ai/hanzo/ci; do
git clone -q --depth 1 --branch "$ref" "$url" "$RUNNER_TEMP/imgver-ci" 2>/dev/null && break
done
[ -x "$RUNNER_TEMP/imgver-ci/bin/imgver" ] \
|| { echo "::error::could not fetch hanzoai/ci@$ref (bin/imgver)"; exit 1; }
- id: run
shell: bash
env:
IMGVER_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
v=$("$RUNNER_TEMP/imgver-ci/bin/imgver" '${{ inputs.repo }}' '${{ inputs.context }}')
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "$v"
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# `go build ./...` writes the binary into the package directory, named after the
# package — which is how a 12MB darwin/arm64 `ci` came to be committed here, in
# a repo whose image is built linux/amd64 from source by the Dockerfile. The
# artifact is never an input to anything; only the source is.
/ci
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
name: CI/CD
# The gate for hanzoai/ci itself, on our own runners against git.hanzo.ai.
#
# THE LAW: `.github/workflows` runs zero CI; everything that gates or builds
# lives here. All real config is the repo-root hanzo.yml — this file is the
# ~7-line caller, exactly like cloud/commerce/console/git.
#
# Self-referential on purpose: this repo's dashboard image is built by this
# repo's own reusable pipeline, pinned at the @v2 TAG rather than at the working
# tree. That pin is what keeps a broken edit to build.yml from also breaking the
# build that would have caught it — the tag moves only when a release is cut.
on:
push:
branches: [main]
# A v* tag is what produces a published, immutable image tag (branch pushes
# only ever yield sha-<sha7>). Without this trigger a release tag builds
# nothing at all and the CR has no version to pin.
tags: ['v*']
pull_request:
workflow_dispatch:
concurrency:
group: cicd-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
secrets: inherit
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
#
# ci — the ci.hanzo.ai dashboard. Pure-Go, no cgo, no node: the page is
# server-rendered from a template compiled into the binary, and the design
# tokens it spends are @hanzo/brand's published stylesheet, go:embed-ed beside
# it. So the image is still the binary and a CA bundle — nothing served from
# disk, nothing to go stale against the code, and no JS toolchain on the path
# that ships the board you read when the builds are broken.
FROM golang:1.26.5-alpine AS builder
WORKDIR /build
# Resolve through the module proxy: proxy.golang.org and sum.golang.org agree
# and neither can change under us, which a direct fetch against a moved tag
# cannot promise.
ENV GOPROXY=https://proxy.golang.org,direct
# The base image above is pinned to exactly the Go go.mod asks for, so nothing
# is downloaded here — the pin is what makes this build hermetic. GOTOOLCHAIN
# is set to auto anyway, because the golang images default it to `local` and
# that turns the NEXT go.mod bump from "fetches the toolchain it needs" into
# "dies mid-build with go.mod requires go >= X". The pin is the fast path; this
# is the one that keeps a version bump from being a build break. bin/gover
# gates the same rule for every repo this pipeline builds.
ENV GOTOOLCHAIN=auto
COPY go.mod ./
RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /build/ci .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S hanzo && adduser -S hanzo -G hanzo
COPY --from=builder /build/ci /app/ci
USER hanzo
EXPOSE 8080
# Liveness only. Readiness deliberately does not gate on having a snapshot — see
# the /healthz comment in main.go: a Hanzo Git outage must render as a dashboard
# saying so, not as this pod leaving the load balancer as well.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/app/ci"]
+165 -4
View File
@@ -40,10 +40,144 @@ jobs:
That's it. The build/test/deploy logic lives here, once.
## Runners — our cloud or your own
## `client:` — one document, eight generated clients
By default the build runs on the **Hanzo cloud** arc pool (we run it; metered as
build minutes). To run on **your own** self-hosted arc runners, pass their labels:
A generated SDK is a **projection** of one API document at one version. This lane
is the only place in the fleet that says how a projection is made, so the eight
client repos (`python-sdk`, `js-sdk`, `go-sdk`, `rust-sdk`, `java-sdk`,
`kotlin-sdk`, `cpp-sdk`, `cli`) stop carrying eight copies of the same eight
lines.
```yaml
client:
spec: { repo: hanzoai/cloud, path: openapi.yaml } # these are the defaults
generate: ./scripts/generate.sh # $SPEC is the fetched document
version: 'package.json:jq -r .version package.json' # optional; see below
```
There is deliberately **no `build:`**. The repo already declared how it proves
itself, in `test:`, and that block runs over the regenerated tree — which is
exactly the gate. A second declaration would be one assertion written twice.
It fires on `repository_dispatch: spec-update`, which **hanzoai/cloud sends once
per release**:
```yaml
on:
repository_dispatch: { types: [spec-update] }
workflow_dispatch:
```
The coupler is the document, **passed by value at a pinned ref**. The payload
carries `(version, sha, spec_sha256)`; the lane fetches `openapi.yaml` at that
sha and refuses if the bytes hash to anything else — every projection of one
release is generated from one digest. Reading a live host instead would be a lie
about which deploy the client describes.
Three gates, in order:
| gate | refuses |
|---|---|
| digest | a client generated from a different document than its siblings |
| `test:` | a spec change that produces a client which does not compile — **including its examples** |
| `.spec-lock` | is committed beside the code: `ref` + `sha256`, so anyone can ask a client repo *which document are you?* without running a generator |
On a delta — and only after `test:` has passed over exactly those bytes — the
lane commits the projection, bumps the **patch** (derived, never typed: a
projection never earns a minor or a major) and pushes the tag. The repo's own tag
lane publishes it, so the registry credential stays where the publish is.
`version:` says **where this client's version lives**, because that answer is
genuinely different per language:
| value | meaning |
|---|---|
| `"<file>:<command printing it>"` | it lives in a file — rewrite it, commit, tag |
| `tag` | the tag **is** the version (a Go module has nothing to rewrite) |
| absent | CI cannot derive one — the projection is committed and gated, nothing is cut |
The third state is not a gap to fill later. A repo whose version is not `x.y.z`
(a `-alpha.N` gradle build) has no patch for this lane to derive, and guessing
one would tag bytes under a number nobody chose.
Credential: **`SPEC_TOKEN`** — a fine-grained token with `contents:read` on the
spec repo.
## `binaries:` — publish a plugin once, install it everywhere
`images:` ships an OCI image a **cluster** runs. `binaries:` ships an
executable a **running host** installs: a [zip](https://github.com/zap-proto/zip)
plugin, fetched at run time by URL and verified against its SHA-256 before it is
ever made executable. Build it once per OS/arch here; every host picks up the
same bits, and nobody rebuilds the world to ship a plugin.
```yaml
binaries:
- name: billing
main: ./cmd/billing # the Go package; default "."
platforms: [linux/amd64, linux/arm64] # default [linux/amd64]
ldflags: "-s -w" # default
```
`main:` is the zero-config **Go** lane. Every other toolchain uses the same block
with `run:` (the command that builds) and `out:` (the glob of what it produced) —
which is how a repo with no Dockerfile and no Go still publishes an artifact:
```yaml
binaries:
- name: sdk
run: npm install && npm run build && npm pack --pack-destination .
out: "*.tgz"
image: node:22-bookworm # the toolchain — see below
```
`image:` names the container the **platform** lane runs `run:` in
(`POST /v1/runner`, one initContainer per entry, in-cluster). Here the toolchain
IS the runner, so this workflow reads past it. It is not a second recipe: both
lanes read the same `binaries:` block out of the same `hanzo.yml` and publish the
same `binaries.json` at the same URL.
Artifacts land under `<name>` in the index regardless of lane; a `run:` entry is
`os: any, arch: any`, because an npm tarball or a wheel is not per-platform and
an index entry that claimed one would be a lie a host acts on.
Built on every push (an arm64 cross-compile that breaks fails the PR that broke
it) and **published on a tag**, after the `test:` gate — a host installs an
artifact unattended, so the tests gate the bits. Each artifact lands on the
GitHub Release for that tag:
```
https://github.com/<owner>/<repo>/releases/download/<tag>/<name>-<os>-<arch>
```
plus `binaries.json` beside them — `{name, os, arch, url, sha256}` for every
artifact, so the bits and the digest that authorizes them ship as one release
and a host reads both from one place. The job summary prints the
`zip.Load(zip.Plugin{URL, Sum})` a host pastes.
Add a top-level `bucket:` and they publish to **hanzoai/s3** instead — same
artifacts, same index, only the url changes:
```yaml
bucket: plugins # → https://s3.hanzo.ai/plugins/<owner>/<repo>/<tag>/binaries.json
```
Credentials are the `S3_ADMIN_*` names the services already read, pulled from
KMS at run time; a declared bucket with no credential fails the publish rather
than shipping an index whose artifacts are missing. Use it for anything large or
frequent — a GitHub release stores it on a quota we do not own.
Builds are `CGO_ENABLED=0 -trimpath`: the host that installs this runs it on
whatever base image the host is, and the digest must be a function of the
source, not of the checkout path.
## Runners — our fleet or your own
By default the build runs on the **Hanzo `git-runner` fleet** on git.hanzo.ai
(we run it; metered as build minutes) — the only pool that serves the default
`hanzo-build-linux-amd64` label. There is no arc pool: arc (arcd) was retired
2026-08-01 and never served any label in this default. To run on **your own**
self-hosted runners, pass their labels:
```yaml
uses: hanzoai/ci/.github/workflows/build.yml@v1
@@ -52,6 +186,33 @@ build minutes). To run on **your own** self-hosted arc runners, pass their label
secrets: inherit
```
## Delegate to platform (skip runner buildx)
By default the build runs buildx **on** the runner. To instead hand the build
to **platform.hanzo.ai** — which builds in-cluster with BuildKit and rolls the
service itself — pass `mode: delegate`:
```yaml
uses: hanzoai/ci/.github/workflows/build.yml@v1
with:
mode: delegate
secrets: inherit
```
The GitHub job then just POSTs each image in `hanzo.yml` to platform's direct
build webhook (`/v1/runner`) and exits in **seconds** — no runner buildx,
no KMS, no runner-side deploy. Platform creates the build job, launches an
in-cluster BuildKit Job on its own pool, pushes to the registry, and patches the
operator `Service` CR to roll it. It's the same build path as the platform
GitHub-App webhook — one build path, two front doors.
Requires one extra secret, `PLATFORM_BUILD_CALLBACK_TOKEN` (org- or repo-level,
picked up via `secrets: inherit`). Override the endpoint with the
`PLATFORM_ENQUEUE_URL` repo/org variable (default `https://platform.hanzo.ai/v1/runner`).
`mode: buildx` (the default) is unchanged — existing repos keep running buildx on
the fleet runner, so delegation is strictly opt-in.
## Credentials
The only GitHub secrets a repo sets are `KMS_CLIENT_ID` / `KMS_CLIENT_SECRET`
@@ -62,6 +223,6 @@ run time. No long-lived registry or cluster credentials live in GitHub.
## Platform-native
`hanzo.yml` is also read by platform.hanzo.ai: a repo on the platform webhook
needs **only** `hanzo.yml` — the platform builds it on arc and rolls it out, no
needs **only** `hanzo.yml` — the platform builds it in-cluster and rolls it out, no
workflow file at all. This reusable is the GitHub-Actions path for repos that
trigger through GitHub instead of the platform.
Executable
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# gover — refuse a Dockerfile whose Go builder image is older than the module
# it compiles. One implementation, every caller.
#
# gover <dockerfile> [context-dir] # e.g. gover Dockerfile .
#
# WHAT THIS CATCHES, AND WHY IT IS A GATE AND NOT A CONVENTION
#
# The official `golang` images set GOTOOLCHAIN=local. That is deliberate on
# their part — the image promises the Go it ships and refuses to silently
# fetch another. The consequence is that a go.mod requiring a NEWER Go than
# the base image does not degrade, it dies:
#
# go: go.mod requires go >= 1.26.5 (running go 1.26.4; GOTOOLCHAIN=local)
#
# hanzoai/visor v1.108.16 is the shipped instance. The failure is invisible
# until the image build runs, because every local `go build` succeeds: a
# developer box runs GOTOOLCHAIN=auto and simply downloads what the module
# asks for. So the mismatch is introduced by editing go.mod — a file that has
# nothing to do with Docker — and is discovered by a red release build.
#
# It is also not one repo's problem. A sweep of every Dockerfile across the
# orgs found 58 FROM lines in 23 repos already below their own go.mod, and
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those 58 fixes
# today; this gate is what makes the 59th impossible, which is the part worth
# having.
#
# WHAT IT DELIBERATELY DOES NOT DO
#
# It does not require the newest Go, and it does not care that an image is
# behind `latest`. A newer toolchain compiling an older `go` directive is
# always valid, so pinning ahead of go.mod is fine and stays silent. The only
# thing refused is an image BELOW the module's floor, because that is the only
# arrangement that cannot build.
#
# A floating tag (`golang:1.26-alpine`, no patch) resolves to the newest patch
# of that minor when the image is pulled. Against a patch-pinned go.mod that
# is correct today and fragile tomorrow — a stale registry mirror serves an
# older patch and the build dies with the message above. That earns a warning,
# never a failure: it builds, and a gate that fails what builds trains people
# to skip gates.
#
# EXIT: 0 clean (warnings still print), 1 when a stage cannot build.
set -uo pipefail
df=${1:?usage: gover <dockerfile> [context-dir]}
ctx=${2:-.}
[ -f "$df" ] || { echo "gover: no such Dockerfile: $df" >&2; exit 1; }
# --- the module floor -------------------------------------------------------
# Nearest go.mod walking up from the Dockerfile, then the build context, then
# the repo root. Multi-module repos are the reason this walks rather than
# assuming the root: hanzoai/s3 builds s3-rdma-sidecar/ and telemetry/server/
# from their own go.mod files, each with a different floor.
#
# WHICH go.mod, precisely: the BUILD CONTEXT decides, not where the file sits.
# A Dockerfile under test/kafka/ built with `context: ../..` compiles the ROOT
# module, and judging it by test/kafka/go.mod reads the wrong floor — in
# hanzoai/s3 that difference is 1.25.0 vs 1.26.5, i.e. the difference between
# "fine" and "cannot build". So when a Dockerfile copies the context's own
# go.mod (`COPY go.mod ...`, the overwhelmingly common shape), that is the
# module being compiled and the context's go.mod wins.
#
# Otherwise fall back to the nearest go.mod above the Dockerfile, which is the
# right answer for a subdirectory that is its own module and is built with its
# own directory as the context — hanzoai/s3's s3-rdma-sidecar/ and
# telemetry/server/ are both that shape.
govers=""; gosrc=""
if grep -qiE '^[[:space:]]*COPY([[:space:]]+--[^[:space:]]+)*[[:space:]]+([^[:space:]]+[[:space:]]+)*go\.mod([[:space:]]|$)' "$df" 2>/dev/null \
&& [ -f "$ctx/go.mod" ]; then
gosrc="$ctx/go.mod"
fi
if [ -z "$gosrc" ]; then
d=$(dirname "$df")
while :; do
if [ -f "$d/go.mod" ]; then gosrc="$d/go.mod"; break; fi
[ "$d" = "." ] || [ "$d" = "/" ] || [ -z "$d" ] && break
d=$(dirname "$d")
done
fi
[ -z "$gosrc" ] && [ -f "$ctx/go.mod" ] && gosrc="$ctx/go.mod"
[ -z "$gosrc" ] && [ -f "go.mod" ] && gosrc="go.mod"
# No module in play — nothing to compare against, and a non-Go image is not
# this gate's business.
[ -z "$gosrc" ] && exit 0
govers=$(grep -m1 -E '^go[[:space:]]+[0-9]' "$gosrc" 2>/dev/null | awk '{print $2}')
[ -z "$govers" ] && exit 0
# --- ARG defaults -----------------------------------------------------------
# `FROM golang:${GO_VERSION}-bookworm` is only as good as its default, and the
# default is the value CI builds with unless hanzo.yml passes `args:`. Read
# them so the parameterised Dockerfiles are checked too, not skipped.
declare -A args=()
while IFS= read -r line; do
if [[ $line =~ ^[[:space:]]*[Aa][Rr][Gg][[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
v="${BASH_REMATCH[2]}"
v="${v%%#*}" # strip trailing comment
v="${v//\"/}"; v="${v//\'/}" # strip quotes
v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"
args[${BASH_REMATCH[1]}]="$v"
fi
done < "$df"
# semver -> comparable integer; missing patch becomes -1 so a floating tag is
# distinguishable from an explicit .0 rather than silently equal to it.
num() { # num <major> <minor> <patch|-1>
printf '%d' $(( $1 * 1000000 + $2 * 1000 + ($3 < 0 ? 999 : $3) ))
}
parse() { # parse <version-ish> -> "major minor patch"; empty when unparseable
local v=$1
[[ $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]] && { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"; return; }
[[ $v =~ ^([0-9]+)\.([0-9]+) ]] && { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} -1"; return; }
echo ""
}
read -r mM mm mp <<<"$(parse "$govers")"
[ -z "${mM:-}" ] && exit 0 # go.mod says something we cannot read; not our call
rc=0; n=0
while IFS= read -r line; do
# FROM [--flag ...] [registry/]golang:<tag> [AS stage]
[[ $line =~ ^[[:space:]]*[Ff][Rr][Oo][Mm][[:space:]]+(--[^[:space:]]+[[:space:]]+)*([^[:space:]]*golang:[^[:space:]]+) ]] || continue
ref="${BASH_REMATCH[2]}"
tag="${ref##*golang:}"
# resolve ${VAR} / $VAR against the ARG defaults
while [[ $tag =~ \$\{?([A-Za-z_][A-Za-z0-9_]*)\}? ]]; do
name="${BASH_REMATCH[1]}"; sub="${args[$name]:-}"
[ -z "$sub" ] && { tag=""; break; }
tag="${tag//\$\{$name\}/$sub}"; tag="${tag//\$$name/$sub}"
done
[ -z "$tag" ] && continue
n=$((n+1))
read -r iM im ip <<<"$(parse "$tag")"
# `golang:alpine`, `golang:1-alpine`, `ARG GO_VERSION=INVALID` — no version to
# compare. Say so once; do not guess and do not fail.
[ -z "${iM:-}" ] && { echo "gover: $df: '$ref' names no Go version — cannot check it against $gosrc ($govers)"; continue; }
if [ "$(num "$iM" "$im" "$ip")" -lt "$(num "$mM" "$mm" "$mp")" ]; then
echo "::error file=$df::Go builder image is older than the module it builds: '$ref' provides Go $iM.$im${ip:+.$ip} but $gosrc requires go $govers. The golang images set GOTOOLCHAIN=local, so this build fails with 'go.mod requires go >= $govers'. Fix: pin the base to golang:$govers-<variant>, and add 'ENV GOTOOLCHAIN=auto' to the builder stage so a future go.mod bump downloads the toolchain instead of failing."
rc=1
elif [ "$ip" -lt 0 ] && [ "$mp" -ge 0 ]; then
echo "::warning file=$df::'$ref' floats to the newest patch of $iM.$im, while $gosrc pins go $govers. It builds today and fails the moment a registry mirror serves an older patch. Pin golang:$govers-<variant> to make it hermetic."
fi
done < "$df"
[ "$n" = 0 ] && exit 0
[ "$rc" = 0 ] && echo "gover: OK — $df ($n Go stage$([ "$n" = 1 ] || echo s)) satisfies $gosrc (go $govers)"
exit $rc
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Tests for bin/gover. Offline and deterministic: every case is a Dockerfile and
# a go.mod written into a temp dir, so this needs no registry and no network.
# Run: bash bin/gover_test.sh
set -uo pipefail
cd "$(dirname "$0")/.."
GOVER="$PWD/bin/gover"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
fail=0
# t <name> <want-rc> <go.mod-directive> <dockerfile-body...>
t() {
local name=$1 want=$2 gomod=$3; shift 3
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
printf 'module x\n\ngo %s\n' "$gomod" > "$d/go.mod"
printf '%s\n' "$@" > "$d/Dockerfile"
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
if [ "$rc" = "$want" ]; then printf 'ok %-56s rc=%s\n' "$name" "$rc"
else printf 'FAIL %-56s rc=%s (want %s)\n %s\n' "$name" "$rc" "$want" "$out"; fail=1; fi
}
# grep-based assertion for the message body, not just the code
tmsg() {
local name=$1 pat=$2 gomod=$3; shift 3
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
printf 'module x\n\ngo %s\n' "$gomod" > "$d/go.mod"
printf '%s\n' "$@" > "$d/Dockerfile"
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1)
if printf '%s' "$out" | grep -q "$pat"; then printf 'ok %-56s\n' "$name"
else printf 'FAIL %-56s\n got: %s\n' "$name" "$out"; fail=1; fi
}
# --- the refusal: image below the module floor ------------------------------
# This is the visor v1.108.16 shape exactly.
t "patch below floor is refused" 1 1.26.5 'FROM golang:1.26.4-alpine'
t "minor below floor is refused" 1 1.26.5 'FROM golang:1.25-alpine'
t "ancient relic is refused" 1 1.26.4 'FROM golang:1.10.1'
t "second stage is checked too" 1 1.26.5 'FROM node:22 AS web' 'FROM golang:1.26.1-bookworm AS api'
# --- the allowances ---------------------------------------------------------
t "exact match builds" 0 1.26.5 'FROM golang:1.26.5-alpine'
t "newer image than floor is fine" 0 1.26.4 'FROM golang:1.26.5-alpine'
t "much newer image is fine" 0 1.25.0 'FROM golang:1.26.5-bookworm'
t "alpine suffix is not a Go patch" 0 1.26 'FROM golang:1.26-alpine3.24'
t "registry prefix is stripped" 0 1.26.5 'FROM docker.io/library/golang:1.26.5-alpine'
t "--platform flag is skipped" 0 1.26.5 'FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS b'
t "non-Go image is not our business" 0 1.26.5 'FROM alpine:3.21'
# --- floating tags warn, never fail -----------------------------------------
# They build. A gate that fails what builds trains people to skip gates.
t "floating tag does not fail" 0 1.26.5 'FROM golang:1.26-alpine'
tmsg "floating tag warns" '::warning' 1.26.5 'FROM golang:1.26-alpine'
t "floating minor below floor IS refused" 1 1.26.5 'FROM golang:1.25-alpine'
# --- ARG resolution ---------------------------------------------------------
# A parameterised FROM is only as good as its default; check it, don't skip it.
t "ARG default below floor is refused" 1 1.26.5 'ARG GO_VERSION=1.26.4' 'FROM golang:${GO_VERSION}-bookworm'
t "ARG default at floor builds" 0 1.26.5 'ARG GO_VERSION=1.26.5' 'FROM golang:${GO_VERSION}-bookworm'
t "unbraced \$VAR resolves" 1 1.26.5 'ARG GO_VERSION=1.24' 'FROM golang:$GO_VERSION-bookworm'
t "ARG with trailing comment parses" 1 1.26.5 'ARG GO_VERSION=1.26.4 # keep in step' 'FROM golang:${GO_VERSION}-alpine'
# luxfi/node ships this literally, to silence a buildx warning on a Dockerfile
# that is never built with the default. It must not crash the gate.
t "unparseable ARG default is skipped" 0 1.26.5 'ARG GO_VERSION=INVALID # silences a warning' 'FROM golang:${GO_VERSION}-bookworm'
t "unversioned golang:alpine is skipped" 0 1.26.5 'FROM golang:alpine'
t "golang:1-alpine is skipped" 0 1.26.5 'FROM golang:1-alpine'
# --- module resolution ------------------------------------------------------
# Multi-module repos build subdirectories against their OWN go.mod. Taking the
# root's floor would report a mismatch that does not exist (or miss one that
# does) — hanzoai/s3 is the live case.
d="$tmp/multi"; mkdir -p "$d/sub"
printf 'module root\n\ngo 1.26.5\n' > "$d/go.mod"
printf 'module sub\n\ngo 1.24.0\n' > "$d/sub/go.mod"
printf 'FROM golang:1.24-alpine\n' > "$d/sub/Dockerfile"
out=$(cd "$d" && bash "$GOVER" sub/Dockerfile . 2>&1); rc=$?
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "nearest go.mod wins over the root"
else printf 'FAIL %-56s rc=%s\n %s\n' "nearest go.mod wins over the root" "$rc" "$out"; fail=1; fi
printf 'FROM golang:1.24-alpine\n' > "$d/Dockerfile"
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
if [ "$rc" = 1 ]; then printf 'ok %-56s rc=1\n' "root Dockerfile is judged by the root go.mod"
else printf 'FAIL %-56s rc=%s\n %s\n' "root Dockerfile is judged by the root go.mod" "$rc" "$out"; fail=1; fi
# --- no module at all -------------------------------------------------------
d2="$tmp/nomod"; mkdir -p "$d2"
printf 'FROM golang:1.20-alpine\n' > "$d2/Dockerfile"
out=$(cd "$d2" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "no go.mod: nothing to compare, stays silent"
else printf 'FAIL %-56s rc=%s\n %s\n' "no go.mod: nothing to compare, stays silent" "$rc" "$out"; fail=1; fi
# --- the remediation is in the message --------------------------------------
# A gate that says only "no" costs the next person the same hour it cost the
# last one.
tmsg "error names the fix (pin + GOTOOLCHAIN=auto)" 'GOTOOLCHAIN=auto' 1.26.5 'FROM golang:1.26.4-alpine'
tmsg "error quotes the runtime failure it prevents" 'go.mod requires go >=' 1.26.5 'FROM golang:1.26.4-alpine'
# --- build context decides the module, not file location -------------------
# hanzoai/s3's live shape: a Dockerfile under test/kafka/ built with
# `context: ../..` that does `COPY go.mod go.sum ./` compiles the ROOT module.
# Judging it by test/kafka/go.mod reads 1.25.0 where the truth is 1.26.5 — the
# difference between "fine" and "cannot build".
d3="$tmp/ctxwins"; mkdir -p "$d3/test/kafka"
printf 'module root\n\ngo 1.26.5\n' > "$d3/go.mod"
printf 'module sub\n\ngo 1.25.0\n' > "$d3/test/kafka/go.mod"
printf 'FROM golang:1.25-alpine\nCOPY go.mod go.sum ./\nRUN go build ./...\n' > "$d3/test/kafka/Dockerfile.s3"
out=$(cd "$d3" && bash "$GOVER" test/kafka/Dockerfile.s3 . 2>&1); rc=$?
if [ "$rc" = 1 ]; then printf 'ok %-56s rc=1\n' "context go.mod wins when Dockerfile COPYs it"
else printf 'FAIL %-56s rc=%s\n %s\n' "context go.mod wins when Dockerfile COPYs it" "$rc" "$out"; fail=1; fi
# ...and the converse: a subdir module built from its OWN directory as context,
# copying its OWN go.mod, is still judged by its own floor.
d4="$tmp/subctx"; mkdir -p "$d4/sidecar"
printf 'module root\n\ngo 1.26.5\n' > "$d4/go.mod"
printf 'module sidecar\n\ngo 1.24.0\n' > "$d4/sidecar/go.mod"
printf 'FROM golang:1.24-alpine\nCOPY go.mod go.sum ./\n' > "$d4/sidecar/Dockerfile"
out=$(cd "$d4/sidecar" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "own-directory context keeps its own floor"
else printf 'FAIL %-56s rc=%s\n %s\n' "own-directory context keeps its own floor" "$rc" "$out"; fail=1; fi
# A Dockerfile that copies a SUBDIRECTORY's go.mod is judged by the nearest one,
# not the context root — otherwise every multi-module repo reports false alarms.
d5="$tmp/nocopy"; mkdir -p "$d5/svc"
printf 'module root\n\ngo 1.26.5\n' > "$d5/go.mod"
printf 'module svc\n\ngo 1.24.0\n' > "$d5/svc/go.mod"
printf 'FROM golang:1.24-alpine\nCOPY svc/go.mod ./\n' > "$d5/svc/Dockerfile"
out=$(cd "$d5" && bash "$GOVER" svc/Dockerfile . 2>&1); rc=$?
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "subdir go.mod copy is not the context root"
else printf 'FAIL %-56s rc=%s\n %s\n' "subdir go.mod copy is not the context root" "$rc" "$out"; fail=1; fi
echo
[ $fail = 0 ] && echo "all gover tests passed" || echo "gover tests FAILED"
exit $fail
Executable
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# imgver — the version an image build publishes. One implementation, every caller.
#
# imgver <image-repo> [context-dir] # e.g. imgver ghcr.io/hanzoai/iam .
#
# We don't ship shas. A build that cannot name a version is a broken build, so
# this exits non-zero rather than letting a caller fall back to sha-<short>.
#
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL: the fleet has two build front doors
# — hanzoai/ci's build.yml (imported by repos with a hanzo.yml) and the
# hand-rolled .hanzo/workflows/deploy.yml that 11 repos carry instead. Both need
# the identical number. Written twice it would be right twice and then wrong
# once, which is how `sha-<short>` became the only tag those 11 repos ever
# published.
#
# THE NUMBER IS DERIVED, NEVER TYPED, and monotonic against two floors:
# declared — the repo's own manifest (package.json / Cargo.toml / VERSION /
# pyproject.toml, or $IMGVER_VERSION to name it outright). The
# human's say: bump the minor there and the series jumps there.
# published — the highest semver already at the registry for THIS image.
# max(declared, published) + a patch is what stops one name from ever covering
# two digests. Deriving from the manifest alone re-publishes the same number on
# every push until someone edits the file, and a node running
# imagePullPolicy: IfNotPresent never picks up the second one. universe's
# images.yml learned that on iam-secret-sync; this is that rule, everywhere.
#
# ENV: GH_PAT (or GITHUB_TOKEN) to read the registry floor; IMGVER_PUBLISHED to
# supply it directly (a registry this cannot read, and the test seam).
# Without either, only the manifest carries the series.
set -euo pipefail
repo="${1:?usage: imgver <image-repo> [context-dir]}"
ctx="${2:-.}"
semver='^[0-9]+\.[0-9]+\.[0-9]+$'
# ---- declared ---------------------------------------------------------------
declared="${IMGVER_VERSION:-}"
case "$declared" in
# The "<file>:<command printing it>" shape hanzo.yml's client lane already uses.
*:*) declared=$(bash -c "${declared#*:}" 2>/dev/null || true) ;;
esac
if [ -z "$declared" ]; then
# The image's own context first, then the repo root: a monorepo's web/ or api/
# carries the version of the thing being built, not the workspace stub.
for d in "$ctx" .; do
[ -d "$d" ] || continue
if [ -f "$d/package.json" ]; then
declared=$(jq -r '.version // ""' "$d/package.json" 2>/dev/null || true)
elif [ -f "$d/Cargo.toml" ]; then
declared=$(sed -n '/^\[\(workspace\.\)\?package\]/,/^\[/p' "$d/Cargo.toml" \
| sed -n 's/^version *= *"\([^"]*\)".*/\1/p' | head -1)
elif [ -f "$d/VERSION" ]; then
declared=$(tr -d ' \n' < "$d/VERSION")
elif [ -f "$d/pyproject.toml" ]; then
declared=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "$d/pyproject.toml" | head -1)
fi
[ -n "$declared" ] && break
done
fi
declared="${declared#v}"
# A workspace stub (0.0.0) or a placeholder is not a version anyone declared.
[ "$declared" = "0.0.0" ] && declared=""
echo "$declared" | grep -qE "$semver" || declared=""
# ---- published --------------------------------------------------------------
# The GitHub Packages API, not the registry v2 tags list: an anonymous ghcr pull
# token can fetch a manifest by name but returns an EMPTY tag list, so a v2 read
# would silently report "nothing published" and restart the series at 0.
published="${IMGVER_PUBLISHED:-}"
tok="${GH_PAT:-${GITHUB_TOKEN:-}}"
if [ -z "$published" ] && [ -n "$tok" ] && [ "${repo#ghcr.io/}" != "$repo" ]; then
org="${repo#ghcr.io/}"; pkg="${org#*/}"; org="${org%%/*}"
published=$(curl -fsSL -H "Authorization: Bearer $tok" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/orgs/${org}/packages/container/${pkg}/versions?per_page=100" 2>/dev/null \
| jq -r '.[].metadata.container.tags[]?' 2>/dev/null \
| sed 's/^v//' | grep -E "$semver" | sort -V | tail -1 || true)
fi
# ---- the number -------------------------------------------------------------
max=$(printf '%s\n%s\n' "$declared" "$published" | grep -E "$semver" | sort -V | tail -1 || true)
if [ -z "$max" ]; then
echo "imgver: no version for $repo. Declare one — a package.json/Cargo.toml/VERSION/pyproject.toml under '$ctx', or IMGVER_VERSION. We don't ship shas." >&2
exit 1
elif [ "$max" = "$declared" ] && [ "$declared" != "$published" ]; then
ver="$declared" # the human bumped it — publish exactly that
else
ver="${max%.*}.$(( ${max##*.} + 1 ))" # already out there — next patch
fi
echo "imgver $repo: declared=${declared:-none} published=${published:-none} -> $ver" >&2
echo "$ver"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Tests for bin/imgver. Runs offline: no GH_PAT means no registry read, so the
# published floor is injected through IMGVER_PUBLISHED and every case is
# deterministic. Run: bash bin/imgver_test.sh
set -uo pipefail
cd "$(dirname "$0")/.."
IMGVER="$PWD/bin/imgver"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
fail=0
run() { # run <declared-env> <published> <ctx>
IMGVER_VERSION="$1" IMGVER_PUBLISHED="$2" GH_PAT= GITHUB_TOKEN= \
bash "$IMGVER" ghcr.io/hanzoai/test "$3" 2>/dev/null
}
t() { # t <name> <declared> <published> <ctx> <want>
got=$(run "$2" "$3" "$4"); rc=$?
[ $rc -ne 0 ] && got="ERROR"
if [ "$got" = "$5" ]; then printf 'ok %-52s -> %s\n' "$1" "$got"
else printf 'FAIL %-52s -> %s (want %s)\n' "$1" "$got" "$5"; fail=1; fi
}
# --- the derivation ---------------------------------------------------------
t "registry ahead: next patch, monotonic" 1.2.3 1.2.7 "$tmp" 1.2.8
t "human bumped the minor: honour it verbatim" 1.3.0 1.2.8 "$tmp" 1.3.0
t "same number published: never 2 digests/name" 1.2.3 1.2.3 "$tmp" 1.2.4
t "no manifest version: registry carries it" "" 1.2.7 "$tmp" 1.2.8
t "nothing published: seed at declared" 1.2.3 "" "$tmp" 1.2.3
t "major bump honoured" 2.0.0 1.9.9 "$tmp" 2.0.0
t "sort -V not lexical (1.2.10 > 1.2.9)" 1.2.9 1.2.10 "$tmp" 1.2.11
t "cloud's real series" 1.801.341 1.801.341 "$tmp" 1.801.342
t "stale manifest cannot drag series backwards" 0.0.1 0.9.0 "$tmp" 0.9.1
t "no version anywhere: fail loud, never sha" "" "" "$tmp" ERROR
t "leading v stripped" v1.4.0 "" "$tmp" 1.4.0
t "0.0.0 workspace stub is not a version" 0.0.0 1.1.1 "$tmp" 1.1.2
t "non-semver declared is ignored" "1.2" 2.0.0 "$tmp" 2.0.1
# --- manifest discovery ------------------------------------------------------
m() { rm -rf "$tmp"/m; mkdir -p "$tmp"/m; }
m; echo '{"version":"3.4.5"}' > "$tmp/m/package.json"
t "package.json" "" "" "$tmp/m" 3.4.5
m; printf '[package]\nname="x"\nversion = "6.7.8"\n' > "$tmp/m/Cargo.toml"
t "Cargo.toml [package]" "" "" "$tmp/m" 6.7.8
m; printf '[workspace.package]\nversion = "1.45.2"\n' > "$tmp/m/Cargo.toml"
t "Cargo.toml [workspace.package] (index's shape)" "" "" "$tmp/m" 1.45.2
m; echo "9.9.9" > "$tmp/m/VERSION"
t "VERSION file" "" "" "$tmp/m" 9.9.9
m; printf '[project]\nversion = "2.3.4"\n' > "$tmp/m/pyproject.toml"
t "pyproject.toml" "" "" "$tmp/m" 2.3.4
m; echo '{"name":"x"}' > "$tmp/m/package.json"
t "package.json with no version key -> ERROR" "" "" "$tmp/m" ERROR
m; echo '{"version":"0.0.0"}' > "$tmp/m/package.json"
t "workspace stub package.json -> ERROR" "" "" "$tmp/m" ERROR
m; echo '{"version":"1.0.0"}' > "$tmp/m/package.json"
t "IMGVER_VERSION overrides the manifest" 5.5.5 "" "$tmp/m" 5.5.5
m; echo '{"version":"1.0.0"}' > "$tmp/m/package.json"
t "resolver expression <file>:<command>" "package.json:echo 7.7.7" "" "$tmp/m" 7.7.7
echo
[ $fail -eq 0 ] && echo "imgver: all cases pass" || echo "imgver: FAILURES"
exit $fail
+55
View File
@@ -0,0 +1,55 @@
package main
import (
_ "embed"
"html/template"
)
// brand.go — where this page's design values come from.
//
// They come from @hanzo/brand, the one place the fleet's palette, radii, type
// scale and spacing are defined, and they arrive as that package's OWN
// published artifact rather than as hex codes retyped here. The distinction is
// the entire point. Until this file existed the template carried its own
// :root block, and being a hand-copy it had already drifted off the house:
// the status colours were GitHub Primer's (#3fb950 / #f85149 / #d29922) where
// the house says #10b981 / #ef4444 / #f59e0b, the surface blacks were each a
// shade wrong (#0b0b0d against --surface-0 #080808), and the hairline border
// was a solid #25252b where the house hairline is a 6%-white wash. Only the
// accent survived intact. A palette that is copied is a palette that diverges.
//
// Vendored, not fetched at build time, and compiled in rather than served off
// disk. @hanzo/brand publishes this file as a plain custom-property sheet
// (`exports["./styles/*"]`, documented for a bare <link>), so consuming it
// costs no npm, no bundler and no React — the image build stays `go build`
// against an empty go.mod, and the page stays one request that returns the
// answer. That matters here more than anywhere: this dashboard is read when
// the build system is broken, which is the worst possible moment for it to
// need the build system in order to draw itself.
//
// Refreshing is a deliberate, reviewed act — fetch, then update the pin:
//
// curl -sSfo brand/variables.css https://unpkg.com/@hanzo/brand@<version>/styles/variables.css
//
//go:embed brand/variables.css
var brandCSS string
// brandCSSVersion and brandCSSSHA256 record WHICH @hanzo/brand the bytes above
// are, and a test rejects any other bytes. This is go.sum's argument, not
// ceremony: without it, "just darken that one border" is a one-character local
// edit that silently restores the second source of truth this file removed, and
// nothing would ever catch it.
const (
brandCSSVersion = "1.4.5"
brandCSSSHA256 = "941dfc0080343d25dc1ef2cd780290a2d8fe6cbd81136912281902f2e8e7741f"
)
// dashboardCSS is what this page adds on top: layout, not design. Every colour,
// radius and size in it is a var() into the sheet above.
//
//go:embed dashboard.css
var dashboardCSS string
// pageCSS is the <style> body: tokens first, then the rules that spend them.
// template.CSS because these are two compile-time constants, never input.
func pageCSS() template.CSS { return template.CSS(brandCSS + dashboardCSS) }
+235
View File
@@ -0,0 +1,235 @@
/**
* @hanzo/brand CSS Variables
*
* Hanzo is monochrome — the brand is ink, paper, and a neutral grayscale.
* There is no brand hue; the brand color is the ink (dark) / paper (light).
*
* Usage:
* @import '@hanzo/brand/styles/variables.css';
* or link: <link rel="stylesheet" href="https://unpkg.com/@hanzo/brand/styles/variables.css">
*/
:root {
/* ===== Hanzo Brand (Monochrome: Hanzo Black ↔ Hanzo White) ===== */
--hanzo-black: #0a0a0b;
--hanzo-black-rgb: 10, 10, 11;
--hanzo-white: #ffffff;
--hanzo-white-rgb: 255, 255, 255;
--hanzo-mono-50: #fafafa;
--hanzo-mono-100: #f5f5f5;
--hanzo-mono-200: #e5e5e5;
--hanzo-mono-300: #d4d4d4;
--hanzo-mono-400: #a3a3a3;
--hanzo-mono-500: #737373;
--hanzo-mono-600: #525252;
--hanzo-mono-700: #404040;
--hanzo-mono-800: #262626;
--hanzo-mono-900: #171717;
--hanzo-mono-950: #0a0a0a;
/* ===== Accent — the ONE Hanzo accent: PURPLE (palette = White · Gray · Purple).
The monochrome base stays (primary action = white, neutrals = gray); purple is
the single interactive/brand accent — links, active, focus, selection. NO blue,
green, or orange. White-label tenants override --hanzo-accent per host so
lux/zoo/pars never inherit Hanzo purple. ===== */
--hanzo-accent: #8b5cf6; /* violet-500 */
--hanzo-accent-hover: #7c3aed; /* violet-600 */
--hanzo-accent-muted: #a78bfa; /* violet-400 — accent text on dark */
--hanzo-accent-soft: rgba(139, 92, 246, 0.12); /* subtle fill / selected row */
--hanzo-accent-rgb: 139, 92, 246;
/* ===== Layered surface blacks (Builder v2 — no gray panels; each subtly different) ===== */
--surface-0: #080808; /* app background */
--surface-1: #0d0d0d; /* panels */
--surface-2: #111111; /* raised */
--surface-3: #171717; /* controls / hover */
/* ===== Hairline border — 1px, almost invisible (no thick outlines) ===== */
--border-hairline: rgba(255, 255, 255, 0.06);
--border-hairline-strong: rgba(255, 255, 255, 0.1);
/* ===== Semantic radius (Builder v2): cards 8 · controls/toolbar 10 · preview/panels 12 ===== */
--radius-card: 0.5rem; /* 8px */
--radius-control: 0.625rem; /* 10px — buttons, toolbar, inputs */
--radius-panel: 0.75rem; /* 12px — preview, large panels */
/* ===== Semantic type roles (Builder v2): heading 20 · body 14 · secondary 12 ===== */
--text-heading: 1.25rem; /* 20px @ 600 */
--text-body: 0.875rem; /* 14px @ 400 */
--text-secondary: 0.75rem; /* 12px @ 400/500 */
/* ===== Semantic Aliases (monochrome; flips with scheme) ===== */
--brand: var(--hanzo-black);
--brand-light: var(--hanzo-mono-800);
--brand-dark: #000000;
--brand-hover: var(--hanzo-mono-900);
--brand-secondary: var(--hanzo-mono-600);
/* ===== Dark Theme Backgrounds ===== */
--bg-primary: #0a0a0a;
--bg-secondary: #141414;
--bg-tertiary: #1a1a1a;
--bg-card: rgba(23, 23, 23, 0.5);
/* ===== Light Theme Backgrounds ===== */
--bg-light: #ffffff;
--bg-light-secondary: #fafafa;
--bg-light-tertiary: #f5f5f5;
/* ===== Borders ===== */
--border: #262626;
--border-light: #e5e5e5;
--border-focus: var(--hanzo-black);
/* ===== Text Colors (Dark Theme) ===== */
--text-primary: #fafafa;
--text-secondary: #a3a3a3;
--text-muted: #737373;
--text-disabled: #525252;
/* ===== Text Colors (Light Theme) ===== */
--text-light-primary: #0a0a0b;
--text-light-secondary: #525252;
--text-light-muted: #737373;
/* ===== Neutral Scale ===== */
--neutral-0: #ffffff;
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4;
--neutral-400: #a3a3a3;
--neutral-500: #737373;
--neutral-600: #525252;
--neutral-700: #404040;
--neutral-800: #262626;
--neutral-900: #171717;
--neutral-950: #0a0a0a;
--neutral-1000: #000000;
/* ===== Semantic Colors ===== */
--success: #10b981;
--success-light: #34d399;
--success-dark: #059669;
--warning: #f59e0b;
--warning-light: #fcd34d;
--warning-dark: #d97706;
--error: #ef4444;
--error-light: #f87171;
--error-dark: #dc2626;
--info: #3b82f6;
--info-light: #60a5fa;
--info-dark: #2563eb;
/* ===== Gradients ===== */
--gradient-brand: linear-gradient(135deg, var(--hanzo-mono-800) 0%, var(--hanzo-black) 100%);
--gradient-accent: linear-gradient(135deg, var(--hanzo-black) 0%, #000000 100%);
--gradient-dark: linear-gradient(135deg, #0a0a0b 0%, #262626 100%);
/* ===== Spacing ===== */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
/* ===== Border Radius ===== */
--radius-sm: 0.125rem;
--radius: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-full: 9999px;
/* ===== Shadows ===== */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* ===== Transitions ===== */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
/* ===== Typography ===== */
--font-sans: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Geist Mono', ui-monospace, Monaco, monospace;
/* ===== Font Size scale (mirrors typography.ts `fontSize`) =====
TIGHT app-first default — the compact developer-app register (linear.app /
vercel.com), NOT a roomy marketing scale. Base is 14px, nav 13px, labels 11px.
Every surface that imports @hanzo/brand inherits this; a brand/tenant can
override any --font-size-* on :root to retune density on demand. */
--font-size-xs: 0.6875rem; /* 11px — eyebrows / section labels */
--font-size-sm: 0.8125rem; /* 13px — nav labels, dense body */
--font-size-base: 0.875rem; /* 14px — base app text (was 16px) */
--font-size-lg: 0.9375rem; /* 15px */
--font-size-xl: 1.0625rem; /* 17px */
--font-size-2xl: 1.3125rem; /* 21px */
--font-size-3xl: 1.625rem; /* 26px */
--font-size-4xl: 2rem; /* 32px */
--font-size-5xl: 2.5rem; /* 40px */
--font-size-6xl: 3.25rem; /* 52px */
--font-size-7xl: 4rem; /* 64px */
--font-size-8xl: 5.25rem; /* 84px */
--font-size-9xl: 7rem; /* 112px */
/* ===== Z-index ladder (mirrors tokens.ts `zIndex`) ===== */
--z-0: 0;
--z-10: 10;
--z-20: 20;
--z-30: 30;
--z-40: 40;
--z-50: 50;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-popover: 500;
--z-tooltip: 600;
--z-notification: 700;
}
/* Dark theme (default for Hanzo) */
[data-theme="dark"],
.dark {
color-scheme: dark;
}
/* Light theme */
[data-theme="light"],
.light {
--bg-primary: var(--bg-light);
--bg-secondary: var(--bg-light-secondary);
--bg-tertiary: var(--bg-light-tertiary);
--bg-card: rgba(255, 255, 255, 0.8);
--border: var(--border-light);
--text-primary: var(--text-light-primary);
--text-secondary: var(--text-light-secondary);
--text-muted: var(--text-light-muted);
/* purple accent flips a shade deeper for contrast on paper */
--hanzo-accent: #7c3aed;
--hanzo-accent-hover: #6d28d9;
--hanzo-accent-muted: #7c3aed;
/* layered "blacks" become layered near-whites in light */
--surface-0: #ffffff;
--surface-1: #fafafa;
--surface-2: #f5f5f5;
--surface-3: #ededed;
--border-hairline: rgba(0, 0, 0, 0.08);
--border-hairline-strong: rgba(0, 0, 0, 0.12);
color-scheme: light;
}
+71
View File
@@ -0,0 +1,71 @@
/* dashboard.css — this page's own rules: what is a row, what sticks, what
collapses on a phone. It names no colour, radius or type size of its own;
every such value is a var() into @hanzo/brand (see brand.go). That is the
difference between consuming the design system and being a second copy of it,
and it is asserted, not merely intended — see TestDashboardCSSNamesNoColours. */
*{box-sizing:border-box}
body{margin:0;background:var(--surface-0);color:var(--text-primary);
font-family:var(--font-sans);font-size:var(--font-size-base);line-height:1.5}
header{display:flex;align-items:center;gap:var(--space-4);
padding:var(--space-4) var(--space-6);background:var(--surface-1);
border-bottom:1px solid var(--border-hairline)}
h1{margin:0;font-size:var(--font-size-lg);font-weight:600;letter-spacing:-.01em}
h1 span{color:var(--hanzo-accent-muted)}
.meta{margin-left:auto;color:var(--text-secondary);font-size:var(--font-size-sm);text-align:right}
.strip{display:flex;gap:var(--space-2);padding:var(--space-4) var(--space-6);flex-wrap:wrap}
.chip{padding:var(--space-2) var(--space-3);background:var(--surface-1);
border:1px solid var(--border-hairline);border-radius:var(--radius-card);
font-size:var(--font-size-sm);color:var(--text-secondary)}
.chip b{color:var(--text-primary);font-weight:600}
.chip.ok b{color:var(--success)} .chip.fail b{color:var(--error)}
.chip.run b{color:var(--warning)} .chip.cancel b{color:var(--text-muted)}
nav{display:flex;gap:var(--space-2);padding:0 var(--space-6) var(--space-4);flex-wrap:wrap}
nav a{padding:var(--space-1) var(--space-3);background:var(--surface-1);
border:1px solid var(--border-hairline);border-radius:var(--radius-full);
color:var(--text-secondary);text-decoration:none;font-size:var(--font-size-sm)}
/* --hanzo-accent-soft is the house "selected row" fill; the active tab is the
one place on this page that is a selection, so it is the one place it is used. */
nav a.on{border-color:var(--hanzo-accent);background:var(--hanzo-accent-soft);color:var(--text-primary)}
nav .who{margin-left:auto;align-self:center;color:var(--text-muted);font-size:var(--font-size-sm)}
/* The stale banner tints its own border colour rather than introducing an amber
of its own — @hanzo/brand ships no warning-surface token, and inventing one
here is exactly the drift this file exists to stop. */
.warn{margin:0 var(--space-6) var(--space-4);padding:var(--space-3) var(--space-4);
border:1px solid var(--warning);border-radius:var(--radius-card);
background:color-mix(in srgb, var(--warning) 10%, transparent);
color:var(--warning-light);font-size:var(--font-size-sm)}
table{width:100%;border-collapse:collapse}
th{position:sticky;top:0;background:var(--surface-1);text-align:left;
font-size:var(--font-size-xs);text-transform:uppercase;letter-spacing:.06em;
font-weight:600;color:var(--text-muted);padding:var(--space-2) var(--space-3);
border-bottom:1px solid var(--border-hairline-strong)}
td{padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--border-hairline);
vertical-align:top}
/* The table is full-bleed but its text has to sit on the same gutter as the
header, chips and nav above it, which are all --space-6 in. */
th:first-child,td:first-child{padding-left:var(--space-6)}
th:last-child,td:last-child{padding-right:var(--space-6)}
tr:hover td{background:var(--surface-2)}
a{color:inherit}
.dot{display:inline-block;width:8px;height:8px;border-radius:var(--radius-full);
margin-right:var(--space-2)}
.dot.success{background:var(--success)} .dot.failure{background:var(--error)}
.dot.running{background:var(--warning);animation:p 1.4s ease-in-out infinite}
.dot.cancelled{background:var(--text-disabled)}
@keyframes p{50%{opacity:.35}}
.repo{font-weight:600}
.org{color:var(--text-muted)}
.title{color:var(--text-secondary);max-width:42ch;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mono{font-family:var(--font-mono);font-size:var(--font-size-sm);color:var(--text-secondary)}
.empty{padding:var(--space-12) var(--space-6);text-align:center;color:var(--text-secondary)}
footer{padding:var(--space-4) var(--space-6);color:var(--text-muted);
font-size:var(--font-size-sm);border-top:1px solid var(--border-hairline)}
@media(max-width:760px){.hide-sm{display:none}}
+3
View File
@@ -0,0 +1,3 @@
module github.com/hanzoai/ci
go 1.26.5
+90
View File
@@ -0,0 +1,90 @@
# Hanzo CI — this repo's own build, driven by this repo's own reusable workflow.
#
# hanzoai/ci is two things that belong together: the reusable pipeline every
# other repo imports (.hanzo/workflows/build.yml), and ci.hanzo.ai, the
# dashboard that shows what that pipeline did. So the dashboard image is built
# by the pipeline it reports on — if the pipeline breaks, the thing that would
# tell you cannot ship, which is the correct and honest coupling.
#
# Until now there was no self-build at all: build.yml is workflow_call-only, so
# the v0.1.0 image was produced out of band and there was no repeatable way to
# cut a second one.
images:
- name: ci
context: .
dockerfile: Dockerfile
repo: ghcr.io/hanzoai/ci
test:
- name: go-vet
run: |
set -e
export GOWORK=off
go vet ./...
- name: go-unit
# The whole tree, not a named list — an allowlist stops covering whatever is
# added after it is written. Small repo; ./... costs nothing.
#
# scope_test.go is the load-bearing one: it asserts the surface refuses a
# request with no X-Org-Id and that `?org=` can only narrow. This service
# shipped once with those properties absent and disclosed every org's build
# metadata to the internet, so a red gate here must block the image.
#
# render_test.go is the other one that has to stay green: it pins the
# vendored @hanzo/brand sheet to the hash of the version it claims to be and
# rejects any colour the page names for itself. Both gates are offline —
# checking that we use one design system costs this pipeline no npm, no
# registry and no network.
run: |
set -e
export GOWORK=off
go test -count=1 ./...
- name: build-yml-is-one-file
# The reusable pipeline is published at TWO paths because two forges read two
# directories — github.com only `.github/workflows`, git.hanzo.ai only
# `.hanzo/workflows`. That is one artifact spelled twice, and nothing until
# now asserted it: the `.hanzo` copy had drifted nine lines (a truncated
# second `on:` block and a duplicate `name:` key) and no reader could see it,
# because the only consumer of that copy is a forge no push from here reaches.
#
# The ONLY legitimate difference is the path each names for itself, so
# normalise that one spelling and demand byte equality of the rest. A gate
# that allowed "the important parts match" would be a gate that cannot say
# what important means.
run: |
set -e
norm() { sed 's|\.hanzo/workflows/build\.yml|.github/workflows/build.yml|g' "$1"; }
if ! diff -u <(norm .github/workflows/build.yml) <(norm .hanzo/workflows/build.yml); then
echo "::error::the two published copies of the reusable have diverged. They are one file at two paths — edit both, or the forge runs a pipeline github.com has never seen."
exit 1
fi
echo "OK: .github and .hanzo copies are one file ($(wc -l < .github/workflows/build.yml) lines)"
- name: imgver
# bin/imgver decides the version EVERY image in the fleet publishes — this
# workflow's build lane calls it, and so does the imgver composite action the
# repos that hand-roll their own deploy.yml use. A wrong number here is one
# tag covering two digests, which a node running imagePullPolicy:
# IfNotPresent never picks up and no reader can see. Offline and
# deterministic: the registry floor is injected, so it needs no network.
run: bash bin/imgver_test.sh
- name: gover
# bin/gover is the gate the build lane runs against every Dockerfile before
# it builds: it refuses a Go builder image older than the go.mod it
# compiles. That failure is not hypothetical — the golang images set
# GOTOOLCHAIN=local, so the mismatch is a hard mid-build death, and
# hanzoai/visor v1.108.16 shipped it. A sweep of the orgs found 54 more
# Dockerfiles already below their own go.mod.
#
# The gate is only worth having if it is exact in BOTH directions: a false
# negative lets the next visor through, and a false positive blocks a build
# that would have worked, which is how gates get skipped. So the suite pins
# the refusals AND the allowances — a newer image than the floor is fine, an
# alpine suffix is not a Go patch, a floating tag warns instead of failing,
# and a multi-module repo is judged by its NEAREST go.mod. Offline and
# deterministic: temp dirs, no registry, no network.
run: bash bin/gover_test.sh
# No `deploy:` ON PURPOSE. Rollout is a reviewed tag pin in hanzoai/universe
# (infra/k8s/operator/crs/ci.yaml), the same rule cloud and git follow: a
# pipeline that both builds and rolls itself out can put an unreviewed image on
# a public host, and cd.hanzo.ai's selfHeal would undo a direct patch anyway.
+495
View File
@@ -0,0 +1,495 @@
// ci — the dashboard behind ci.hanzo.ai.
//
// It owns no build state. Run truth lives in Hanzo Git (git.hanzo.ai), which
// schedules the jobs and holds every log; this reads that and presents it. The
// alternative — a CI service with its own run database — would put two answers
// to "did the build pass" in the fleet, and the one users look at would be the
// one that can drift. So: git.hanzo.ai is the store, ci.hanzo.ai is the view.
//
// This is the CI half of the pair. cd.hanzo.ai reconciles image pins from
// hanzoai/universe and is the delivery view; the two are deliberately separate
// surfaces over separate systems, not one console pretending build and deploy
// are the same event.
//
// Tenancy is the same value everywhere: an org slug. Hanzo Git namespaces repos
// by org, IAM issues that slug in the `owner` claim, and Hanzo CD fences
// projects by it. Filtering here by `org` is therefore the same boundary those
// enforce, not a parallel notion of who-sees-what.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
cfg, err := loadConfig()
if err != nil {
logger.Error("config", "err", err)
os.Exit(1)
}
src := &gitSource{base: cfg.gitBase, token: cfg.gitToken, http: &http.Client{Timeout: 20 * time.Second}}
cache := &runCache{}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// One poller, one cache. Every viewer reads the same snapshot, so N open
// dashboards cost Hanzo Git exactly as much as one — a dashboard that
// fanned each page load into upstream calls is how a status page takes the
// system it reports on down.
go poll(ctx, logger, src, cache, cfg)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Liveness only: up means "serving". Readiness deliberately does NOT
// gate on having a snapshot — a Hanzo Git outage must show as a stale
// dashboard saying so, not as ci.hanzo.ai disappearing from the LB too.
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
})
mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) {
v, ok := requireViewer(w, r, cfg.adminOrg)
if !ok {
return
}
snap := cache.get()
writeJSON(w, http.StatusOK, map[string]any{
"runs": v.visible(snap.Runs, r.URL.Query().Get("org")),
"fetchedAt": snap.FetchedAt,
"stale": snap.stale(cfg.staleAfter),
"sourceErr": snap.errString(),
"repos": snap.Repos,
"orgs": v.orgs(snap.Runs),
})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
v, ok := requireViewer(w, r, cfg.adminOrg)
if !ok {
return
}
renderDashboard(w, cache.get(), v, r.URL.Query().Get("org"), cfg)
})
srv := &http.Server{
Addr: cfg.listen,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
sh, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(sh)
}()
logger.Info("ci dashboard listening", "addr", cfg.listen, "source", cfg.gitBase, "refresh", cfg.refresh.String())
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("serve", "err", err)
os.Exit(1)
}
}
// ───────────────────────────── config ─────────────────────────────
type config struct {
listen string
gitBase string
// adminOrg is the ONE org whose members see across tenants. It must match
// admin-guard's IAM_ADMIN_ORG — the guard decides who gets in, this decides
// who sees everything, and a mismatch would silently demote the fleet view to
// a single-org view (or, if set too wide, promote a tenant to it).
adminOrg string
gitToken string
refresh time.Duration
staleAfter time.Duration
scanRepos int
runsPer int
}
func loadConfig() (config, error) {
c := config{
listen: env("CI_LISTEN", ":8080"),
gitBase: strings.TrimRight(env("CI_GIT_BASE", "https://git.hanzo.ai"), "/"),
adminOrg: env("CI_ADMIN_ORG", "admin"),
gitToken: os.Getenv("CI_GIT_TOKEN"),
scanRepos: envInt("CI_SCAN_REPOS", 60),
runsPer: envInt("CI_RUNS_PER_REPO", 8),
}
c.refresh = time.Duration(envInt("CI_REFRESH_SECONDS", 45)) * time.Second
// Stale is a multiple of refresh, not its own knob: the only meaningful
// definition of stale is "we have missed several refreshes", and deriving
// it means the two can never be configured into contradiction.
c.staleAfter = 4 * c.refresh
if c.gitToken == "" {
return c, errors.New("CI_GIT_TOKEN required (Hanzo Git API token)")
}
return c, nil
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func envInt(k string, def int) int {
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
return v
}
return def
}
// ───────────────────────────── model ─────────────────────────────
// Run is the projection of a Hanzo Git workflow run this dashboard shows. It is
// deliberately a SUBSET: the upstream object carries a dozen more fields, and
// copying them all would make this a second schema to maintain against theirs.
type Run struct {
ID int64 `json:"id"`
Org string `json:"org"`
Repo string `json:"repo"`
Workflow string `json:"workflow"`
Title string `json:"title"`
// Status and Conclusion are BOTH required to know how a run went, and
// reading only one is wrong in a way that looks fine. Status answers
// "is it over" (queued | in_progress | completed); Conclusion answers
// "how did it end" and is empty until it is over. A view that buckets on
// Status alone sees `completed` and cannot tell a pass from a failure —
// which is exactly the bug this pair replaced: every finished run,
// including successes and cancellations, was being drawn as failing.
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Event string `json:"event"`
Branch string `json:"branch"`
SHA string `json:"sha"`
Actor string `json:"actor"`
Number int `json:"number"`
URL string `json:"url"`
StartedAt time.Time `json:"startedAt"`
EndedAt time.Time `json:"endedAt"`
}
// Duration is zero-valued rather than negative when a run has not finished —
// callers render "running", and a negative duration would print as one.
func (r Run) Duration() time.Duration {
if r.StartedAt.IsZero() || r.EndedAt.IsZero() || r.EndedAt.Before(r.StartedAt) {
return 0
}
return r.EndedAt.Sub(r.StartedAt)
}
type snapshot struct {
Runs []Run `json:"runs"`
Repos int `json:"repos"`
FetchedAt time.Time `json:"fetchedAt"`
Err error `json:"-"`
}
func (s snapshot) stale(after time.Duration) bool {
return s.FetchedAt.IsZero() || time.Since(s.FetchedAt) > after
}
func (s snapshot) errString() string {
if s.Err == nil {
return ""
}
return s.Err.Error()
}
type runCache struct {
mu sync.RWMutex
snap snapshot
}
func (c *runCache) get() snapshot {
c.mu.RLock()
defer c.mu.RUnlock()
return c.snap
}
// put keeps the LAST GOOD run list when a refresh fails, recording the error
// alongside it. A failed poll must not blank the dashboard: "Hanzo Git is
// unreachable, here is what we last saw" is strictly more useful than an empty
// page, which reads as "nothing is building".
func (c *runCache) put(s snapshot) {
c.mu.Lock()
defer c.mu.Unlock()
if s.Err != nil && len(s.Runs) == 0 && len(c.snap.Runs) > 0 {
prev := c.snap
prev.Err = s.Err
c.snap = prev
return
}
c.snap = s
}
// ───────────────────────────── source ─────────────────────────────
type gitSource struct {
base string
token string
http *http.Client
}
func (g *gitSource) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.base+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+g.token)
req.Header.Set("Accept", "application/json")
resp, err := g.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", path, resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
type repoRef struct {
FullName string `json:"full_name"`
}
// repos returns the most recently ACTIVE repositories. Sorting by activity and
// taking a window is the whole scan strategy: the instance mirrors ~1400 repos
// and almost none of them built in the last hour, so walking all of them would
// spend the entire refresh budget confirming silence.
func (g *gitSource) repos(ctx context.Context, limit int) ([]string, error) {
var body struct {
Data []repoRef `json:"data"`
}
q := url.Values{}
q.Set("sort", "updated")
q.Set("order", "desc")
q.Set("limit", strconv.Itoa(limit))
if err := g.getJSON(ctx, "/v1/repos/search?"+q.Encode(), &body); err != nil {
return nil, err
}
names := make([]string, 0, len(body.Data))
for _, r := range body.Data {
if r.FullName != "" {
names = append(names, r.FullName)
}
}
return names, nil
}
type apiRun struct {
ID int64 `json:"id"`
DisplayTitle string `json:"display_title"`
Path string `json:"path"`
Event string `json:"event"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
RunNumber int `json:"run_number"`
HTMLURL string `json:"html_url"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
Actor struct {
Login string `json:"login"`
} `json:"actor"`
}
func (g *gitSource) runs(ctx context.Context, fullName string, limit int) ([]Run, error) {
var body struct {
WorkflowRuns []apiRun `json:"workflow_runs"`
}
path := fmt.Sprintf("/v1/repos/%s/actions/runs?limit=%d", fullName, limit)
if err := g.getJSON(ctx, path, &body); err != nil {
return nil, err
}
org, repo := splitFullName(fullName)
out := make([]Run, 0, len(body.WorkflowRuns))
for _, r := range body.WorkflowRuns {
out = append(out, Run{
ID: r.ID,
Org: org,
Repo: repo,
Workflow: workflowOf(r.Path),
Title: r.DisplayTitle,
Status: r.Status,
Conclusion: r.Conclusion,
Event: r.Event,
Branch: r.HeadBranch,
SHA: shortSHA(r.HeadSHA),
Actor: r.Actor.Login,
Number: r.RunNumber,
URL: r.HTMLURL,
StartedAt: parseTime(r.StartedAt),
EndedAt: parseTime(r.CompletedAt),
})
}
return out, nil
}
// poll refreshes the snapshot on an interval, forever.
func poll(ctx context.Context, logger *slog.Logger, src *gitSource, cache *runCache, cfg config) {
refresh := func() {
rctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
names, err := src.repos(rctx, cfg.scanRepos)
if err != nil {
logger.Warn("repo scan failed", "err", err)
cache.put(snapshot{FetchedAt: time.Now().UTC(), Err: err})
return
}
// Fan out, bounded. The cap is small on purpose: this is a read against
// the forge that schedules every build in the fleet, and a dashboard is
// never worth degrading it.
const workers = 6
var (
mu sync.Mutex
all []Run
errs []string
wg sync.WaitGroup
)
jobs := make(chan string)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for name := range jobs {
rs, err := src.runs(rctx, name, cfg.runsPer)
mu.Lock()
if err != nil {
// A repo with Actions disabled 404s. That is normal and
// not worth surfacing as a dashboard-level failure, so
// it is counted, not shown.
errs = append(errs, name)
} else {
all = append(all, rs...)
}
mu.Unlock()
}
}()
}
for _, n := range names {
select {
case jobs <- n:
case <-rctx.Done():
}
}
close(jobs)
wg.Wait()
sort.Slice(all, func(i, j int) bool { return all[i].StartedAt.After(all[j].StartedAt) })
cache.put(snapshot{Runs: all, Repos: len(names) - len(errs), FetchedAt: time.Now().UTC()})
logger.Info("refreshed", "repos", len(names), "withRuns", len(names)-len(errs), "runs", len(all))
}
refresh()
t := time.NewTicker(cfg.refresh)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
refresh()
}
}
}
// ───────────────────────────── helpers ─────────────────────────────
func splitFullName(s string) (org, repo string) {
if i := strings.IndexByte(s, '/'); i > 0 {
return s[:i], s[i+1:]
}
return "", s
}
// workflowOf reduces "e2e.yml@refs/heads/main" to "e2e.yml".
func workflowOf(path string) string {
if i := strings.IndexByte(path, '@'); i > 0 {
return path[:i]
}
return path
}
func shortSHA(s string) string {
if len(s) > 7 {
return s[:7]
}
return s
}
func parseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
// Hanzo Git reports an unset timestamp as the Unix epoch rather than null;
// treated as absent so the UI shows "—" instead of 1970.
if t.Year() < 2000 {
return time.Time{}
}
return t.UTC()
}
func filterByOrg(runs []Run, org string) []Run {
if org == "" {
return runs
}
out := make([]Run, 0, len(runs))
for _, r := range runs {
if r.Org == org {
out = append(out, r)
}
}
return out
}
func orgsOf(runs []Run) []string {
seen := map[string]bool{}
for _, r := range runs {
if r.Org != "" {
seen[r.Org] = true
}
}
out := make([]string, 0, len(seen))
for o := range seen {
out = append(out, o)
}
sort.Strings(out)
return out
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"fmt"
"html/template"
"net/http"
"strings"
"time"
)
// render.go — the HTML view. Server-rendered on purpose: this page is a table
// of build results, and a client-side app would ship a bundle, a fetch layer
// and a loading state to show the same rows a second later. The dashboard also
// has to be readable when the thing it reports on is broken, which is exactly
// when a build pipeline for its own frontend is the wrong dependency.
//
// That argument is about the BUILD, not about the design. The look is the
// house's and is not restated here: the <style> block is @hanzo/brand's own
// published token sheet plus this page's layout rules, both compiled in — see
// brand.go. Server rendering and one design system are not in tension; only
// server rendering and a JS component library are, and it is the tokens, not
// the components, that this page ever needed.
// renderDashboard writes the page for ONE viewer. Every row it renders has
// already passed v.visible — the template is never handed the full snapshot and
// asked to be careful with it, because a template that can see everything is one
// edit away from showing it.
func renderDashboard(w http.ResponseWriter, snap snapshot, v viewer, org string, cfg config) {
runs := v.visible(snap.Runs, org)
if len(runs) > 200 {
runs = runs[:200]
}
data := struct {
Runs []Run
Orgs []string
Org string
Viewer string
Sudo bool
Repos int
FetchedAt time.Time
Age string
Stale bool
SourceErr string
Source string
Counts map[string]int
}{
Runs: runs,
Orgs: v.orgs(snap.Runs),
Org: org,
Viewer: v.org,
Sudo: v.sudo,
Repos: snap.Repos,
FetchedAt: snap.FetchedAt,
Age: humanAge(snap.FetchedAt),
Stale: snap.stale(cfg.staleAfter),
SourceErr: snap.errString(),
Source: cfg.gitBase,
Counts: countByOutcome(runs),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// countByOutcome buckets runs for the summary strip.
func countByOutcome(runs []Run) map[string]int {
c := map[string]int{"success": 0, "failure": 0, "running": 0, "cancelled": 0}
for _, r := range runs {
c[outcome(r)]++
}
return c
}
// outcome collapses (status, conclusion) into the four states worth a colour.
//
// Status alone is NOT enough and getting this wrong is silent: Hanzo Git
// reports every finished run as `completed` regardless of how it went, so
// bucketing on status painted successes and cancellations as failures — on the
// live instance that was 15 of 20 runs mislabelled red.
//
// `cancelled` gets its own bucket rather than folding into failure. On this
// fleet cancellations are the single largest category (superseded pushes cancel
// the in-flight run), and a board that shows them as broken is a board nobody
// trusts, which is worse than no board.
func outcome(r Run) string {
if !strings.EqualFold(r.Status, "completed") {
return "running" // queued | in_progress | waiting | blocked
}
switch strings.ToLower(r.Conclusion) {
case "success":
return "success"
case "cancelled", "canceled", "skipped":
return "cancelled"
case "":
// Completed with no conclusion should not happen; if it does, say
// "running" rather than inventing a verdict the data does not support.
return "running"
default:
return "failure" // failure | timed_out | action_required
}
}
func humanAge(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < time.Minute:
return fmt.Sprintf("%ds ago", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
default:
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
}
func humanDur(d time.Duration) string {
if d <= 0 {
return "—"
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
}
// `class="dark"` is @hanzo/brand's own dark hook, not a local convention: the
// sheet's :root IS the dark scale, and the class is what additionally sets
// color-scheme so the scrollbars and form controls the browser draws match.
// Elsewhere in the fleet next-themes toggles that class; this page has no JS and
// no toggle, so it states its scheme once and means it.
var tmpl = template.Must(template.New("ci").Funcs(template.FuncMap{
"outcome": outcome,
"dur": func(r Run) string { return humanDur(r.Duration()) },
"ago": humanAge,
"css": pageCSS,
}).Parse(`<!doctype html>
<html lang="en" class="dark"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hanzo CI</title>
<meta http-equiv="refresh" content="60">
<style>{{css}}</style></head><body>
<header>
<h1>Hanzo <span>CI</span></h1>
<div class="meta">
{{.Repos}} repos &middot; refreshed {{.Age}}<br>
source {{.Source}}
</div>
</header>
<div class="strip">
<span class="chip ok">passing <b>{{index .Counts "success"}}</b></span>
<span class="chip fail">failing <b>{{index .Counts "failure"}}</b></span>
<span class="chip run">running <b>{{index .Counts "running"}}</b></span>
<span class="chip cancel">cancelled <b>{{index .Counts "cancelled"}}</b></span>
</div>
<nav>
{{if .Sudo}}<a href="/" {{if eq .Org ""}}class="on"{{end}}>all orgs</a>{{end}}
{{range .Orgs}}<a href="/?org={{.}}" {{if eq $.Org .}}class="on"{{end}}>{{.}}</a>{{end}}
<span class="who">signed in as {{.Viewer}}{{if .Sudo}} &middot; fleet view{{end}}</span>
</nav>
{{if .Stale}}<div class="warn">
Snapshot is stale — last successful refresh {{.Age}}.
{{if .SourceErr}}Hanzo Git said: {{.SourceErr}}{{else}}Hanzo Git is not answering.{{end}}
These rows are the last good read, not current state.
</div>{{end}}
{{if .Runs}}
<table>
<thead><tr>
<th>Repository</th><th>Workflow</th><th class="hide-sm">Commit</th>
<th class="hide-sm">Actor</th><th>Started</th><th>Took</th>
</tr></thead>
<tbody>
{{range .Runs}}
<tr>
<td><span class="dot {{outcome .}}"></span><a href="{{.URL}}"><span class="org">{{.Org}}/</span><span class="repo">{{.Repo}}</span></a></td>
<td>{{.Workflow}} <span class="mono">#{{.Number}}</span><div class="title">{{.Title}}</div></td>
<td class="hide-sm mono">{{.Branch}}@{{.SHA}}<div>{{.Event}}</div></td>
<td class="hide-sm mono">{{.Actor}}</td>
<td class="mono">{{ago .StartedAt}}</td>
<td class="mono">{{dur .}}</td>
</tr>
{{end}}
</tbody></table>
{{else}}
<div class="empty">
No runs in the scanned window.<br>
<span class="mono">Builds land here from {{.Source}} — this view holds no state of its own.</span>
</div>
{{end}}
<footer>
Build truth lives in Hanzo Git; this is a view over it.
Delivery is <a href="https://cd.hanzo.ai">cd.hanzo.ai</a>.
</footer>
</body></html>`))
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
// render_test.go guards the two properties the view has to keep: that its design
// values come from exactly one place, and that it can only ever draw rows the
// viewer was already permitted to see.
// TestBrandCSSIsUpstreamBytes is the pin. The vendored sheet is only a source of
// truth while it is byte-for-byte what @hanzo/brand published; the moment it can
// be edited in place it is a fork wearing an upstream name, which is the exact
// state this repo was in when it carried its own :root block.
func TestBrandCSSIsUpstreamBytes(t *testing.T) {
sum := sha256.Sum256([]byte(brandCSS))
got := hex.EncodeToString(sum[:])
if got != brandCSSSHA256 {
t.Fatalf("brand/variables.css is not @hanzo/brand@%s\n got %s\n want %s\n"+
"A token refresh: re-fetch the sheet and set brandCSSSHA256 to the got value.\n"+
"A local colour edit: make it in @hanzo/brand and release it, not here.",
brandCSSVersion, got, brandCSSSHA256)
}
}
// colourLiteral matches a value that decides an appearance on its own — a hex,
// or an rgb()/hsl() function. `color-mix(in srgb, var(--x) ...)` is deliberately
// not one of these: it derives from a token instead of naming a new colour.
var colourLiteral = regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(`)
// TestDashboardCSSNamesNoColours is what makes "one source of truth" a fact
// rather than an intention. Vendoring the sheet is only half the job; if the
// page can still write a hex next to it, the second palette grows back one
// "just this once" at a time — which is how the old :root block came to hold
// GitHub's status colours instead of the house's.
func TestDashboardCSSNamesNoColours(t *testing.T) {
if m := colourLiteral.FindAllString(dashboardCSS, -1); len(m) > 0 {
t.Fatalf("dashboard.css names colours directly: %v\n"+
"Every colour must be a var() into @hanzo/brand; if the token you need "+
"does not exist, add it there rather than here.", m)
}
if !strings.Contains(dashboardCSS, "var(--") {
t.Fatal("dashboard.css references no tokens at all — it has stopped consuming the design system")
}
}
// TestRenderedPageShowsOnlyTheViewersOrg drives the HTML, not the predicates.
// scope_test.go proves visible() and orgs() are right; this proves the page is
// actually built from them — the leak that started all of this was a handler
// handing a template more than the viewer was owed, and a template cannot be
// trusted to be careful with a snapshot it can see all of.
func TestRenderedPageShowsOnlyTheViewersOrg(t *testing.T) {
w := httptest.NewRecorder()
renderDashboard(w, snapshot{Runs: testRuns(), Repos: 3}, viewer{org: "lux"}, "", config{})
body := w.Body.String()
if !strings.Contains(body, ">lux/<") {
t.Fatal("lux viewer's own run is missing from the page")
}
// Rows: no other org's repo may be drawn.
for _, leaked := range []string{">hanzo/<", ">zoo/<"} {
if strings.Contains(body, leaked) {
t.Errorf("page rendered %s to a lux viewer", leaked)
}
}
// Nav: nor may another org's NAME, which discloses who builds here even
// when their runs are correctly hidden.
for _, leaked := range []string{"/?org=hanzo", "/?org=zoo", "all orgs"} {
if strings.Contains(body, leaked) {
t.Errorf("nav offered %q to a lux viewer", leaked)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"net/http"
"strings"
)
// scope.go answers exactly one question: whose builds may THIS request see?
//
// It exists because the first cut of this service conflated a FILTER with a
// GATE. `?org=lux` narrowed what was rendered and read like tenancy, but it
// decided nothing about who was allowed to ask — so /v1/runs answered 200 to
// anyone on the internet with every org's repo names, branches, commit SHAs and
// actor logins. A query parameter is a request for a view; it can never be the
// authority for one.
//
// The authority is 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 X-Org-Id with the
// guard's value, so on the wired path the header cannot be forged. This file
// still treats its ABSENCE as fatal rather than as "no filter", because absence
// is the signal that the request did not come through the guard at all.
// orgHeader is the identity the whole surface is scoped by. One name, one
// meaning, platform-wide (see the X-* header convention: X-Org-Id is the org
// slug from the JWT `owner` claim).
const orgHeader = "X-Org-Id"
// viewer is the resolved, trusted answer. Constructed only from headers the
// guard controls — never from the query string, never from a cookie.
type viewer struct {
// org is the caller's home org slug, from the verified `owner` claim.
org string
// sudo reports whether org is the platform admin org, which is the ONE
// identity that may see across tenants (the fleet view).
sudo bool
}
// resolveViewer lifts the guard-set header into a viewer. It fails closed: a
// missing or blank X-Org-Id yields ok=false and the caller MUST refuse the
// request.
//
// Defaulting an absent header to "no filter" is the specific bug this function
// exists to prevent — that default is what turns "reached ci without the guard"
// into "rendered every org's builds".
func resolveViewer(r *http.Request, adminOrg string) (viewer, bool) {
org := strings.TrimSpace(r.Header.Get(orgHeader))
if org == "" {
return viewer{}, false
}
return viewer{org: org, sudo: strings.EqualFold(org, strings.TrimSpace(adminOrg))}, true
}
// visible narrows runs to what v is permitted to see, then applies want (the
// optional `?org=` selection) WITHIN that permission.
//
// The ordering is the whole point: permission is applied first and `want` can
// only ever narrow the result. A lux viewer asking for `?org=hanzo` gets an
// empty list, not hanzo's builds — the parameter selects among what you may
// already see, it never reaches for more.
func (v viewer) visible(runs []Run, want string) []Run {
want = strings.TrimSpace(want)
if v.sudo {
// The fleet view: every org, narrowed by the requested one if given.
return filterByOrg(runs, want)
}
if want != "" && !strings.EqualFold(want, v.org) {
return nil
}
return filterByOrg(runs, v.org)
}
// orgs lists the org tabs this viewer may choose between. A tenant gets exactly
// its own org — rendering the full org list to a tenant would leak the set of
// orgs that build on the platform even though their runs are correctly hidden.
func (v viewer) orgs(runs []Run) []string {
if v.sudo {
return orgsOf(runs)
}
return []string{v.org}
}
// requireViewer resolves the viewer or writes the refusal. It returns ok=false
// when the request must not proceed.
func requireViewer(w http.ResponseWriter, r *http.Request, adminOrg string) (viewer, bool) {
v, ok := resolveViewer(r, adminOrg)
if ok {
return v, true
}
// 403, not 401: a 401 invites a credential retry, but there is nothing the
// CALLER can add to fix this. The header is set by infrastructure, so its
// absence is a routing fault (ci reached off-guard) and the honest answer is
// that this path is not authorized to serve, whoever is asking.
http.Error(w, "forbidden: no "+orgHeader+" (this service is only reachable through the IAM gate)", http.StatusForbidden)
return viewer{}, false
}
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// scope_test.go is the regression suite for the leak this service shipped with:
// /v1/runs answered 200 to anyone, with every org's repo names, branches, commit
// SHAs and actor logins, because `?org=` was a filter being used as a gate.
//
// The properties asserted here are the ones that made it a leak, not merely the
// ones that make the new code work.
func testRuns() []Run {
return []Run{
{Org: "hanzo", Repo: "cloud", Workflow: "build", Status: "completed", Conclusion: "success"},
{Org: "lux", Repo: "node", Workflow: "build", Status: "completed", Conclusion: "failure"},
{Org: "zoo", Repo: "app", Workflow: "test", Status: "in_progress"},
}
}
// TestNoOrgHeaderIsRefused is the core fix. An absent X-Org-Id means the request
// did not come through the IAM gate; the ONLY safe answer is to refuse. The old
// code treated the equivalent condition (no `?org=`) as "show everything".
func TestNoOrgHeaderIsRefused(t *testing.T) {
for _, hdr := range []string{"", " "} {
r := httptest.NewRequest(http.MethodGet, "/v1/runs", nil)
if hdr != "" {
r.Header.Set(orgHeader, hdr)
}
w := httptest.NewRecorder()
v, ok := requireViewer(w, r, "admin")
if ok {
t.Fatalf("X-Org-Id=%q admitted as viewer %+v — absence must fail closed", hdr, v)
}
if w.Code != http.StatusForbidden {
t.Errorf("X-Org-Id=%q: status=%d want 403", hdr, w.Code)
}
}
}
// TestTenantCannotWidenWithQueryParam is the attack the original design invited:
// the caller picks the org. Now the header decides and the parameter may only
// narrow, so a lux viewer asking for hanzo's builds gets nothing — NOT hanzo's
// builds, and not a silent fallback to its own either (that would be confusing,
// but it is the empty answer that matters for security).
func TestTenantCannotWidenWithQueryParam(t *testing.T) {
lux := viewer{org: "lux"}
got := lux.visible(testRuns(), "hanzo")
if len(got) != 0 {
t.Fatalf("lux viewer asking ?org=hanzo saw %d runs (%+v) — must see none", len(got), got)
}
own := lux.visible(testRuns(), "")
if len(own) != 1 || own[0].Org != "lux" {
t.Fatalf("lux viewer saw %+v; want exactly its own org", own)
}
if same := lux.visible(testRuns(), "lux"); len(same) != 1 {
t.Errorf("lux viewer asking ?org=lux saw %d runs; want its own 1", len(same))
}
}
// TestSudoSeesFleetAndCanNarrow asserts the admin org keeps the cross-tenant
// view that makes this dashboard useful to the platform, and that `?org=` still
// works as a plain filter for it.
func TestSudoSeesFleetAndCanNarrow(t *testing.T) {
sudo := viewer{org: "admin", sudo: true}
if all := sudo.visible(testRuns(), ""); len(all) != 3 {
t.Fatalf("sudo saw %d runs; want all 3", len(all))
}
one := sudo.visible(testRuns(), "zoo")
if len(one) != 1 || one[0].Org != "zoo" {
t.Fatalf("sudo ?org=zoo saw %+v; want zoo only", one)
}
}
// TestResolveViewerSudoDetection pins the sudo bit to the configured admin org,
// case-insensitively, and proves an ordinary org never gets it.
func TestResolveViewerSudoDetection(t *testing.T) {
cases := []struct {
hdr, adminOrg string
wantSudo bool
}{
{"admin", "admin", true},
{"ADMIN", "admin", true},
{" admin ", "admin", true},
{"lux", "admin", false},
{"administrator", "admin", false}, // prefix must not match
{"admin", "root", false}, // honours a non-default admin org
}
for _, tc := range cases {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set(orgHeader, tc.hdr)
v, ok := resolveViewer(r, tc.adminOrg)
if !ok {
t.Fatalf("X-Org-Id=%q: not resolved", tc.hdr)
}
if v.sudo != tc.wantSudo {
t.Errorf("X-Org-Id=%q adminOrg=%q: sudo=%v want %v", tc.hdr, tc.adminOrg, v.sudo, tc.wantSudo)
}
}
}
// TestTenantOrgListIsNotTheFleetList covers the quieter leak: even with runs
// correctly hidden, rendering every org's NAME in the nav would disclose the set
// of orgs that build on the platform.
func TestTenantOrgListIsNotTheFleetList(t *testing.T) {
lux := viewer{org: "lux"}
orgs := lux.orgs(testRuns())
if len(orgs) != 1 || orgs[0] != "lux" {
t.Fatalf("tenant org list = %v; want only its own org", orgs)
}
if sudoOrgs := (viewer{org: "admin", sudo: true}).orgs(testRuns()); len(sudoOrgs) != 3 {
t.Errorf("sudo org list = %v; want all 3", sudoOrgs)
}
}
// TestRunsEndpointScopesEndToEnd drives the actual HTTP handler wiring, not just
// the predicates — the leak was in the handler, so the handler is what must be
// asserted.
func TestRunsEndpointScopesEndToEnd(t *testing.T) {
cache := &runCache{}
cache.put(snapshot{Runs: testRuns(), Repos: 3})
cfg := config{adminOrg: "admin"}
h := func(w http.ResponseWriter, r *http.Request) {
v, ok := requireViewer(w, r, cfg.adminOrg)
if !ok {
return
}
snap := cache.get()
writeJSON(w, http.StatusOK, map[string]any{
"runs": v.visible(snap.Runs, r.URL.Query().Get("org")),
"orgs": v.orgs(snap.Runs),
})
}
t.Run("anonymous → 403", func(t *testing.T) {
w := httptest.NewRecorder()
h(w, httptest.NewRequest(http.MethodGet, "/v1/runs", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("status=%d want 403; body=%s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "cloud") || strings.Contains(w.Body.String(), "node") {
t.Error("refusal body leaked repo names")
}
})
t.Run("lux viewer sees only lux, even asking for hanzo", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/runs?org=hanzo", nil)
r.Header.Set(orgHeader, "lux")
w := httptest.NewRecorder()
h(w, r)
var got struct {
Runs []Run `json:"runs"`
Orgs []string `json:"orgs"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Runs) != 0 {
t.Errorf("lux asking ?org=hanzo got %+v; want none", got.Runs)
}
if len(got.Orgs) != 1 || got.Orgs[0] != "lux" {
t.Errorf("orgs=%v; want [lux]", got.Orgs)
}
})
}