Compare commits

..
Author SHA1 Message Date
hanzo-dev 1f44cb4a03 ci: add opt-in mode: delegate — hand the build to platform.hanzo.ai
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.
2026-07-04 21:10:48 -07:00
21 changed files with 55 additions and 4744 deletions
-59
View File
@@ -1,59 +0,0 @@
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
@@ -1,5 +0,0 @@
# `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
@@ -1,27 +0,0 @@
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
@@ -1,41 +0,0 @@
# 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"]
+8 -142
View File
@@ -40,144 +40,10 @@ jobs:
That's it. The build/test/deploy logic lives here, once.
## `client:` — one document, eight generated clients
## Runners — our cloud or your own
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:
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:
```yaml
uses: hanzoai/ci/.github/workflows/build.yml@v1
@@ -188,7 +54,7 @@ self-hosted runners, pass their labels:
## Delegate to platform (skip runner buildx)
By default the build runs buildx **on** the runner. To instead hand the build
By default the build runs buildx **on** the arc runner. To instead hand the build
to **platform.hanzo.ai** — which builds in-cluster with BuildKit and rolls the
service itself — pass `mode: delegate`:
@@ -200,7 +66,7 @@ service itself — pass `mode: delegate`:
```
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,
build webhook (`/v1/arcd/enqueue`) 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
@@ -208,10 +74,10 @@ 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`).
`PLATFORM_ENQUEUE_URL` repo/org variable (default `https://platform.hanzo.ai/v1/arcd/enqueue`).
`mode: buildx` (the default) is unchanged — existing repos keep running buildx on
the fleet runner, so delegation is strictly opt-in.
arc, so delegation is strictly opt-in.
## Credentials
@@ -223,6 +89,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 in-cluster and rolls it out, no
needs **only** `hanzo.yml` — the platform builds it on arc 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.
-147
View File
@@ -1,147 +0,0 @@
#!/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
@@ -1,128 +0,0 @@
#!/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
-93
View File
@@ -1,93 +0,0 @@
#!/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
@@ -1,60 +0,0 @@
#!/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
@@ -1,55 +0,0 @@
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
@@ -1,235 +0,0 @@
/**
* @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
@@ -1,71 +0,0 @@
/* 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
@@ -1,3 +0,0 @@
module github.com/hanzoai/ci
go 1.26.5
-90
View File
@@ -1,90 +0,0 @@
# 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
@@ -1,495 +0,0 @@
// 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
@@ -1,205 +0,0 @@
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
@@ -1,78 +0,0 @@
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
@@ -1,96 +0,0 @@
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
@@ -1,176 +0,0 @@
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)
}
})
}