Compare commits

..
14 Commits
Author SHA1 Message Date
zeekay b047a7a569 build: the forge lane is the lane that runs
Hanzo CI/CD / cicd (push) Successful in 4m10s
CI/CD / gate (push) Successful in 4m11s
8a415e9 moved the REGISTRY_USER/REGISTRY_PASSWORD fetch out of the
`bucket:`/`site:` gate in .github/workflows/build.yml and left
.hanzo/workflows/build.yml untouched. Those two files are one artifact spelled
twice — github.com reads only `.github/workflows`, git.hanzo.ai reads only
`.hanzo/workflows` — and this repo's own `build-yml-is-one-file` gate exists to
refuse exactly this. It would have caught it: the gate passes at a830325 and
fails at 8a415e9.

The consequence is the inverse of the fix's intent. The copy that got the fix is
read by the forge nobody builds on; the copy the runners actually execute still
asks KMS for the registry credential only when a repo declares a bucket or a
site. So a repo declaring only `images:` — the common shape — still arrives at
`docker login registry.hanzo.ai` with two empty strings, and deleting the
REGISTRY_USER/REGISTRY_PASSWORD org secrets on the strength of 8a415e9 would
have broken every image build in three orgs rather than none.

They were byte-identical at a830325 (md5 1c027d00), and the only difference the
gate tolerates is a self-naming path that neither copy actually spells
differently, so the mirror is a literal copy.

No condition changes: under `mode: buildx` all 24 steps have effective
conditions identical to f098b39, which is what the v1 tag points at today.
2026-08-06 12:52:27 -07:00
hanzo-dev 8a415e9745 build: the registry credential is not an object-store credential
Hanzo CI/CD / cicd (push) Failing after 2m17s
CI/CD / gate (push) Failing after 2m18s
REGISTRY_USER/REGISTRY_PASSWORD were fetched from KMS only inside the
`bucket:`/`site:` gate — a gate about writing bytes to hanzoai/s3, which has
nothing to do with pushing an image. A repo declaring only `images:` therefore
asked KMS for nothing, arrived at `docker login registry.hanzo.ai` with two
empty strings, and failed at the push with no error pointing anywhere near this
step.

hanzoai/bot is the repo that found it, and it will not be the last: `images:`
without `bucket:`/`site:` is the common shape, not the exotic one.

The tempting fix is to paste the two names into the forge's org secrets, and
that is precisely the CI-only copy the comment four lines down warns against —
"a second thing to rotate and the one nobody remembers". The contract is that
the ONLY things living in the forge are KMS_CLIENT_ID/SECRET. So the fetch
moves out of the gate instead of the credential moving into GitHub.

Unconditional is safe: `get` returns empty when KMS has no such key, nothing is
exported, and the login step still falls back to the kubeconfig read for repos
provisioned before these names existed. The S3 keys stay gated, because those
really are specific to the two lanes that write to a bucket.
2026-08-06 12:35:47 -07:00
zeekay a830325d31 main is not the proof branch
Hanzo CI/CD / cicd (push) Successful in 1m22s
CI/CD / gate (push) Successful in 1m22s
2c036d3 set `mode: delegate` on this repo's own caller to prove the reordered
pipeline gates before it delegates. It belongs on a PR branch, and it reached
main because the two commits after it were made without leaving that branch.

On main it is worse than untidy: this caller pins @v2, which still resolves to
the PRE-reorder build.yml, so main would have run the exact defect the reorder
exists to remove — delegate evaluated fourth, the gate skipped, green in
seconds. Restore the caller. The proof runs on a branch, pinned at the version
tag that actually carries the fix.
2026-08-06 12:01:54 -07:00
zeekay d1e6edf6d1 the pipeline carries its own rule, because its tools come from somewhere else
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
Calling "$CI_HOME/bin/publishable" looked like the DRY choice and is broken on
every forge run. The tools checkout and this workflow resolve from DIFFERENT
places: a caller pins the workflow at a ref its own forge resolves, while the
tools step derives its ref from GITHUB_WORKFLOW_REF — which git.hanzo.ai does
not set. Measured on run 36473: `GITHUB_WORKFLOW_REF=<unset>`, `derived
ref=v1`, `cloned https://github.com/hanzoai/ci@v1`. So a step calling a NEW
file under $CI_HOME cannot work until a tag moves on github.com, a host the
caller never named — and hanzoai/ci's github main has diverged from the forge's,
so moving it is not a formality either.

Inline the rule. That leaves it spelled in two files, which is the same shape
as the two copies of build.yml, so it gets the same treatment:
`publishable-rule-is-one-rule` refuses any difference between the pattern
build.yml enforces and the one bin/publishable's test proves. Proven red
against a one-token drift.
2026-08-06 12:01:09 -07:00
zeekay 2c036d3a61 proof: delegate must gate before it delegates
Hanzo CI/CD / cicd (pull_request) Canceled after 0s
CI/CD / gate (pull_request) Canceled after 0s
2026-08-06 11:50:22 -07:00
zeekay 11fcb7b145 delegate chooses where the image is built, not whether it was tested
Hanzo CI/CD / cicd (push) Successful in 1m53s
CI/CD / gate (push) Successful in 1m54s
`mode: delegate` was evaluated fourth, ahead of every toolchain and ahead of
`test:`, and every other step in the file carried `if: inputs.mode !=
'delegate'`. So a delegated run skipped the gate along with the build: it
POSTed the image to platform and exited green in seconds having proven
nothing. The seam was right — ci gates, platform builds — and the ORDER said
otherwise, which is the kind of defect that reads as correct in every diff
that touches one step at a time.

Move the delegate POST to sit after `Test (per hanzo.yml)`, and let the steps
the gate cannot run without run in both modes: the two git-auth steps, the KMS
fetch that gives them GIT_TOKEN, and the Go/C/Node/Rust toolchains. What a
delegated run skips is buildx, publish and deploy. Nothing in the fleet passes
`mode: delegate` today (0 of 258 callers), so this changes no live run — it
closes the trap before the first repo falls into it.

`gate-runs-before-delegate` locks both halves of that, because either alone can
hold while the gate still does not run: the POST must come after the gate, and
the gate must not be delegate-guarded. Proven red against both regressions,
including a reconstruction of the exact prior layout.

Delegate also now refuses a repo that declares `build_secrets`. `/v1/runner`
resolves no KMS name — platform's own secrets arrive as a KMSSecret CRD with a
statically declared key list, which structurally cannot serve a name read out
of a hanzo.yml at some SHA — so delegating such a repo builds an image with an
empty credential, on a machine whose logs the run never shows.

And bin/publishable, with its own test, refuses a `build_secrets` name that has
not declared itself public. A build_secret is handed to buildx as `--build-arg
NAME=value`, and a build-arg is in the published image: `docker history` prints
it to anyone who can pull. The one repo whose value is genuinely publishable
asserted the `pk-` prefix inside its own Dockerfile — so the check existed
once, for one image, and any repo adding a real credential got none. The rule
is the name, because the name is what a reviewer reads and what git keeps; the
fleet had already started saying it that way (docs renamed EVENT_INGEST_KEY ->
PUBLISHABLE_KEY, world's four are VITE_*). This makes the convention the
mechanism instead of a habit.
2026-08-06 11:38:34 -07:00
hanzo-dev 5e0e4f906c hanzo-inc resolves to the KMS org it actually seals to
Hanzo CI/CD / cicd (push) Successful in 2m15s
CI/CD / gate (push) Successful in 2m15s
The owner -> KMS-org relation knew hanzoai, luxfi and zooai. hanzo-inc — pay
and the other company surfaces — fell through to the default branch and
became the literal owner, which is not a KMS org.

That was survivable by accident: the read takes its org from the TOKEN, so
the flat route still returned the right secret. What was wrong was the
fallback URL and, more expensively, every error message this step can print
— they named org=hanzo-inc, which sends whoever reads them looking for a
missing tenant rather than a missing credential.

One line, in the one place the comment above it already says this relation
belongs.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:40:56 -07:00
hanzo-dev f098b39ecd a documented example is not an expression to evaluate
Hanzo CI/CD / cicd (push) Successful in 59s
CI/CD / gate (push) Successful in 1m0s
`v1` has failed at load, on every caller in the fleet, since 0e1a56b. Not a
test failure — a startup failure: zero jobs, zero seconds, and GitHub showing
the file's PATH where its name should be, because it never got far enough to
read `name:`.

The `tests:` input's description quotes what a caller writes, and quoted it
literally:

    `tests: ${{ github.ref_type != 'tag' }}`

Prose or not, that is an expression, and it sits under `on.workflow_call.inputs`
where NO context exists. GitHub validates the whole file before it schedules
anything, refuses `github` there, and drops the run — so every repo importing
`hanzoai/ci@v1` stopped running CI entirely while reporting only a red X with
nothing inside it to read.

The irony is exact: 0e1a56b is titled "the test step has never run, and the gate
that would have said so never ran either." It made that true of every gate at
once.

Say it without the sigil. Same meaning to a reader, nothing to evaluate.
Confirmed with actionlint: clean at 23862e5c (before the break), the same error
at v1, v1.0.34 and v2, clean here.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:28:04 -07:00
hanzo-dev 674fabdcba split the server, do not strip a scheme you assumed
Hanzo CI/CD / cicd (push) Successful in 1m0s
CI/CD / gate (push) Successful in 1m0s
v1.0.33 built the forge substitution with `${SERVER#https://}`. GITHUB_SERVER_URL
on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's in-cluster
address, plain http — so the strip matched nothing, passed the whole URL through,
and produced `https://http://hanzo-git.hanzo.svc/hanzoai/`. git then went looking
for a host called `http`:

  fatal: unable to access 'https://http://hanzo-git.hanzo.svc/hanzoai/authz/':
  Could not resolve host: http

Take the scheme and the host as the two values they are, and compare the HOST
against github.com so the guard cannot be fooled by a spelling either. Checked
against http/https, in-cluster and public, with and without a trailing slash
before pushing this time.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 06:34:42 -07:00
hanzo-dev 3f65c9e24f the test gate reaches our modules at the address they live at
Hanzo CI/CD / cicd (push) Successful in 1m43s
CI/CD / gate (push) Successful in 1m44s
The gate's first real execution died in `go vet`, before one test ran, on
`github.com/hanzoai/dashscopego v0.6.0` — an indirect dependency of
hanzoai/cloud that git.hanzo.ai serves and github.com answers `Repository not
found` for.

A module path is a NAME, not an address. This step authenticated to github.com
and only github.com, so on a forge runner it dialled the mirror instead of the
canonical host. Its own caller already knew better: cloud's `containment` and
`reach` jobs each make exactly this substitution, carrying a comment about the
nine consecutive releases that one sibling module's drifted GitHub ACL once
blocked. The gate that runs the tests reached the modules through a different
door, so the law was written twice and applied where it was not needed.

go.sum is what makes it safe rather than merely convenient: the forge serves the
same objects, so the fetched zip hashes to the h1: line already committed, and a
forge serving different bytes fails the build loudly instead of shipping them.

Owner-scoped and host-derived — `${{ github.repository_owner }}` against
GITHUB_SERVER_URL — so it is the same statement for any org on any forge, and a
no-op on github.com or without FORGE_TOKEN. Longest prefix wins in git, so every
other module still resolves from github.com exactly as before.

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

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

The contract, once:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

COREPACK_NPM_REGISTRY on the job. corepack fetches the pnpm/yarn tarball ITSELF
from registry.npmjs.org and does not read .npmrc to do it, so all 317 repos that
pin `packageManager` reach npmjs on every install however carefully their
registry is configured — the dependencies came from our mirror and the tool
fetching them did not. Set here for BYO runners; the git-runner fleet gets the
same value from its own config so jobs that never import this reusable are
covered too.
2026-08-04 17:57:07 -07:00
8 changed files with 1469 additions and 574 deletions
+60
View File
@@ -0,0 +1,60 @@
name: sitedeploy
description: Publish a built static export to the Hanzo PaaS Sites plane.
# For every repo whose deploy is "a static export goes live" — hanzo.ai, hanzo.app,
# hips, computer and the ones after them. Those were each about to carry their own
# copy of enqueue → upload → complete, which is how one contract becomes N
# transcriptions that drift (see the imgver action next door for the same story
# told about image tags).
#
# - uses: hanzoai/ci/.github/actions/sitedeploy@v1
# with: { slug: hanzo-ai, dir: out }
# env: { HANZO_DEPLOY_TOKEN: '${{ secrets.HANZO_DEPLOY_TOKEN }}' }
#
# A SITE IS NOT AN APP. This publishes files and stops: no image, no CR, no
# replicas, no registry. Building a container so a Go binary can serve /public is
# the shape the Sites plane exists to retire.
#
# ONE credential. The 202 hands back a prefix-scoped, 30-minute presigned POST
# grant, so CI never holds a bucket key — do NOT add SITES_S3_* here; that is the
# standing shared-bucket credential the grant replaced.
inputs:
slug:
description: The project slug on the Sites plane (POST /v1/projects/<slug>/deploy)
required: true
dir:
description: The built export directory
required: true
api:
description: Cloud API base
required: false
default: https://api.hanzo.ai
jobs:
description: Parallel uploads
required: false
default: '24'
runs:
using: composite
steps:
- name: Fetch sitedeploy
shell: bash
# The action ref is the script ref: an action pinned to @v1 runs v1's
# sitedeploy. Both forges, because this repo is served from each.
run: |
set -euo pipefail
ref="${GITHUB_ACTION_REF:-v1}"
for url in https://git.hanzo.ai/hanzoai/ci https://github.com/hanzoai/ci; do
git clone -q --depth 1 --branch "$ref" "$url" "$RUNNER_TEMP/sitedeploy-ci" 2>/dev/null && break
done
[ -x "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" ] \
|| { echo "::error::could not fetch hanzoai/ci@$ref (bin/sitedeploy)"; exit 1; }
- name: Deploy
shell: bash
env:
HANZO_API: ${{ inputs.api }}
SITEDEPLOY_JOBS: ${{ inputs.jobs }}
SITEDEPLOY_COMMIT: ${{ github.sha }}
SITEDEPLOY_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
bash "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" '${{ inputs.slug }}' '${{ inputs.dir }}'
+463 -287
View File
@@ -29,14 +29,17 @@ on:
default: '["hanzo-build-linux-amd64"]'
mode:
description: >-
Build execution mode. `buildx` (default) runs the full buildx →
test → deploy pipeline ON the runner. `delegate` instead POSTs the
build to platform.hanzo.ai (`/v1/runner`) — platform builds
in-cluster with BuildKit and rolls the operator Service CR itself, so
the GitHub job finishes in seconds with no runner buildx. A repo opts
in by passing `with: { mode: delegate }`; everything else is unchanged.
Requires the `PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets:
inherit).
Where the IMAGE is built. `buildx` (default) builds it on this runner.
`delegate` POSTs it to platform.hanzo.ai (`/v1/runner`), which builds
in-cluster with BuildKit and rolls the operator Service CR itself.
A repo opts in with `with: { mode: delegate }` and needs the
`PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets: inherit).
THE GATE RUNS IN BOTH MODES, and that is the whole point of the input
being named for the build. `delegate` used to be evaluated fourth, ahead
of every toolchain and ahead of `test:`, so it skipped the gate along
with the build — 20 steps skipped, 0 executed, green. What a delegated
run skips is buildx, publish and deploy. It does not skip being tested.
type: string
default: buildx
tests:
@@ -73,7 +76,7 @@ on:
A string sidesteps it on both planes with identical meaning, and the
comparison is against 'false' rather than a truthiness test so that the
expression callers already write — hanzoai/cloud's
`tests: ${{ github.ref_type != 'tag' }}`, which renders the strings
`tests:` set from `github.ref_type != 'tag'`, which renders the strings
"true"/"false" — keeps working unchanged.
type: string
default: 'true'
@@ -104,6 +107,14 @@ permissions:
jobs:
cicd:
runs-on: ${{ fromJson(inputs.runner) }}
env:
# corepack fetches the pnpm/yarn TARBALL ITSELF from registry.npmjs.org,
# and it does not read .npmrc to do it — so 317 repos that pin
# `packageManager` reach out to npmjs on every install no matter how
# carefully their registry is configured. This is the one variable that
# closes that hole; without it "installs go through our registry" is true
# of the dependencies and false of the package manager fetching them.
COREPACK_NPM_REGISTRY: https://pkg.hanzo.ai
steps:
- uses: actions/checkout@v4
with:
@@ -214,64 +225,7 @@ jobs:
jq --version
yq --version
- name: Delegate build to platform (mode=delegate)
# The GHA-escape fast path: instead of running buildx on this runner, POST
# each image in hanzo.yml to platform.hanzo.ai's direct-enqueue webhook
# (`/v1/runner`). Platform creates a build_job row, launches an
# in-cluster BuildKit Job on its own pool, pushes to the registry, and —
# for a system service — patches the operator Service CR to roll it. The
# downstream is IDENTICAL to the platform GitHub-App webhook path (one
# build path, two front doors), so a delegated build behaves exactly like
# a platform-native one. This job then exits in seconds — no buildx, no
# KMS, no runner-side deploy.
if: inputs.mode == 'delegate'
env:
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
run: |
set -euo pipefail
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
fi
REPO="${{ github.repository }}"
SHA="${{ github.sha }}"
SHORT=$(echo "$SHA" | cut -c1-7)
REF="${{ github.ref }}"
BRANCH="${{ github.ref_name }}"
# One enqueue per (image, platform), mirroring the buildx tag shape the
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# Same Go-builder check the buildx lane runs. It belongs here too:
# delegating the build moves WHERE it runs, not whether the image
# can compile the module, and a mismatch enqueued to platform fails
# on a machine whose logs this run never shows. Cheap to check on
# the runner that already has the checkout; expensive to discover
# from the in-cluster builder.
"$CI_HOME/bin/gover" "$df" "$ctx"
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
arch="${plat##*/}"
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
body=$(jq -nc \
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
--arg ref "$REF" --arg branch "$BRANCH" \
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
echo "::group::delegate $name → $image"
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
cat /tmp/enqueue.out; echo
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
echo "::endgroup::"
done
done
- name: Authenticated git for go modules (rate-limit + any private repo)
if: inputs.mode != 'delegate'
# luxfi/hanzoai/zooai Go modules are PUBLIC, so `go` resolves them through
# the default public proxy (proxy.golang.org) + checksum db (sum.golang.org)
# — canonical, IMMUTABLE hashes that a force-moved tag can no longer break.
@@ -327,7 +281,6 @@ jobs:
- name: Fetch deploy credentials from KMS
id: kms
if: inputs.mode != 'delegate'
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
@@ -367,6 +320,15 @@ jobs:
if [ -z "$ORG" ]; then
case "${{ github.repository_owner }}" in
hanzoai) ORG=hanzo ;;
# hanzo-inc is the same TENANT as hanzoai — pay, commerce and the
# rest of the company surfaces bill and seal to KMS org `hanzo`.
# Without this line ORG fell through to the literal owner,
# "hanzo-inc", which is not a KMS org. That still LOOKED fine,
# because the read below takes its org from the TOKEN, not the URL
# — so the only things that were wrong were the fallback URL and
# every error message, which named an org that does not exist and
# sent the reader looking for a tenant instead of a credential.
hanzo-inc) ORG=hanzo ;;
luxfi) ORG=lux ;;
zooai) ORG=zoo ;;
*) ORG="${{ github.repository_owner }}" ;;
@@ -383,6 +345,40 @@ jobs:
# how a stale key survived: every failure looked like "not in KMS", and
# "not in KMS" was survivable.
DECLARED="$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true)"
# A build_secret becomes `--build-arg NAME=value` below, and a build-arg
# is IN THE PUBLISHED IMAGE — `docker history` prints it to anyone who
# can pull. So `build_secrets` can only ever carry values that are
# public on purpose: a Vite/Next SPA has no server to read an env from,
# so its ingest key must be inlined at build, and inlining it is what
# publishing it means. Every other value must not be here at all.
#
# Nothing checked that. The one repo whose value is genuinely
# publishable (hanzoai/ui, a `pk-…` ingest key) asserted the `pk-`
# prefix INSIDE ITS OWN Dockerfile — so the check existed once, for one
# image, and any repo that added a real credential got no check at all.
# Per-repo vigilance is not a mechanism; the assertion belongs here,
# where the build-arg is assembled, once, for every caller.
#
# INLINE, not "$CI_HOME/bin/publishable", and that is forced rather than
# chosen. The tools checkout and this workflow resolve from DIFFERENT
# places: the caller pins the workflow at a ref its forge resolves, while
# the tools step derives its ref from GITHUB_WORKFLOW_REF — which
# git.hanzo.ai does not set, so it falls back to `v1` and clones
# github.com. Measured on run 36473: `GITHUB_WORKFLOW_REF=<unset>`,
# `derived ref=v1`, `cloned https://github.com/hanzoai/ci@v1`. A step
# that calls a NEW file under $CI_HOME is therefore broken on every forge
# run until a tag moves on a host this repo is not even pinned to.
#
# The rule is also spelled in bin/publishable, which the `publishable`
# gate tests. Two spellings of one rule is the same shape as the two
# copies of this file, and it gets the same treatment: a gate refuses
# any difference between them. See `publishable-rule-is-one-rule`.
for bs in $DECLARED; do
case "$bs" in
PUBLISHABLE_*|PUBLIC_*|NEXT_PUBLIC_*|EXPO_PUBLIC_*|NUXT_PUBLIC_*|VITE_*|REACT_APP_*|*_PUBLISHABLE|*_PUBLIC) ;;
*) echo "::error::build_secret '$bs' does not declare itself publishable, and a build_secret is baked into the image as a --build-arg where \`docker history\` reveals it. Rename it (PUBLISHABLE_*, PUBLIC_*, NEXT_PUBLIC_*, VITE_*, REACT_APP_*) if the value is public on purpose; if it is a real credential it cannot be a build_secret at all."; exit 1 ;;
esac
done
required() { [ -n "$DECLARED" ]; }
if [ -z "${KMS_CLIENT_ID:-}" ] || [ -z "$ORG" ]; then
if required; then
@@ -417,10 +413,35 @@ jobs:
if [ -n "$GIT_TOKEN" ]; then echo "::add-mask::$GIT_TOKEN"; echo "GIT_TOKEN=$GIT_TOKEN" >> "$GITHUB_ENV"; fi
KUBECONFIG_B64=$(get KUBECONFIG)
if [ -n "$KUBECONFIG_B64" ]; then echo "$KUBECONFIG_B64" | base64 -d > "$RUNNER_TEMP/kubeconfig"; echo "kubeconfig=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_OUTPUT"; fi
# The REGISTRY credential, and it is fetched UNCONDITIONALLY because
# every repo that publishes an image needs it — which is most of them.
#
# It used to sit inside the `bucket:`/`site:` gate below, and that gate
# is about writing bytes to hanzoai/s3, a completely unrelated lane. So
# a repo declaring only `images:` — hanzoai/bot, and it is not alone —
# asked KMS for nothing and reached the docker login step with empty
# credentials. The build then failed at the push with no clue pointing
# here, and the obvious "fix" is to paste REGISTRY_USER/REGISTRY_PASSWORD
# into the forge's org secrets, which is exactly the CI-only copy the
# comment below warns about: a second thing to rotate, and the one
# nobody remembers. The contract is that the ONLY things living in the
# forge are KMS_CLIENT_ID/SECRET, so the fetch moves out of the gate
# rather than the credential moving into GitHub.
#
# Empty is harmless: nothing is exported when KMS has no such key, and
# the login step already falls back to the kubeconfig read for repos
# provisioned before these names existed.
for k in REGISTRY_USER REGISTRY_PASSWORD; do
v=$(get "$k")
if [ -n "$v" ]; then
[ "$k" = REGISTRY_PASSWORD ] && echo "::add-mask::$v"
echo "$k=$v" >> "$GITHUB_ENV"
fi
done
# Object-store credential, for the two lanes that write bytes to
# hanzoai/s3: a repo publishing `binaries:` to a bucket:, and a repo
# publishing a `site:` (whose build output has to land in the org's own
# storage space before /v1/sites can promote it). Same four names the
# storage space before /v1/sites can promote it). Same names the
# services read at run time, from the same org/path/env — a CI-only copy
# of a credential is a second thing to rotate and the one nobody
# remembers. One fetch serves both lanes; a repo declaring both does not
@@ -521,14 +542,20 @@ jobs:
echo "::notice::KMS GHCR token login failed — keeping the earlier login"
fi
- name: Native registry credential (oci.hanzo.ai)
- name: Native registry credential (registry.hanzo.ai)
if: inputs.mode != 'delegate'
env:
KUBECONFIG: ${{ steps.kms.outputs.kubeconfig }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
# KMS first (exported to GITHUB_ENV by the step above), GitHub secret
# second. `env:` here cannot see GITHUB_ENV from THIS step's own env
# block, so the KMS values are read out of the environment in `run:`.
GH_REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
GH_REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -uo pipefail
# REGISTRY_USER/PASSWORD may already be in the environment from KMS.
REGISTRY_USER="${REGISTRY_USER:-${GH_REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${GH_REGISTRY_PASSWORD:-}}"
# Direct credential first (repo/org secret — works on private repos,
# where the Free plan hides org secrets... including these; private
# repos set them at REPO level). KMS-kubeconfig read is the fallback.
@@ -537,14 +564,14 @@ jobs:
# cred) must never fail the run — the image still pushes to GHCR, the
# primary. Without this guard, bash -e aborts the step and SKIPS the
# build entirely (a registry hiccup takes the whole lane red).
if echo "$REGISTRY_PASSWORD" | docker login oci.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
if echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
fi
exit 0
fi
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
# Bare runners ship no kubectl — same static provision the deploy
# step uses.
command -v kubectl >/dev/null 2>&1 || {
@@ -555,218 +582,18 @@ jobs:
}
CFG=$(kubectl -n hanzo get secret registry-credentials -o jsonpath='{.data.\.dockerconfigjson}' 2>/dev/null | base64 -d || true)
if [ -z "$CFG" ]; then
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0
fi
USERPASS=$(echo "$CFG" | jq -r '.auths["registry.hanzo.ai"].auth // empty' | base64 -d)
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
echo "::add-mask::${USERPASS#*:}"
# Best-effort: login failure → skip mirror, never fail the run (see above).
if echo "${USERPASS#*:}" | docker login oci.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
if echo "${USERPASS#*:}" | docker login registry.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
fi
- name: Build & push images (per hanzo.yml)
if: inputs.mode != 'delegate'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
# image lane lives in its own release.yml, or a pure library) skip the
# build step entirely instead of exploding on a null .images.
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
fi
# Build-time private cross-org Go module read (the buildx `gh_token`
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
# working). Keeps image builds green when the KMS deploy-cred fetch is
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
# No-op for public-only builds when both are empty. Exported so the
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
VER="${REL#v}" # v-stripped alias: 1.26.19
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
# clean (no trailing dash). Build + deploy must agree on this shape.
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
# platforms: [linux/amd64, linux/arm64]
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
# $TARGETARCH cross-compile natively (fast, no emulation). For true
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
# ---- Refuse a Go builder 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 — it
# dies mid-build with "go.mod requires go >= X (running Y;
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
# instance, and it is invisible before the image build: every local
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
# just downloads what the module asks for. So the break is
# introduced by editing go.mod — a file with nothing to do with
# Docker — and found by a red release build.
#
# It is not one repo's problem either: a sweep of every Dockerfile
# across the orgs found 54 below their own go.mod in 23 repos, and
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
# fixes today; this line is what makes the 55th impossible.
#
# Checked here, before any build work, so the run fails in seconds
# rather than after binfmt, a version derivation and a layer cache.
"$CI_HOME/bin/gover" "$df" "$ctx"
# ---- The semver this build publishes ----------------------------
# A branch build used to publish sha-<short7> and nothing else, so an
# image only earned a version when a human remembered to cut a git
# tag. That is the whole reason 14 of the fleet's 117 pins named a
# commit instead of a release: not drift, just the only tag CI ever
# offered them. Every build now publishes a version too, and the
# sha- tag stays beside it for forensics.
#
# The number is DERIVED, never typed, by bin/imgver — the ONE
# implementation, shared with the hand-rolled
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
# this workflow (see .github/actions/imgver). Written twice it would be
# right twice and then wrong once, which is exactly how `sha-<short>`
# became the only tag those repos ever published. Read that script for
# the rule; it is monotonic against the repo's manifest AND the
# registry, so one tag can never cover two digests.
ver=""
if [ "$IS_TAG" = 1 ]; then
# Release build: the git tag IS the version. Identity in, identity
# out — deriving here would publish a number nobody asked for.
:
else
# An explicit `version:` on the image entry wins over the repo-wide
# one; both are optional, and imgver falls back to the manifest.
iv=$(echo "$img"|jq -r '.version // ""')
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
fi
if [ "$plats" = "linux/amd64" ]; then
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
else
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
fi
# Release (tag) build. The git tag IS the release name, so publish it
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
# stripping the v is why releases were finished by hand-`crane copy`ing
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
# 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. The old
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
if [ "$IS_TAG" = 1 ]; then
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
else
# The tag universe is meant to PIN. It goes on every build, not
# only the ones somebody remembered to tag by hand.
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
fi
echo "::group::build $name → $repo (${sfx}) [$plats]"
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
# pinned base image tag) + `build_secrets` (KMS values the KMS step
# exported into the env above). Empty when a repo declares neither, so
# the buildx line is unchanged for every existing repo.
BUILD_ARGS=""
while IFS= read -r kv; do [ -n "$kv" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $kv"; done \
< <(echo "$img" | jq -r '(.args // {}) | to_entries[] | "\(.key)=\(.value)"')
for bs in $(echo "$img" | jq -r '(.build_secrets // [])[]'); do
v=$(printenv "$bs" 2>/dev/null || true)
# Defense in depth: the KMS step above already refuses to continue
# with an unresolved build_secret, so reaching here empty means the
# two steps disagree. Stop rather than bake a blank credential.
[ -z "$v" ] && { echo "::error::build_secret $bs is unset at build time — refusing to bake an empty value"; exit 1; }
BUILD_ARGS="$BUILD_ARGS --build-arg $bs=$v"
done
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
# purpose. The --label sets it from outside for any Dockerfile; the
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
# and `unknown` is exactly what every image built without this said).
#
# An unlabelled image cannot be traced to source, and that stops being
# cosmetic the moment two builders race for one tag: with one image
# labelled and the other reading `unknown`, "which of these is the
# release" had no answer short of diffing layers — while the wrong one
# was already pinned. A version is a receipt only if the image can
# name its own commit, so every image this fleet publishes now can.
#
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
# warns, never fails), so this is a no-op for every repo that has not
# asked for it.
PROV="--build-arg REVISION=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
# cleanly when absent (public-only builds unaffected).
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
# The semver is the tag the fleet PINS, so prove it exists before the
# run goes green — buildx can exit 0 while the manifest is not yet
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
# The digest is printed with it because universe pins repo:tag@digest
# and the kubelet honours the DIGEST: publishing both here is what
# lets the two move together instead of being looked up separately
# and drifting apart.
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
for i in 1 2 3 4 5 6; do
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
[ -n "$dig" ] && break
sleep 5
done
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
# oci.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
# is migrating to pull from ours. A skip here is now a WARNING, not
# a notice: an image that never reaches our registry is the reason
# a deploy still depends on GitHub, and that should be visible in
# the run, not buried.
if [ "${MIRROR_OK:-}" = "1" ]; then
# crane, not buildx imagetools: the IAM token realm doesn't answer
# buildx's multi-scope token request (spec gap, tracked).
command -v crane >/dev/null 2>&1 || {
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
| tar -xz -C "$HOME/.local/bin" crane
export PATH="$HOME/.local/bin:$PATH"
}
mrepo="oci.hanzo.ai/${repo#*/}"
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
crane copy "$ref" "${mrepo}:${ref##*:}" \
|| echo "::warning::$ref did not reach oci.hanzo.ai (ghcr push unaffected)"
done
fi
echo "::endgroup::"
done
# The pins this run earned, ready to paste into universe. Tag and
# digest are printed TOGETHER because they are one fact: the failure
# this prevents is a new tag landing beside an old digest, which
# reports the new version on the pod and serves the old bytes.
if [ -s "$RUNNER_TEMP/pins.md" ]; then
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
fi
- name: Provision Go toolchain (go test gates on bare runners)
# hanzo.yml `test:` gates (e.g. `go vet ./...`, `go test ...`) run
# DIRECTLY on the runner, NOT inside a build container — but the stock
@@ -775,7 +602,7 @@ jobs:
# OWN Go version from go.mod so the toolchain matches the module exactly.
# Guarded to Go repos (go.mod present) so pure-JS/TS callers are
# unaffected; harmless if a future runner image bakes Go in.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
if: hashFiles('go.mod') != ''
uses: actions/setup-go@v5
with:
go-version-file: go.mod
@@ -789,7 +616,7 @@ jobs:
# fails (snapshot mirror rot: "no longer has a Release file"),
# repoint archive.ubuntu.com at the DO mirror — both sources.list and
# noble's deb822 ubuntu.sources — and retry once.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
if: hashFiles('go.mod') != ''
run: |
command -v gcc >/dev/null 2>&1 && exit 0
sudo apt-get update -qq || {
@@ -814,12 +641,12 @@ jobs:
# root-only hashFiles SKIPPED this step and the gate died at
# bash: line 3: pnpm: command not found (exit 127)
# after 49 ginkgo suites had already passed.
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
if: hashFiles('package.json', '**/package.json') != ''
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable corepack (pnpm/yarn shims for JS test gates)
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
if: hashFiles('package.json', '**/package.json') != ''
run: corepack enable
- name: Provision Rust toolchain (cargo test gates)
@@ -831,7 +658,7 @@ jobs:
# the exact failure this workflow refuses everywhere else.
# Guarded to Rust callers (Cargo.toml present); a no-op once a runner
# image bakes rustup in.
if: inputs.mode != 'delegate' && hashFiles('Cargo.toml') != ''
if: hashFiles('Cargo.toml') != ''
uses: dtolnay/rust-toolchain@stable
- name: Provision Java toolchain (client codegen)
@@ -1013,16 +840,72 @@ jobs:
# unset) — so `go vet` dies with `could not read Username for github.com`.
# Reuse the SAME token here for the runner's git (GIT_TOKEN, set by the
# KMS step above; GH_PAT fallback). No-op when neither is present.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
#
# AND OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. A module path is a NAME,
# not an address: `github.com/<owner>/x` is what the package is called, and
# which host git dials for it is a separate decision. On a forge runner
# that decision is the forge — the forge is where the code canonically
# lives, and github.com is fed by a mirror that trails it, sometimes by
# hours and sometimes not at all.
#
# Measured, on the first run in which this step's gate had ever actually
# executed: hanzoai/cloud's go.mod carries `github.com/hanzoai/dashscopego
# v0.6.0` as an indirect dependency. git.hanzo.ai serves it; github.com
# answers `Repository not found`. So `go vet` died before a single test
# ran, on a module that was never missing. Its own caller already knew the
# answer — cloud's `containment` and `reach` jobs each make this exact
# substitution, with a comment about the nine consecutive releases an
# ACL drift on ONE sibling module once blocked — but the gate that runs
# its tests reached the modules through a different door.
#
# go.sum is what makes this safe rather than merely convenient: the forge
# serves the same objects, so the fetched zip hashes to the h1: line
# already committed. A forge serving different bytes fails the build
# loudly instead of shipping them.
#
# Longest prefix wins in git, so the owner-scoped forge rule outranks the
# bare github.com rule beneath it and every OTHER module still resolves
# from github.com. Both rules are optional and independent: no FORGE_TOKEN
# (or a github.com-hosted run, where the server IS github.com) leaves the
# old behaviour exactly as it was.
#
# SPLIT THE SERVER, do not strip a scheme you assumed. GITHUB_SERVER_URL
# on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's
# in-cluster address, plain http — and a `${SERVER#https://}` that only
# knows the https spelling passes it through whole, producing
# `https://http://hanzo-git.hanzo.svc/...` and a git that resolves the host
# `http`. Take the scheme and the host as the two values they are, and
# compare the HOST against github.com so the guard cannot be fooled by a
# spelling either.
if: hashFiles('go.mod') != ''
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
OWNER: ${{ github.repository_owner }}
run: |
set -uo pipefail
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -z "$TOKEN" ]; then echo "no git token — public modules only"; exit 0; fi
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
wrote=0
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
"https://github.com/${OWNER}/"
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
wrote=1
fi
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -n "$TOKEN" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
echo "github.com modules authenticated"
wrote=1
fi
[ "$wrote" = 1 ] || { echo "no git credential — public modules only"; exit 0; }
{ echo "GIT_CONFIG_GLOBAL=$CFG"; echo "GIT_CONFIG_NOSYSTEM=1"; } >> "$GITHUB_ENV"
echo "runner git authenticated for private Go modules"
@@ -1045,7 +928,7 @@ jobs:
# it ran nothing AND nothing shows as having run. A gate that is not a test
# gate — vet, lint, a build, a codegen-freshness check — says neither and is
# untouched. To silence it legitimately, make the gate run a test.
if: inputs.mode != 'delegate' && inputs.tests != 'false'
if: inputs.tests != 'false'
run: |
set -euo pipefail
NOTHING='\[no test files\]|\[no tests to run\]|no tests ran|collected 0 items|No tests found|no test specified|running 0 tests|Tests:[[:space:]]+0 total|(^|[^0-9])0 (passing|passed)'
@@ -1074,6 +957,299 @@ jobs:
fi
done
- name: Delegate build to platform (mode=delegate)
# Delegate moves WHERE the image is built. It does not move whether the
# commit was gated — so this step sits AFTER `Test (per hanzo.yml)`, and
# every step the gate needs runs in both modes. It used to sit fourth,
# before any of them, and `mode: delegate` therefore skipped the gate
# along with the build: 20 steps skipped, 0 executed, green. "ci gates,
# platform builds" was the design and the ordering said otherwise.
#
# What it does: POST each image in hanzo.yml to platform.hanzo.ai's
# direct-enqueue webhook (`/v1/runner`). Platform creates a build_job
# row, launches an in-cluster BuildKit Job on its own pool, pushes to the
# registry, and — for a system service — patches the operator Service CR
# to roll it. Downstream is IDENTICAL to the platform webhook path, so a
# delegated build behaves exactly like a platform-native one. What this
# job skips is buildx, publish and deploy — not the gate.
if: inputs.mode == 'delegate'
env:
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
run: |
set -euo pipefail
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
fi
# `/v1/runner` has no way to carry a build_secret: its wire body accepts
# buildArgs, and platform resolves NO KMS name — its own secrets arrive
# as a KMSSecret CRD with a statically declared key list, which cannot
# serve a name discovered by reading a hanzo.yml at some SHA. A repo
# that declares build_secrets and delegates gets an image built without
# them: green run, empty credential, exactly the silence the KMS step
# refuses. Say so here rather than downstream, where this run shows no
# logs at all.
if [ -n "$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true)" ]; then
echo "::error::hanzo.yml declares build_secrets, which mode=delegate cannot deliver — platform builds with no KMS. Build on the runner (drop mode: delegate), or drop the build_secrets."; exit 1
fi
REPO="${{ github.repository }}"
SHA="${{ github.sha }}"
SHORT=$(echo "$SHA" | cut -c1-7)
REF="${{ github.ref }}"
BRANCH="${{ github.ref_name }}"
# One enqueue per (image, platform), mirroring the buildx tag shape the
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# Same Go-builder check the buildx lane runs. It belongs here too:
# delegating the build moves WHERE it runs, not whether the image
# can compile the module, and a mismatch enqueued to platform fails
# on a machine whose logs this run never shows. Cheap to check on
# the runner that already has the checkout; expensive to discover
# from the in-cluster builder.
"$CI_HOME/bin/gover" "$df" "$ctx"
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
arch="${plat##*/}"
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
body=$(jq -nc \
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
--arg ref "$REF" --arg branch "$BRANCH" \
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
echo "::group::delegate $name → $image"
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
cat /tmp/enqueue.out; echo
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
echo "::endgroup::"
done
done
- name: Build & push images (per hanzo.yml)
# AFTER the gate, deliberately. This step used to run before `test:`,
# so a repo whose gates FAILED still published an image: hanzoai/base
# f9ec7067 pushed sha-f9ec706-amd64 on a run whose tests then went red.
# Deploy and Publish site were skipped, so nothing shipped it — but the
# tag existed, and a tag that exists is something a human pins by hand.
# An artifact is a claim that the commit passed; it should not be
# possible to produce one from a commit that did not.
#
# Nothing here needs the toolchains above it, and Deploy still follows
# this step, so the only thing the move changes is that a red gate now
# produces no image at all.
if: inputs.mode != 'delegate'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
# image lane lives in its own release.yml, or a pure library) skip the
# build step entirely instead of exploding on a null .images.
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
fi
# Build-time private cross-org Go module read (the buildx `gh_token`
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
# working). Keeps image builds green when the KMS deploy-cred fetch is
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
# No-op for public-only builds when both are empty. Exported so the
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
VER="${REL#v}" # v-stripped alias: 1.26.19
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
# clean (no trailing dash). Build + deploy must agree on this shape.
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
# platforms: [linux/amd64, linux/arm64]
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
# $TARGETARCH cross-compile natively (fast, no emulation). For true
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
# ---- Refuse a Go builder 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 — it
# dies mid-build with "go.mod requires go >= X (running Y;
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
# instance, and it is invisible before the image build: every local
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
# just downloads what the module asks for. So the break is
# introduced by editing go.mod — a file with nothing to do with
# Docker — and found by a red release build.
#
# It is not one repo's problem either: a sweep of every Dockerfile
# across the orgs found 54 below their own go.mod in 23 repos, and
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
# fixes today; this line is what makes the 55th impossible.
#
# Checked here, before any build work, so the run fails in seconds
# rather than after binfmt, a version derivation and a layer cache.
"$CI_HOME/bin/gover" "$df" "$ctx"
# ---- The semver this build publishes ----------------------------
# A branch build used to publish sha-<short7> and nothing else, so an
# image only earned a version when a human remembered to cut a git
# tag. That is the whole reason 14 of the fleet's 117 pins named a
# commit instead of a release: not drift, just the only tag CI ever
# offered them. Every build now publishes a version too, and the
# sha- tag stays beside it for forensics.
#
# The number is DERIVED, never typed, by bin/imgver — the ONE
# implementation, shared with the hand-rolled
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
# this workflow (see .github/actions/imgver). Written twice it would be
# right twice and then wrong once, which is exactly how `sha-<short>`
# became the only tag those repos ever published. Read that script for
# the rule; it is monotonic against the repo's manifest AND the
# registry, so one tag can never cover two digests.
ver=""
if [ "$IS_TAG" = 1 ]; then
# Release build: the git tag IS the version. Identity in, identity
# out — deriving here would publish a number nobody asked for.
:
else
# An explicit `version:` on the image entry wins over the repo-wide
# one; both are optional, and imgver falls back to the manifest.
iv=$(echo "$img"|jq -r '.version // ""')
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
fi
if [ "$plats" = "linux/amd64" ]; then
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
else
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
fi
# Release (tag) build. The git tag IS the release name, so publish it
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
# stripping the v is why releases were finished by hand-`crane copy`ing
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
# 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. The old
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
if [ "$IS_TAG" = 1 ]; then
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
else
# The tag universe is meant to PIN. It goes on every build, not
# only the ones somebody remembered to tag by hand.
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
fi
echo "::group::build $name → $repo (${sfx}) [$plats]"
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
# pinned base image tag) + `build_secrets` (KMS values the KMS step
# exported into the env above). Empty when a repo declares neither, so
# the buildx line is unchanged for every existing repo.
BUILD_ARGS=""
while IFS= read -r kv; do [ -n "$kv" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $kv"; done \
< <(echo "$img" | jq -r '(.args // {}) | to_entries[] | "\(.key)=\(.value)"')
for bs in $(echo "$img" | jq -r '(.build_secrets // [])[]'); do
v=$(printenv "$bs" 2>/dev/null || true)
# Defense in depth: the KMS step above already refuses to continue
# with an unresolved build_secret, so reaching here empty means the
# two steps disagree. Stop rather than bake a blank credential.
[ -z "$v" ] && { echo "::error::build_secret $bs is unset at build time — refusing to bake an empty value"; exit 1; }
BUILD_ARGS="$BUILD_ARGS --build-arg $bs=$v"
done
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
# purpose. The --label sets it from outside for any Dockerfile; the
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
# and `unknown` is exactly what every image built without this said).
#
# An unlabelled image cannot be traced to source, and that stops being
# cosmetic the moment two builders race for one tag: with one image
# labelled and the other reading `unknown`, "which of these is the
# release" had no answer short of diffing layers — while the wrong one
# was already pinned. A version is a receipt only if the image can
# name its own commit, so every image this fleet publishes now can.
#
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
# warns, never fails), so this is a no-op for every repo that has not
# asked for it.
PROV="--build-arg REVISION=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
# cleanly when absent (public-only builds unaffected).
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
# The semver is the tag the fleet PINS, so prove it exists before the
# run goes green — buildx can exit 0 while the manifest is not yet
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
# The digest is printed with it because universe pins repo:tag@digest
# and the kubelet honours the DIGEST: publishing both here is what
# lets the two move together instead of being looked up separately
# and drifting apart.
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
for i in 1 2 3 4 5 6; do
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
[ -n "$dig" ] && break
sleep 5
done
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
# registry.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
# is migrating to pull from ours. A skip here is now a WARNING, not
# a notice: an image that never reaches our registry is the reason
# a deploy still depends on GitHub, and that should be visible in
# the run, not buried.
if [ "${MIRROR_OK:-}" = "1" ]; then
# crane, not buildx imagetools: the IAM token realm doesn't answer
# buildx's multi-scope token request (spec gap, tracked).
command -v crane >/dev/null 2>&1 || {
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
| tar -xz -C "$HOME/.local/bin" crane
export PATH="$HOME/.local/bin:$PATH"
}
# ONE mapping, stated once. The GHCR org and the registry org are
# not the same word — ghcr.io/hanzoai/x mirrors to
# registry.hanzo.ai/hanzo/x — and a second copy of this table is
# how a push path and a pin drift into naming different images.
rorg="${repo#*/}"; rname="${rorg#*/}"; rorg="${rorg%%/*}"
case "$rorg" in
hanzoai) rorg=hanzo ;;
zooai) rorg=zoo ;;
*) ;; # bootnode, lux-private, ... map to themselves
esac
mrepo="registry.hanzo.ai/$rorg/$rname"
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
crane copy "$ref" "${mrepo}:${ref##*:}" \
|| echo "::warning::$ref did not reach registry.hanzo.ai (ghcr push unaffected)"
done
fi
echo "::endgroup::"
done
# The pins this run earned, ready to paste into universe. Tag and
# digest are printed TOGETHER because they are one fact: the failure
# this prevents is a new tag landing beside an old digest, which
# reports the new version on the pod and serves the old bytes.
if [ -s "$RUNNER_TEMP/pins.md" ]; then
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
fi
- name: Client — commit the projection and cut the patch
# LAST, and after `test:` on purpose: this pushes a tag, the tag starts a
# publish, and a published client is the one artifact in this workflow
+463 -287
View File
@@ -29,14 +29,17 @@ on:
default: '["hanzo-build-linux-amd64"]'
mode:
description: >-
Build execution mode. `buildx` (default) runs the full buildx →
test → deploy pipeline ON the runner. `delegate` instead POSTs the
build to platform.hanzo.ai (`/v1/runner`) — platform builds
in-cluster with BuildKit and rolls the operator Service CR itself, so
the GitHub job finishes in seconds with no runner buildx. A repo opts
in by passing `with: { mode: delegate }`; everything else is unchanged.
Requires the `PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets:
inherit).
Where the IMAGE is built. `buildx` (default) builds it on this runner.
`delegate` POSTs it to platform.hanzo.ai (`/v1/runner`), which builds
in-cluster with BuildKit and rolls the operator Service CR itself.
A repo opts in with `with: { mode: delegate }` and needs the
`PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets: inherit).
THE GATE RUNS IN BOTH MODES, and that is the whole point of the input
being named for the build. `delegate` used to be evaluated fourth, ahead
of every toolchain and ahead of `test:`, so it skipped the gate along
with the build — 20 steps skipped, 0 executed, green. What a delegated
run skips is buildx, publish and deploy. It does not skip being tested.
type: string
default: buildx
tests:
@@ -73,7 +76,7 @@ on:
A string sidesteps it on both planes with identical meaning, and the
comparison is against 'false' rather than a truthiness test so that the
expression callers already write — hanzoai/cloud's
`tests: ${{ github.ref_type != 'tag' }}`, which renders the strings
`tests:` set from `github.ref_type != 'tag'`, which renders the strings
"true"/"false" — keeps working unchanged.
type: string
default: 'true'
@@ -104,6 +107,14 @@ permissions:
jobs:
cicd:
runs-on: ${{ fromJson(inputs.runner) }}
env:
# corepack fetches the pnpm/yarn TARBALL ITSELF from registry.npmjs.org,
# and it does not read .npmrc to do it — so 317 repos that pin
# `packageManager` reach out to npmjs on every install no matter how
# carefully their registry is configured. This is the one variable that
# closes that hole; without it "installs go through our registry" is true
# of the dependencies and false of the package manager fetching them.
COREPACK_NPM_REGISTRY: https://pkg.hanzo.ai
steps:
- uses: actions/checkout@v4
with:
@@ -214,64 +225,7 @@ jobs:
jq --version
yq --version
- name: Delegate build to platform (mode=delegate)
# The GHA-escape fast path: instead of running buildx on this runner, POST
# each image in hanzo.yml to platform.hanzo.ai's direct-enqueue webhook
# (`/v1/runner`). Platform creates a build_job row, launches an
# in-cluster BuildKit Job on its own pool, pushes to the registry, and —
# for a system service — patches the operator Service CR to roll it. The
# downstream is IDENTICAL to the platform GitHub-App webhook path (one
# build path, two front doors), so a delegated build behaves exactly like
# a platform-native one. This job then exits in seconds — no buildx, no
# KMS, no runner-side deploy.
if: inputs.mode == 'delegate'
env:
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
run: |
set -euo pipefail
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
fi
REPO="${{ github.repository }}"
SHA="${{ github.sha }}"
SHORT=$(echo "$SHA" | cut -c1-7)
REF="${{ github.ref }}"
BRANCH="${{ github.ref_name }}"
# One enqueue per (image, platform), mirroring the buildx tag shape the
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# Same Go-builder check the buildx lane runs. It belongs here too:
# delegating the build moves WHERE it runs, not whether the image
# can compile the module, and a mismatch enqueued to platform fails
# on a machine whose logs this run never shows. Cheap to check on
# the runner that already has the checkout; expensive to discover
# from the in-cluster builder.
"$CI_HOME/bin/gover" "$df" "$ctx"
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
arch="${plat##*/}"
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
body=$(jq -nc \
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
--arg ref "$REF" --arg branch "$BRANCH" \
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
echo "::group::delegate $name → $image"
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
cat /tmp/enqueue.out; echo
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
echo "::endgroup::"
done
done
- name: Authenticated git for go modules (rate-limit + any private repo)
if: inputs.mode != 'delegate'
# luxfi/hanzoai/zooai Go modules are PUBLIC, so `go` resolves them through
# the default public proxy (proxy.golang.org) + checksum db (sum.golang.org)
# — canonical, IMMUTABLE hashes that a force-moved tag can no longer break.
@@ -327,7 +281,6 @@ jobs:
- name: Fetch deploy credentials from KMS
id: kms
if: inputs.mode != 'delegate'
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
@@ -367,6 +320,15 @@ jobs:
if [ -z "$ORG" ]; then
case "${{ github.repository_owner }}" in
hanzoai) ORG=hanzo ;;
# hanzo-inc is the same TENANT as hanzoai — pay, commerce and the
# rest of the company surfaces bill and seal to KMS org `hanzo`.
# Without this line ORG fell through to the literal owner,
# "hanzo-inc", which is not a KMS org. That still LOOKED fine,
# because the read below takes its org from the TOKEN, not the URL
# — so the only things that were wrong were the fallback URL and
# every error message, which named an org that does not exist and
# sent the reader looking for a tenant instead of a credential.
hanzo-inc) ORG=hanzo ;;
luxfi) ORG=lux ;;
zooai) ORG=zoo ;;
*) ORG="${{ github.repository_owner }}" ;;
@@ -383,6 +345,40 @@ jobs:
# how a stale key survived: every failure looked like "not in KMS", and
# "not in KMS" was survivable.
DECLARED="$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true)"
# A build_secret becomes `--build-arg NAME=value` below, and a build-arg
# is IN THE PUBLISHED IMAGE — `docker history` prints it to anyone who
# can pull. So `build_secrets` can only ever carry values that are
# public on purpose: a Vite/Next SPA has no server to read an env from,
# so its ingest key must be inlined at build, and inlining it is what
# publishing it means. Every other value must not be here at all.
#
# Nothing checked that. The one repo whose value is genuinely
# publishable (hanzoai/ui, a `pk-…` ingest key) asserted the `pk-`
# prefix INSIDE ITS OWN Dockerfile — so the check existed once, for one
# image, and any repo that added a real credential got no check at all.
# Per-repo vigilance is not a mechanism; the assertion belongs here,
# where the build-arg is assembled, once, for every caller.
#
# INLINE, not "$CI_HOME/bin/publishable", and that is forced rather than
# chosen. The tools checkout and this workflow resolve from DIFFERENT
# places: the caller pins the workflow at a ref its forge resolves, while
# the tools step derives its ref from GITHUB_WORKFLOW_REF — which
# git.hanzo.ai does not set, so it falls back to `v1` and clones
# github.com. Measured on run 36473: `GITHUB_WORKFLOW_REF=<unset>`,
# `derived ref=v1`, `cloned https://github.com/hanzoai/ci@v1`. A step
# that calls a NEW file under $CI_HOME is therefore broken on every forge
# run until a tag moves on a host this repo is not even pinned to.
#
# The rule is also spelled in bin/publishable, which the `publishable`
# gate tests. Two spellings of one rule is the same shape as the two
# copies of this file, and it gets the same treatment: a gate refuses
# any difference between them. See `publishable-rule-is-one-rule`.
for bs in $DECLARED; do
case "$bs" in
PUBLISHABLE_*|PUBLIC_*|NEXT_PUBLIC_*|EXPO_PUBLIC_*|NUXT_PUBLIC_*|VITE_*|REACT_APP_*|*_PUBLISHABLE|*_PUBLIC) ;;
*) echo "::error::build_secret '$bs' does not declare itself publishable, and a build_secret is baked into the image as a --build-arg where \`docker history\` reveals it. Rename it (PUBLISHABLE_*, PUBLIC_*, NEXT_PUBLIC_*, VITE_*, REACT_APP_*) if the value is public on purpose; if it is a real credential it cannot be a build_secret at all."; exit 1 ;;
esac
done
required() { [ -n "$DECLARED" ]; }
if [ -z "${KMS_CLIENT_ID:-}" ] || [ -z "$ORG" ]; then
if required; then
@@ -417,10 +413,35 @@ jobs:
if [ -n "$GIT_TOKEN" ]; then echo "::add-mask::$GIT_TOKEN"; echo "GIT_TOKEN=$GIT_TOKEN" >> "$GITHUB_ENV"; fi
KUBECONFIG_B64=$(get KUBECONFIG)
if [ -n "$KUBECONFIG_B64" ]; then echo "$KUBECONFIG_B64" | base64 -d > "$RUNNER_TEMP/kubeconfig"; echo "kubeconfig=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_OUTPUT"; fi
# The REGISTRY credential, and it is fetched UNCONDITIONALLY because
# every repo that publishes an image needs it — which is most of them.
#
# It used to sit inside the `bucket:`/`site:` gate below, and that gate
# is about writing bytes to hanzoai/s3, a completely unrelated lane. So
# a repo declaring only `images:` — hanzoai/bot, and it is not alone —
# asked KMS for nothing and reached the docker login step with empty
# credentials. The build then failed at the push with no clue pointing
# here, and the obvious "fix" is to paste REGISTRY_USER/REGISTRY_PASSWORD
# into the forge's org secrets, which is exactly the CI-only copy the
# comment below warns about: a second thing to rotate, and the one
# nobody remembers. The contract is that the ONLY things living in the
# forge are KMS_CLIENT_ID/SECRET, so the fetch moves out of the gate
# rather than the credential moving into GitHub.
#
# Empty is harmless: nothing is exported when KMS has no such key, and
# the login step already falls back to the kubeconfig read for repos
# provisioned before these names existed.
for k in REGISTRY_USER REGISTRY_PASSWORD; do
v=$(get "$k")
if [ -n "$v" ]; then
[ "$k" = REGISTRY_PASSWORD ] && echo "::add-mask::$v"
echo "$k=$v" >> "$GITHUB_ENV"
fi
done
# Object-store credential, for the two lanes that write bytes to
# hanzoai/s3: a repo publishing `binaries:` to a bucket:, and a repo
# publishing a `site:` (whose build output has to land in the org's own
# storage space before /v1/sites can promote it). Same four names the
# storage space before /v1/sites can promote it). Same names the
# services read at run time, from the same org/path/env — a CI-only copy
# of a credential is a second thing to rotate and the one nobody
# remembers. One fetch serves both lanes; a repo declaring both does not
@@ -521,14 +542,20 @@ jobs:
echo "::notice::KMS GHCR token login failed — keeping the earlier login"
fi
- name: Native registry credential (oci.hanzo.ai)
- name: Native registry credential (registry.hanzo.ai)
if: inputs.mode != 'delegate'
env:
KUBECONFIG: ${{ steps.kms.outputs.kubeconfig }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
# KMS first (exported to GITHUB_ENV by the step above), GitHub secret
# second. `env:` here cannot see GITHUB_ENV from THIS step's own env
# block, so the KMS values are read out of the environment in `run:`.
GH_REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
GH_REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -uo pipefail
# REGISTRY_USER/PASSWORD may already be in the environment from KMS.
REGISTRY_USER="${REGISTRY_USER:-${GH_REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${GH_REGISTRY_PASSWORD:-}}"
# Direct credential first (repo/org secret — works on private repos,
# where the Free plan hides org secrets... including these; private
# repos set them at REPO level). KMS-kubeconfig read is the fallback.
@@ -537,14 +564,14 @@ jobs:
# cred) must never fail the run — the image still pushes to GHCR, the
# primary. Without this guard, bash -e aborts the step and SKIPS the
# build entirely (a registry hiccup takes the whole lane red).
if echo "$REGISTRY_PASSWORD" | docker login oci.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
if echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
fi
exit 0
fi
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
# Bare runners ship no kubectl — same static provision the deploy
# step uses.
command -v kubectl >/dev/null 2>&1 || {
@@ -555,218 +582,18 @@ jobs:
}
CFG=$(kubectl -n hanzo get secret registry-credentials -o jsonpath='{.data.\.dockerconfigjson}' 2>/dev/null | base64 -d || true)
if [ -z "$CFG" ]; then
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0
fi
USERPASS=$(echo "$CFG" | jq -r '.auths["registry.hanzo.ai"].auth // empty' | base64 -d)
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
echo "::add-mask::${USERPASS#*:}"
# Best-effort: login failure → skip mirror, never fail the run (see above).
if echo "${USERPASS#*:}" | docker login oci.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
if echo "${USERPASS#*:}" | docker login registry.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
else
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
fi
- name: Build & push images (per hanzo.yml)
if: inputs.mode != 'delegate'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
# image lane lives in its own release.yml, or a pure library) skip the
# build step entirely instead of exploding on a null .images.
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
fi
# Build-time private cross-org Go module read (the buildx `gh_token`
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
# working). Keeps image builds green when the KMS deploy-cred fetch is
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
# No-op for public-only builds when both are empty. Exported so the
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
VER="${REL#v}" # v-stripped alias: 1.26.19
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
# clean (no trailing dash). Build + deploy must agree on this shape.
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
# platforms: [linux/amd64, linux/arm64]
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
# $TARGETARCH cross-compile natively (fast, no emulation). For true
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
# ---- Refuse a Go builder 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 — it
# dies mid-build with "go.mod requires go >= X (running Y;
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
# instance, and it is invisible before the image build: every local
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
# just downloads what the module asks for. So the break is
# introduced by editing go.mod — a file with nothing to do with
# Docker — and found by a red release build.
#
# It is not one repo's problem either: a sweep of every Dockerfile
# across the orgs found 54 below their own go.mod in 23 repos, and
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
# fixes today; this line is what makes the 55th impossible.
#
# Checked here, before any build work, so the run fails in seconds
# rather than after binfmt, a version derivation and a layer cache.
"$CI_HOME/bin/gover" "$df" "$ctx"
# ---- The semver this build publishes ----------------------------
# A branch build used to publish sha-<short7> and nothing else, so an
# image only earned a version when a human remembered to cut a git
# tag. That is the whole reason 14 of the fleet's 117 pins named a
# commit instead of a release: not drift, just the only tag CI ever
# offered them. Every build now publishes a version too, and the
# sha- tag stays beside it for forensics.
#
# The number is DERIVED, never typed, by bin/imgver — the ONE
# implementation, shared with the hand-rolled
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
# this workflow (see .github/actions/imgver). Written twice it would be
# right twice and then wrong once, which is exactly how `sha-<short>`
# became the only tag those repos ever published. Read that script for
# the rule; it is monotonic against the repo's manifest AND the
# registry, so one tag can never cover two digests.
ver=""
if [ "$IS_TAG" = 1 ]; then
# Release build: the git tag IS the version. Identity in, identity
# out — deriving here would publish a number nobody asked for.
:
else
# An explicit `version:` on the image entry wins over the repo-wide
# one; both are optional, and imgver falls back to the manifest.
iv=$(echo "$img"|jq -r '.version // ""')
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
fi
if [ "$plats" = "linux/amd64" ]; then
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
else
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
fi
# Release (tag) build. The git tag IS the release name, so publish it
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
# stripping the v is why releases were finished by hand-`crane copy`ing
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
# 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. The old
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
if [ "$IS_TAG" = 1 ]; then
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
else
# The tag universe is meant to PIN. It goes on every build, not
# only the ones somebody remembered to tag by hand.
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
fi
echo "::group::build $name → $repo (${sfx}) [$plats]"
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
# pinned base image tag) + `build_secrets` (KMS values the KMS step
# exported into the env above). Empty when a repo declares neither, so
# the buildx line is unchanged for every existing repo.
BUILD_ARGS=""
while IFS= read -r kv; do [ -n "$kv" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $kv"; done \
< <(echo "$img" | jq -r '(.args // {}) | to_entries[] | "\(.key)=\(.value)"')
for bs in $(echo "$img" | jq -r '(.build_secrets // [])[]'); do
v=$(printenv "$bs" 2>/dev/null || true)
# Defense in depth: the KMS step above already refuses to continue
# with an unresolved build_secret, so reaching here empty means the
# two steps disagree. Stop rather than bake a blank credential.
[ -z "$v" ] && { echo "::error::build_secret $bs is unset at build time — refusing to bake an empty value"; exit 1; }
BUILD_ARGS="$BUILD_ARGS --build-arg $bs=$v"
done
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
# purpose. The --label sets it from outside for any Dockerfile; the
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
# and `unknown` is exactly what every image built without this said).
#
# An unlabelled image cannot be traced to source, and that stops being
# cosmetic the moment two builders race for one tag: with one image
# labelled and the other reading `unknown`, "which of these is the
# release" had no answer short of diffing layers — while the wrong one
# was already pinned. A version is a receipt only if the image can
# name its own commit, so every image this fleet publishes now can.
#
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
# warns, never fails), so this is a no-op for every repo that has not
# asked for it.
PROV="--build-arg REVISION=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
# cleanly when absent (public-only builds unaffected).
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
# The semver is the tag the fleet PINS, so prove it exists before the
# run goes green — buildx can exit 0 while the manifest is not yet
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
# The digest is printed with it because universe pins repo:tag@digest
# and the kubelet honours the DIGEST: publishing both here is what
# lets the two move together instead of being looked up separately
# and drifting apart.
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
for i in 1 2 3 4 5 6; do
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
[ -n "$dig" ] && break
sleep 5
done
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
# oci.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
# is migrating to pull from ours. A skip here is now a WARNING, not
# a notice: an image that never reaches our registry is the reason
# a deploy still depends on GitHub, and that should be visible in
# the run, not buried.
if [ "${MIRROR_OK:-}" = "1" ]; then
# crane, not buildx imagetools: the IAM token realm doesn't answer
# buildx's multi-scope token request (spec gap, tracked).
command -v crane >/dev/null 2>&1 || {
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
| tar -xz -C "$HOME/.local/bin" crane
export PATH="$HOME/.local/bin:$PATH"
}
mrepo="oci.hanzo.ai/${repo#*/}"
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
crane copy "$ref" "${mrepo}:${ref##*:}" \
|| echo "::warning::$ref did not reach oci.hanzo.ai (ghcr push unaffected)"
done
fi
echo "::endgroup::"
done
# The pins this run earned, ready to paste into universe. Tag and
# digest are printed TOGETHER because they are one fact: the failure
# this prevents is a new tag landing beside an old digest, which
# reports the new version on the pod and serves the old bytes.
if [ -s "$RUNNER_TEMP/pins.md" ]; then
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
fi
- name: Provision Go toolchain (go test gates on bare runners)
# hanzo.yml `test:` gates (e.g. `go vet ./...`, `go test ...`) run
# DIRECTLY on the runner, NOT inside a build container — but the stock
@@ -775,7 +602,7 @@ jobs:
# OWN Go version from go.mod so the toolchain matches the module exactly.
# Guarded to Go repos (go.mod present) so pure-JS/TS callers are
# unaffected; harmless if a future runner image bakes Go in.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
if: hashFiles('go.mod') != ''
uses: actions/setup-go@v5
with:
go-version-file: go.mod
@@ -789,7 +616,7 @@ jobs:
# fails (snapshot mirror rot: "no longer has a Release file"),
# repoint archive.ubuntu.com at the DO mirror — both sources.list and
# noble's deb822 ubuntu.sources — and retry once.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
if: hashFiles('go.mod') != ''
run: |
command -v gcc >/dev/null 2>&1 && exit 0
sudo apt-get update -qq || {
@@ -814,12 +641,12 @@ jobs:
# root-only hashFiles SKIPPED this step and the gate died at
# bash: line 3: pnpm: command not found (exit 127)
# after 49 ginkgo suites had already passed.
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
if: hashFiles('package.json', '**/package.json') != ''
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable corepack (pnpm/yarn shims for JS test gates)
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
if: hashFiles('package.json', '**/package.json') != ''
run: corepack enable
- name: Provision Rust toolchain (cargo test gates)
@@ -831,7 +658,7 @@ jobs:
# the exact failure this workflow refuses everywhere else.
# Guarded to Rust callers (Cargo.toml present); a no-op once a runner
# image bakes rustup in.
if: inputs.mode != 'delegate' && hashFiles('Cargo.toml') != ''
if: hashFiles('Cargo.toml') != ''
uses: dtolnay/rust-toolchain@stable
- name: Provision Java toolchain (client codegen)
@@ -1013,16 +840,72 @@ jobs:
# unset) — so `go vet` dies with `could not read Username for github.com`.
# Reuse the SAME token here for the runner's git (GIT_TOKEN, set by the
# KMS step above; GH_PAT fallback). No-op when neither is present.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
#
# AND OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. A module path is a NAME,
# not an address: `github.com/<owner>/x` is what the package is called, and
# which host git dials for it is a separate decision. On a forge runner
# that decision is the forge — the forge is where the code canonically
# lives, and github.com is fed by a mirror that trails it, sometimes by
# hours and sometimes not at all.
#
# Measured, on the first run in which this step's gate had ever actually
# executed: hanzoai/cloud's go.mod carries `github.com/hanzoai/dashscopego
# v0.6.0` as an indirect dependency. git.hanzo.ai serves it; github.com
# answers `Repository not found`. So `go vet` died before a single test
# ran, on a module that was never missing. Its own caller already knew the
# answer — cloud's `containment` and `reach` jobs each make this exact
# substitution, with a comment about the nine consecutive releases an
# ACL drift on ONE sibling module once blocked — but the gate that runs
# its tests reached the modules through a different door.
#
# go.sum is what makes this safe rather than merely convenient: the forge
# serves the same objects, so the fetched zip hashes to the h1: line
# already committed. A forge serving different bytes fails the build
# loudly instead of shipping them.
#
# Longest prefix wins in git, so the owner-scoped forge rule outranks the
# bare github.com rule beneath it and every OTHER module still resolves
# from github.com. Both rules are optional and independent: no FORGE_TOKEN
# (or a github.com-hosted run, where the server IS github.com) leaves the
# old behaviour exactly as it was.
#
# SPLIT THE SERVER, do not strip a scheme you assumed. GITHUB_SERVER_URL
# on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's
# in-cluster address, plain http — and a `${SERVER#https://}` that only
# knows the https spelling passes it through whole, producing
# `https://http://hanzo-git.hanzo.svc/...` and a git that resolves the host
# `http`. Take the scheme and the host as the two values they are, and
# compare the HOST against github.com so the guard cannot be fooled by a
# spelling either.
if: hashFiles('go.mod') != ''
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
OWNER: ${{ github.repository_owner }}
run: |
set -uo pipefail
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -z "$TOKEN" ]; then echo "no git token — public modules only"; exit 0; fi
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
wrote=0
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
"https://github.com/${OWNER}/"
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
wrote=1
fi
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -n "$TOKEN" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
echo "github.com modules authenticated"
wrote=1
fi
[ "$wrote" = 1 ] || { echo "no git credential — public modules only"; exit 0; }
{ echo "GIT_CONFIG_GLOBAL=$CFG"; echo "GIT_CONFIG_NOSYSTEM=1"; } >> "$GITHUB_ENV"
echo "runner git authenticated for private Go modules"
@@ -1045,7 +928,7 @@ jobs:
# it ran nothing AND nothing shows as having run. A gate that is not a test
# gate — vet, lint, a build, a codegen-freshness check — says neither and is
# untouched. To silence it legitimately, make the gate run a test.
if: inputs.mode != 'delegate' && inputs.tests != 'false'
if: inputs.tests != 'false'
run: |
set -euo pipefail
NOTHING='\[no test files\]|\[no tests to run\]|no tests ran|collected 0 items|No tests found|no test specified|running 0 tests|Tests:[[:space:]]+0 total|(^|[^0-9])0 (passing|passed)'
@@ -1074,6 +957,299 @@ jobs:
fi
done
- name: Delegate build to platform (mode=delegate)
# Delegate moves WHERE the image is built. It does not move whether the
# commit was gated — so this step sits AFTER `Test (per hanzo.yml)`, and
# every step the gate needs runs in both modes. It used to sit fourth,
# before any of them, and `mode: delegate` therefore skipped the gate
# along with the build: 20 steps skipped, 0 executed, green. "ci gates,
# platform builds" was the design and the ordering said otherwise.
#
# What it does: POST each image in hanzo.yml to platform.hanzo.ai's
# direct-enqueue webhook (`/v1/runner`). Platform creates a build_job
# row, launches an in-cluster BuildKit Job on its own pool, pushes to the
# registry, and — for a system service — patches the operator Service CR
# to roll it. Downstream is IDENTICAL to the platform webhook path, so a
# delegated build behaves exactly like a platform-native one. What this
# job skips is buildx, publish and deploy — not the gate.
if: inputs.mode == 'delegate'
env:
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
run: |
set -euo pipefail
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
fi
# `/v1/runner` has no way to carry a build_secret: its wire body accepts
# buildArgs, and platform resolves NO KMS name — its own secrets arrive
# as a KMSSecret CRD with a statically declared key list, which cannot
# serve a name discovered by reading a hanzo.yml at some SHA. A repo
# that declares build_secrets and delegates gets an image built without
# them: green run, empty credential, exactly the silence the KMS step
# refuses. Say so here rather than downstream, where this run shows no
# logs at all.
if [ -n "$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true)" ]; then
echo "::error::hanzo.yml declares build_secrets, which mode=delegate cannot deliver — platform builds with no KMS. Build on the runner (drop mode: delegate), or drop the build_secrets."; exit 1
fi
REPO="${{ github.repository }}"
SHA="${{ github.sha }}"
SHORT=$(echo "$SHA" | cut -c1-7)
REF="${{ github.ref }}"
BRANCH="${{ github.ref_name }}"
# One enqueue per (image, platform), mirroring the buildx tag shape the
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# Same Go-builder check the buildx lane runs. It belongs here too:
# delegating the build moves WHERE it runs, not whether the image
# can compile the module, and a mismatch enqueued to platform fails
# on a machine whose logs this run never shows. Cheap to check on
# the runner that already has the checkout; expensive to discover
# from the in-cluster builder.
"$CI_HOME/bin/gover" "$df" "$ctx"
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
arch="${plat##*/}"
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
body=$(jq -nc \
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
--arg ref "$REF" --arg branch "$BRANCH" \
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
echo "::group::delegate $name → $image"
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
cat /tmp/enqueue.out; echo
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
echo "::endgroup::"
done
done
- name: Build & push images (per hanzo.yml)
# AFTER the gate, deliberately. This step used to run before `test:`,
# so a repo whose gates FAILED still published an image: hanzoai/base
# f9ec7067 pushed sha-f9ec706-amd64 on a run whose tests then went red.
# Deploy and Publish site were skipped, so nothing shipped it — but the
# tag existed, and a tag that exists is something a human pins by hand.
# An artifact is a claim that the commit passed; it should not be
# possible to produce one from a commit that did not.
#
# Nothing here needs the toolchains above it, and Deploy still follows
# this step, so the only thing the move changes is that a red gate now
# produces no image at all.
if: inputs.mode != 'delegate'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
# image lane lives in its own release.yml, or a pure library) skip the
# build step entirely instead of exploding on a null .images.
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
fi
# Build-time private cross-org Go module read (the buildx `gh_token`
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
# working). Keeps image builds green when the KMS deploy-cred fetch is
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
# No-op for public-only builds when both are empty. Exported so the
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
VER="${REL#v}" # v-stripped alias: 1.26.19
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
# clean (no trailing dash). Build + deploy must agree on this shape.
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
# platforms: [linux/amd64, linux/arm64]
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
# $TARGETARCH cross-compile natively (fast, no emulation). For true
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
# ---- Refuse a Go builder 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 — it
# dies mid-build with "go.mod requires go >= X (running Y;
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
# instance, and it is invisible before the image build: every local
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
# just downloads what the module asks for. So the break is
# introduced by editing go.mod — a file with nothing to do with
# Docker — and found by a red release build.
#
# It is not one repo's problem either: a sweep of every Dockerfile
# across the orgs found 54 below their own go.mod in 23 repos, and
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
# fixes today; this line is what makes the 55th impossible.
#
# Checked here, before any build work, so the run fails in seconds
# rather than after binfmt, a version derivation and a layer cache.
"$CI_HOME/bin/gover" "$df" "$ctx"
# ---- The semver this build publishes ----------------------------
# A branch build used to publish sha-<short7> and nothing else, so an
# image only earned a version when a human remembered to cut a git
# tag. That is the whole reason 14 of the fleet's 117 pins named a
# commit instead of a release: not drift, just the only tag CI ever
# offered them. Every build now publishes a version too, and the
# sha- tag stays beside it for forensics.
#
# The number is DERIVED, never typed, by bin/imgver — the ONE
# implementation, shared with the hand-rolled
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
# this workflow (see .github/actions/imgver). Written twice it would be
# right twice and then wrong once, which is exactly how `sha-<short>`
# became the only tag those repos ever published. Read that script for
# the rule; it is monotonic against the repo's manifest AND the
# registry, so one tag can never cover two digests.
ver=""
if [ "$IS_TAG" = 1 ]; then
# Release build: the git tag IS the version. Identity in, identity
# out — deriving here would publish a number nobody asked for.
:
else
# An explicit `version:` on the image entry wins over the repo-wide
# one; both are optional, and imgver falls back to the manifest.
iv=$(echo "$img"|jq -r '.version // ""')
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
fi
if [ "$plats" = "linux/amd64" ]; then
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
else
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
fi
# Release (tag) build. The git tag IS the release name, so publish it
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
# stripping the v is why releases were finished by hand-`crane copy`ing
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
# 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. The old
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
if [ "$IS_TAG" = 1 ]; then
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
else
# The tag universe is meant to PIN. It goes on every build, not
# only the ones somebody remembered to tag by hand.
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
fi
echo "::group::build $name → $repo (${sfx}) [$plats]"
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
# pinned base image tag) + `build_secrets` (KMS values the KMS step
# exported into the env above). Empty when a repo declares neither, so
# the buildx line is unchanged for every existing repo.
BUILD_ARGS=""
while IFS= read -r kv; do [ -n "$kv" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $kv"; done \
< <(echo "$img" | jq -r '(.args // {}) | to_entries[] | "\(.key)=\(.value)"')
for bs in $(echo "$img" | jq -r '(.build_secrets // [])[]'); do
v=$(printenv "$bs" 2>/dev/null || true)
# Defense in depth: the KMS step above already refuses to continue
# with an unresolved build_secret, so reaching here empty means the
# two steps disagree. Stop rather than bake a blank credential.
[ -z "$v" ] && { echo "::error::build_secret $bs is unset at build time — refusing to bake an empty value"; exit 1; }
BUILD_ARGS="$BUILD_ARGS --build-arg $bs=$v"
done
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
# purpose. The --label sets it from outside for any Dockerfile; the
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
# and `unknown` is exactly what every image built without this said).
#
# An unlabelled image cannot be traced to source, and that stops being
# cosmetic the moment two builders race for one tag: with one image
# labelled and the other reading `unknown`, "which of these is the
# release" had no answer short of diffing layers — while the wrong one
# was already pinned. A version is a receipt only if the image can
# name its own commit, so every image this fleet publishes now can.
#
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
# warns, never fails), so this is a no-op for every repo that has not
# asked for it.
PROV="--build-arg REVISION=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
# cleanly when absent (public-only builds unaffected).
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
# The semver is the tag the fleet PINS, so prove it exists before the
# run goes green — buildx can exit 0 while the manifest is not yet
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
# The digest is printed with it because universe pins repo:tag@digest
# and the kubelet honours the DIGEST: publishing both here is what
# lets the two move together instead of being looked up separately
# and drifting apart.
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
for i in 1 2 3 4 5 6; do
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
[ -n "$dig" ] && break
sleep 5
done
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
# registry.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
# is migrating to pull from ours. A skip here is now a WARNING, not
# a notice: an image that never reaches our registry is the reason
# a deploy still depends on GitHub, and that should be visible in
# the run, not buried.
if [ "${MIRROR_OK:-}" = "1" ]; then
# crane, not buildx imagetools: the IAM token realm doesn't answer
# buildx's multi-scope token request (spec gap, tracked).
command -v crane >/dev/null 2>&1 || {
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
| tar -xz -C "$HOME/.local/bin" crane
export PATH="$HOME/.local/bin:$PATH"
}
# ONE mapping, stated once. The GHCR org and the registry org are
# not the same word — ghcr.io/hanzoai/x mirrors to
# registry.hanzo.ai/hanzo/x — and a second copy of this table is
# how a push path and a pin drift into naming different images.
rorg="${repo#*/}"; rname="${rorg#*/}"; rorg="${rorg%%/*}"
case "$rorg" in
hanzoai) rorg=hanzo ;;
zooai) rorg=zoo ;;
*) ;; # bootnode, lux-private, ... map to themselves
esac
mrepo="registry.hanzo.ai/$rorg/$rname"
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
crane copy "$ref" "${mrepo}:${ref##*:}" \
|| echo "::warning::$ref did not reach registry.hanzo.ai (ghcr push unaffected)"
done
fi
echo "::endgroup::"
done
# The pins this run earned, ready to paste into universe. Tag and
# digest are printed TOGETHER because they are one fact: the failure
# this prevents is a new tag landing beside an old digest, which
# reports the new version on the pod and serves the old bytes.
if [ -s "$RUNNER_TEMP/pins.md" ]; then
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
fi
- name: Client — commit the projection and cut the patch
# LAST, and after `test:` on purpose: this pushes a tag, the tag starts a
# publish, and a published client is the one artifact in this workflow
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# publishable — refuse a `build_secrets` name that does not declare itself public.
#
# Usage: publishable <hanzo.yml> (reads images[].build_secrets, rc=1 on refusal)
#
# WHY A NAME AND NOT A VALUE. A build_secret is handed to buildx as
# `--build-arg NAME=value`, and a build-arg is IN THE PUBLISHED IMAGE: `docker
# history` prints it to anyone who can pull. So the only value that may sit
# here is one that is public on purpose — a Vite/Next static export has no
# server to read an env from, so its ingest key must be inlined at build, and
# inlining it is what publishing it means.
#
# The value cannot be the test. At the moment ci reads hanzo.yml the value does
# not exist yet (KMS has not been called), and guessing secrecy from a string's
# shape is a heuristic that is wrong in both directions. The name is what a
# reviewer reads, it is in git, and it is decided by the person who knows the
# answer. So the name carries the assertion.
#
# The fleet had already started saying it this way — hanzoai/docs renamed
# EVENT_INGEST_KEY -> PUBLISHABLE_KEY, and hanzoai/world's four are VITE_*,
# which a bundler inlines by construction. This turns that convention into the
# mechanism. Before it, the ONE repo whose value is genuinely publishable
# (hanzoai/ui, a `pk-…` key) asserted the prefix inside its own Dockerfile, so
# the check existed once, for one image, and any repo adding a real credential
# got no check at all.
set -uo pipefail
f=${1:-hanzo.yml}
[ -r "$f" ] || exit 0 # no config, nothing declared, nothing to refuse
names=$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' "$f" 2>/dev/null) || exit 0
[ -n "$names" ] || exit 0
rc=0
for n in $names; do
case "$n" in
# Prefixes a bundler already treats as client-side, plus an explicit
# self-declaration for everything else. Anything outside this set has not
# claimed to be public, so it is not baked.
PUBLISHABLE_*|PUBLIC_*|NEXT_PUBLIC_*|EXPO_PUBLIC_*|NUXT_PUBLIC_*|VITE_*|REACT_APP_*|*_PUBLISHABLE|*_PUBLIC) ;;
*)
echo "::error::build_secret '$n' does not declare itself publishable, and a build_secret is baked into the image as a --build-arg where \`docker history\` reveals it. Rename it (PUBLISHABLE_*, PUBLIC_*, NEXT_PUBLIC_*, VITE_*, REACT_APP_*) if the value is public on purpose; if it is a real credential it cannot be a build_secret at all." >&2
rc=1 ;;
esac
done
exit $rc
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Tests for bin/publishable. Offline and deterministic: every case is a
# hanzo.yml written into a temp dir. Run: bash bin/publishable_test.sh
set -uo pipefail
cd "$(dirname "$0")/.."
PUB="$PWD/bin/publishable"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
fail=0
# t <name> <want-rc> <build_secrets yaml-inline list>
t() {
local name=$1 want=$2 list=$3
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
{ echo 'images:'; echo ' - name: app'; echo ' repo: ghcr.io/hanzoai/app'
[ -n "$list" ] && echo " build_secrets: $list"; } > "$d/hanzo.yml"
out=$(bash "$PUB" "$d/hanzo.yml" 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
}
echo "--- refused: a name that never claimed to be public ---"
# The live case. hanzoai/ui declares exactly this, and its value IS publishable
# — but nothing outside its own Dockerfile could know that.
t "EVENT_INGEST_KEY is refused" 1 '[EVENT_INGEST_KEY]'
t "a real credential is refused" 1 '[STRIPE_SECRET_KEY]'
t "a token is refused" 1 '[GITHUB_TOKEN]'
t "a password is refused" 1 '[DB_PASSWORD]'
t "a private key is refused" 1 '[SIGNING_PRIVATE_KEY]'
t "one bad name among good ones is refused" 1 '[VITE_GTM_ID, EVENT_INGEST_KEY]'
echo "--- allowed: the name declares it ---"
# These are the fleet's real declarations, verbatim.
t "PUBLISHABLE_KEY (hanzoai/docs)" 0 '[PUBLISHABLE_KEY]'
t "VITE_MAPBOX_TOKEN (hanzoai/world)" 0 '[VITE_MAPBOX_TOKEN]'
t "VITE_SENTRY_DSN (hanzoai/world)" 0 '[VITE_SENTRY_DSN]'
t "world's four together" 0 '[VITE_MAPBOX_TOKEN, VITE_SENTRY_DSN, VITE_ANALYTICS_WEBSITE_ID, VITE_GTM_ID]'
t "NEXT_PUBLIC_ prefix" 0 '[NEXT_PUBLIC_INGEST_KEY]'
t "REACT_APP_ prefix" 0 '[REACT_APP_MAP_KEY]'
t "EXPO_PUBLIC_ prefix" 0 '[EXPO_PUBLIC_API_KEY]'
t "NUXT_PUBLIC_ prefix" 0 '[NUXT_PUBLIC_API_KEY]'
t "PUBLIC_ prefix" 0 '[PUBLIC_ANALYTICS_ID]'
t "_PUBLISHABLE suffix" 0 '[STRIPE_PUBLISHABLE]'
t "_PUBLIC suffix" 0 '[ANALYTICS_ID_PUBLIC]'
echo "--- silent: nothing declared, nothing to say ---"
# 44 of the fleet's 47 repos are this case and must be byte-for-byte unchanged.
t "no build_secrets key at all" 0 ''
t "empty build_secrets list" 0 '[]'
echo "--- a missing file is not a refusal ---"
out=$(bash "$PUB" "$tmp/does-not-exist.yml" 2>&1); rc=$?
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "absent hanzo.yml is silent"
else printf 'FAIL %-56s rc=%s\n' "absent hanzo.yml is silent" "$rc"; fail=1; fi
echo "--- the refusal says what to do about it ---"
d="$tmp/msg"; mkdir -p "$d"
printf 'images:\n - name: app\n build_secrets: [EVENT_INGEST_KEY]\n' > "$d/hanzo.yml"
out=$(bash "$PUB" "$d/hanzo.yml" 2>&1)
for pat in "EVENT_INGEST_KEY" "docker history" "PUBLISHABLE_" "cannot be a build_secret"; do
if printf '%s' "$out" | grep -qF "$pat"; then printf 'ok %-56s\n' "message names '$pat'"
else printf 'FAIL %-56s\n got: %s\n' "message names '$pat'" "$out"; fail=1; fi
done
[ "$fail" = 0 ] && echo "PASS" || echo "FAIL"
exit $fail
Executable
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
# sitedeploy — publish a built static export to the Hanzo PaaS Sites plane.
# One implementation, every static site.
#
# sitedeploy <slug> <dir> # e.g. sitedeploy hanzo-ai out
#
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL: the same forty lines of enqueue →
# upload → complete were about to be pasted into hanzo.ai, hanzo.app, hips,
# computer and every static surface after them. Written N times it is right N-1
# times and then wrong once — which is exactly how `sha-<short>` became the only
# tag eleven repos ever published (see bin/imgver). The plane's contract lives
# here, once.
#
# THE THREE STEPS, and why the bytes never pass through the API:
#
# POST /v1/projects/<slug>/deploy -> 202 {id, bucket, prefix, upload}
# POST <upload.url> per file -> the bytes, straight to S3
# POST /v1/projects/<slug>/deployments/<id>/complete
#
# A real export is large — hanzo.ai is 128 MB across 8403 files — and cloud's
# BodyLimit is 16 MiB, so an artifact POST is refused by fasthttp BEFORE any
# handler runs, and it fails as an opaque 400 "Error when parsing request" that
# reads like a malformed payload rather than a size cap. The git source is the
# documented route for exactly this shape.
#
# NO STANDING S3 CREDENTIAL. The 202 carries `upload`: a presigned POST policy
# that is prefix-scoped (starts-with $key "<org>/<slug>/", enforced by S3 itself),
# short-lived (30 min) and size-bounded. That replaced handing every repo the
# bucket's own long-lived access key — one key for a bucket whose only tenant
# separation is the key prefix, so every repo holding it could overwrite EVERY
# org's site (cloud apps/projects/grant.go). Do not reintroduce SITES_S3_* here.
# HANZO_DEPLOY_TOKEN is the ONE credential this needs.
#
# DELETION IS THE SERVER'S. The grant authorizes writes only, so CI cannot remove
# a file; `keys` in the completion is the manifest cloud reconciles the prefix
# against, and it deletes what the build no longer produces. That is where
# `aws s3 sync --delete` went. It fails CLOSED on an empty manifest, and so does
# this script — a build that enumerated nothing has failed, and honouring it
# literally would delete the live site.
#
# ENV: HANZO_DEPLOY_TOKEN (required) the org-scoped `sk-` key; mint at POST /v1/keys
# HANZO_API (https://api.hanzo.ai)
# SITEDEPLOY_JOBS (24) parallel uploads
# SITEDEPLOY_COMMIT / SITEDEPLOY_BRANCH recorded on the deployment
# SITEDEPLOY_PLAN=1 print the manifest and exit; no network. The test seam.
set -euo pipefail
slug="${1:?usage: sitedeploy <slug> <dir>}"
dir="${2:?usage: sitedeploy <slug> <dir>}"
api="${HANZO_API:-https://api.hanzo.ai}"
jobs="${SITEDEPLOY_JOBS:-24}"
[ -d "$dir" ] || { echo "::error::$dir is not a directory — the build produced no export"; exit 1; }
# ---- content type -----------------------------------------------------------
# The presigned POST carries no Content-Type condition, so whatever CI sends is
# what the object stores — and what it stores is what the edge serves. Send
# nothing and every page is application/octet-stream, which a browser DOWNLOADS
# instead of rendering: a green deploy that serves an unusable site.
ctype() {
case "${1##*.}" in
html|htm) echo 'text/html; charset=utf-8' ;;
css) echo 'text/css; charset=utf-8' ;;
js|mjs) echo 'text/javascript; charset=utf-8' ;;
json) echo 'application/json; charset=utf-8' ;;
xml) echo 'application/xml; charset=utf-8' ;;
txt) echo 'text/plain; charset=utf-8' ;;
svg) echo 'image/svg+xml' ;;
png) echo 'image/png' ;;
jpg|jpeg) echo 'image/jpeg' ;;
gif) echo 'image/gif' ;;
webp) echo 'image/webp' ;;
avif) echo 'image/avif' ;;
ico) echo 'image/x-icon' ;;
woff) echo 'font/woff' ;;
woff2) echo 'font/woff2' ;;
ttf) echo 'font/ttf' ;;
otf) echo 'font/otf' ;;
wasm) echo 'application/wasm' ;;
pdf) echo 'application/pdf' ;;
webmanifest) echo 'application/manifest+json' ;;
map) echo 'application/json' ;;
*) echo 'application/octet-stream' ;;
esac
}
# ---- cache control ----------------------------------------------------------
# Mirrors cloud's apps/sites.CacheControlFor so a site cached the same whichever
# route its bytes took. bin/sitedeploy_test.sh pins these strings; if the server
# changes its policy the test is what catches the drift.
#
# A fingerprinted asset (Vite/Next/webpack emit app.4f3a9c21.js) is immutable: a
# new build changes the hash, so the old URL can be cached forever.
#
# The class is written `[._-]` with the dash LAST and the pattern held in a
# variable. Go's regexp spells the same set `[.\-_]`, and transcribing that
# literally into `[[ =~ ]]` is a silent defect twice over: a backslash is literal
# inside a POSIX bracket expression, so `\-_` reads as the RANGE \…_ and the shell
# rejects it as "invalid character range" — and because the `if` merely evaluates
# false, every fingerprinted asset quietly fell back to max-age=3600 instead of
# immutable. An unquoted inline pattern also has its own quoting hazards; the
# variable form is the one that is read as a regex rather than a glob.
fingerprint_re='[._-][0-9a-fA-F]{8,}\.[a-z0-9]+$'
cachectl() {
local k="$1" base="${1##*/}"
case "${k##*.}" in
html|htm) echo 'public, max-age=60, s-maxage=86400' ;;
js|mjs|css|woff|woff2|png|jpg|jpeg|gif|svg|webp|avif|ico|ttf|otf|wasm|data|pck|unityweb|mem)
if [[ "$base" =~ $fingerprint_re ]]; then
echo 'public, max-age=31536000, immutable'
else
echo 'public, max-age=3600'
fi ;;
*) echo 'public, max-age=3600' ;;
esac
}
# ---- the manifest -----------------------------------------------------------
# Paths RELATIVE to the export root, which is exactly what the completion's
# `keys` must carry (cloud reconciles `keep[rel]` against them).
#
# CNAME does not travel. It is a GitHub Pages artifact that means nothing to S3
# and would ship a stale hostname claim into the bucket.
manifest=$(cd "$dir" && find . -type f ! -name CNAME | sed 's|^\./||' | LC_ALL=C sort)
count=$(printf '%s' "$manifest" | grep -c . || true)
[ "$count" -gt 0 ] || { echo "::error::$dir contains no files — refusing to deploy an empty manifest"; exit 1; }
if [ -n "${SITEDEPLOY_PLAN:-}" ]; then
printf 'slug=%s dir=%s files=%s\n' "$slug" "$dir" "$count"
while IFS= read -r k; do [ -n "$k" ] && printf '%s\t%s\t%s\n' "$k" "$(ctype "$k")" "$(cachectl "$k")"; done <<< "$manifest"
exit 0
fi
: "${HANZO_DEPLOY_TOKEN:?HANZO_DEPLOY_TOKEN is unset. It is the ONE credential this needs; set it on the forge (git.hanzo.ai), which is what reads .hanzo/workflows — not on GitHub. Mint: POST /v1/keys {\"type\":\"secret\"}}"
# ---- 0. ensure the project exists (self-provision) --------------------------
# A brand-new slug has no project, and the enqueue below 404s on a missing one —
# which is why every never-before-deployed site failed its first run. Create it
# idempotently here instead: 200/201 the first time, 409 every time after, both
# success. So a new site needs no out-of-band `POST /v1/projects`; committing the
# workflow is enough. The repo is linked because the git-source enqueue requires
# a linked project (deployGit rejects an unlinked one); SITEDEPLOY_REPO overrides
# the origin for a forge checkout whose remote is not the canonical repo.
repo="${SITEDEPLOY_REPO:-$(git remote get-url origin 2>/dev/null || true)}"
pcode=$(curl -sS -o /tmp/sd-proj.json -w '%{http_code}' \
-X POST "$api/v1/projects" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg s "$slug" --arg u "$repo" --arg b "${SITEDEPLOY_BRANCH:-main}" \
'{slug:$s, name:$s} + (if $u=="" then {} else {repo:{url:$u, branch:$b}} end)')")
case "$pcode" in
200|201|409) : ;; # created now, or already there — either is the state we need
*) echo "::error::ensure project $slug returned HTTP $pcode"; head -c 400 /tmp/sd-proj.json; echo; exit 1 ;;
esac
# ---- 1. enqueue -------------------------------------------------------------
# 202 Accepted is the success code: the deployment is queued, not live. bucket
# and prefix come FROM cloud (sitePrefix(org, slug) is server-side) — never guess
# them, or the upload lands where nothing is served the moment an org or slug
# changes.
code=$(curl -sS -o /tmp/sd-enq.json -w '%{http_code}' \
-X POST "$api/v1/projects/$slug/deploy" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
-d "{\"source\":\"git\",\"commit\":\"${SITEDEPLOY_COMMIT:-}\",\"branch\":\"${SITEDEPLOY_BRANCH:-main}\"}")
if [ "$code" != "202" ]; then
echo "::error::enqueue $api/v1/projects/$slug/deploy returned HTTP $code"; head -c 600 /tmp/sd-enq.json; echo; exit 1
fi
dep=$(jq -r '.id' /tmp/sd-enq.json)
prefix=$(jq -r '.prefix' /tmp/sd-enq.json)
upload_url=$(jq -r '.upload.url // empty' /tmp/sd-enq.json)
if [ -z "$upload_url" ]; then
echo "::error::the 202 carried no upload grant, so there is no way to write the bytes."
echo " cloud mints one only when presigning is configured (S3_ADMIN_* on the cloud deployment)."
exit 1
fi
jq -c '{id,version,status,bucket,prefix}' /tmp/sd-enq.json
# A build that dies after this point would leave the deployment "queued" and the
# project stuck "building" forever. Report the failure so cloud records an honest
# terminal state instead of a lie by omission.
fail() {
curl -sS -X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
-d '{"status":"error","message":"CI upload failed"}' >/dev/null 2>&1 || true
echo "::error::marked deployment $dep as error"
}
trap 'fail' ERR
# ---- 2. the bytes -----------------------------------------------------------
# `file` goes LAST: S3 ignores every field after the file part, so a grant field
# trailing the body is silently dropped and the signature check fails.
#
# `key` is DROPPED from the grant's fields and re-sent per object. The grant
# carries key="<org>/<slug>/" — the starts-with PLACEHOLDER, not a destination —
# and forwarding it verbatim alongside the real key posts `key` twice, which S3
# answers 400 for every object. That is the whole of the first end-to-end run:
# 8403 files, 8403 400s. Everything else in the map (bucket, policy, x-amz-*) is
# covered by the signature and must travel untouched.
jq -r '.upload.fields | to_entries[] | select(.key != "key") | "-F\n\(.key)=\(.value)"' /tmp/sd-enq.json > /tmp/sd-fields
put() {
local rel="$1" args=() line
# A read loop, NOT `mapfile`: mapfile is bash 4+, and macOS ships bash 3.2, so
# on a dev box it fails as `command not found`, the array stays EMPTY, and every
# upload goes out with no policy or signature at all. S3 then rejects it for a
# malformed X-Amz-Credential — an error that points at the credential rather
# than at the array that never got built.
while IFS= read -r line; do args+=("$line"); done < /tmp/sd-fields
curl -sS --fail-with-body -o /dev/null \
-X POST "$UP_URL" \
-F "key=$PREFIX/$rel" "${args[@]}" \
-F "Content-Type=$(ctype "$rel")" -F "Cache-Control=$(cachectl "$rel")" \
-F "file=@$DIR/$rel" \
|| { echo "::error::upload failed: $rel"; return 1; }
}
export -f put ctype cachectl
export UP_URL="$upload_url" PREFIX="$prefix" DIR="$dir" fingerprint_re
echo "uploading $count files to s3://$(jq -r .bucket /tmp/sd-enq.json)/$prefix ($jobs parallel)"
printf '%s\n' "$manifest" | grep . | xargs -P "$jobs" -I{} bash -c 'put "$@"' _ {}
# ---- 3. flip it live --------------------------------------------------------
# `wc -c`, not `stat`: the size flag is spelled -f%z on BSD and -c%s on GNU, and a
# `stat -f%z || stat -c%s` fallback does not work — on Linux the first arm fails
# INSIDE the pipeline, awk still exits 0, and the `||` never fires, so the byte
# count silently reports 0. wc is the one spelling both agree on.
bytes=$(cd "$dir" && find . -type f ! -name CNAME -exec wc -c {} + | awk '$2!="total"{s+=$1} END{print s+0}')
jq -n --arg c "${SITEDEPLOY_COMMIT:-}" --argjson f "$count" --argjson b "${bytes:-0}" \
--args '{status:"live",commit:$c,files:$f,bytes:$b,keys:$ARGS.positional}' \
$(printf '%s\n' "$manifest" | grep .) > /tmp/sd-done.json
code=$(curl -sS -o /tmp/sd-resp.json -w '%{http_code}' \
-X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
--data-binary @/tmp/sd-done.json)
trap - ERR
if [ "$code" != "200" ]; then
echo "::error::complete returned HTTP $code"; head -c 600 /tmp/sd-resp.json; echo; fail; exit 1
fi
jq -c '{status,liveUrl,version,files,bytes}' /tmp/sd-resp.json
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Tests for bin/sitedeploy. Runs OFFLINE: SITEDEPLOY_PLAN=1 stops the script
# before the first network call and prints the manifest it would upload, so every
# case here is deterministic and needs no token, no bucket and no cluster.
# Run: bash bin/sitedeploy_test.sh
set -uo pipefail
cd "$(dirname "$0")/.."
SD="$PWD/bin/sitedeploy"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
fail=0
plan() { SITEDEPLOY_PLAN=1 bash "$SD" a-slug "$1" 2>&1; }
t() { # t <name> <got> <want>
if [ "$2" = "$3" ]; then printf 'ok %-56s -> %s\n' "$1" "$2"
else printf 'FAIL %-56s -> %s (want %s)\n' "$1" "$2" "$3"; fail=1; fi
}
# field <dir> <key> <col> — the ctype (2) or cachectl (3) column for one key
field() { plan "$1" | awk -F'\t' -v k="$2" -v c="$3" '$1==k{print $c}'; }
site="$tmp/site"; mkdir -p "$site/assets" "$site/nested/deep"
echo '<h1>hi</h1>' > "$site/index.html"
echo 'body{}' > "$site/assets/app.4f3a9c21.css"
echo 'x' > "$site/assets/plain.css"
echo 'y' > "$site/assets/chunk-AB12CD34.js"
echo '{}' > "$site/data.json"
echo 'z' > "$site/nested/deep/page.html"
echo 'hanzo.ai' > "$site/CNAME"
# --- the manifest ------------------------------------------------------------
# Keys are RELATIVE to the export root: cloud reconciles keep[rel] against them,
# so a leading ./ or an absolute path would match nothing and the completion
# would prune the entire live site.
t "keys are relative, no leading ./" "$(plan "$site" | awk -F'\t' 'NR>1&&$1~/^\.?\//{print "ABS"}' | head -1)" ""
t "nested paths keep their subdirs" "$(plan "$site" | awk -F'\t' '$1=="nested/deep/page.html"{print "yes"}')" "yes"
# CNAME is a GitHub Pages artifact: it means nothing to S3 and would ship a stale
# hostname claim into the bucket.
t "CNAME does not travel" "$(plan "$site" | awk -F'\t' '$1=="CNAME"{print "leaked"}')" ""
t "file count excludes CNAME" "$(plan "$site" | head -1 | grep -o 'files=[0-9]*')" "files=6"
# --- content type ------------------------------------------------------------
# The presigned POST carries no Content-Type condition, so what CI sends is what
# the object stores and what the edge serves. Send nothing and a browser
# DOWNLOADS every page instead of rendering it.
t "html" "$(field "$site" index.html 2)" "text/html; charset=utf-8"
t "css" "$(field "$site" assets/plain.css 2)" "text/css; charset=utf-8"
t "js" "$(field "$site" assets/chunk-AB12CD34.js 2)" "text/javascript; charset=utf-8"
t "json" "$(field "$site" data.json 2)" "application/json; charset=utf-8"
# --- cache control: mirrors cloud apps/sites.CacheControlFor -----------------
# These strings are the SERVER's policy, pinned here so the two cannot drift
# apart silently. If cloud changes CacheControlFor, this is what goes red.
t "html is short-lived, long at the edge" "$(field "$site" index.html 3)" "public, max-age=60, s-maxage=86400"
t "unfingerprinted asset is an hour" "$(field "$site" assets/plain.css 3)" "public, max-age=3600"
# The regression this pins: Go spells the class [.\-_], and transcribing that
# into [[ =~ ]] makes the shell reject it as an invalid character range. The `if`
# then merely evaluates false, so every hashed asset silently lost `immutable`.
t "fingerprinted .hash. is immutable" "$(field "$site" assets/app.4f3a9c21.css 3)" "public, max-age=31536000, immutable"
t "fingerprinted -HASH- is immutable" "$(field "$site" assets/chunk-AB12CD34.js 3)" "public, max-age=31536000, immutable"
# --- fail closed -------------------------------------------------------------
# reconcilePrefix deletes whatever the manifest omits, so an empty manifest is a
# request to delete the live site. A build that enumerated nothing has failed.
empty="$tmp/empty"; mkdir -p "$empty"
plan "$empty" >/dev/null 2>&1
t "empty export is refused" "$?" "1"
only_cname="$tmp/onlycname"; mkdir -p "$only_cname"; echo x > "$only_cname/CNAME"
plan "$only_cname" >/dev/null 2>&1
t "a dir holding only CNAME is empty too" "$?" "1"
plan "$tmp/does-not-exist" >/dev/null 2>&1
t "missing export dir is refused" "$?" "1"
# --- the credential ----------------------------------------------------------
# Not in PLAN mode (that is the offline seam), but a real run must refuse to
# start rather than enqueue a deployment it cannot complete.
out=$(HANZO_DEPLOY_TOKEN= bash "$SD" a-slug "$site" 2>&1); rc=$?
t "no token: exits non-zero" "$rc" "1"
t "no token: says which secret" "$(printf '%s' "$out" | grep -c HANZO_DEPLOY_TOKEN)" "1"
[ $fail -eq 0 ] && echo "PASS" || echo "FAIL"
exit $fail
+54
View File
@@ -59,6 +59,60 @@ test:
exit 1
fi
echo "OK: .github and .hanzo copies are one file ($(wc -l < .github/workflows/build.yml) lines)"
- name: gate-runs-before-delegate
# `mode: delegate` chooses WHERE the image is built. It must never choose
# WHETHER the commit was tested. It did: the delegate step was evaluated
# fourth, ahead of every toolchain and ahead of `test:`, and every other step
# carried `if: inputs.mode != 'delegate'` — so a delegated run skipped the
# gate along with the build and finished green in seconds having proven
# nothing. The seam was right and the ORDER was wrong, which is the kind of
# defect that reads as correct in every diff that touches one step at a time.
#
# Two assertions, because either one alone can be satisfied while the gate
# still does not run: the delegate POST must come AFTER the test gate, and
# the test gate must not be delegate-guarded.
run: |
set -e
f=.github/workflows/build.yml
t=$(grep -n '^ - name: Test (per hanzo.yml)$' "$f" | cut -d: -f1)
d=$(grep -n '^ - name: Delegate build to platform (mode=delegate)$' "$f" | cut -d: -f1)
[ -n "$t" ] && [ -n "$d" ] || { echo "::error::cannot find the test gate ($t) or the delegate step ($d) — a rename broke this assertion, fix the assertion"; exit 1; }
[ "$d" -gt "$t" ] || { echo "::error::the delegate step (line $d) runs BEFORE the test gate (line $t) — a delegated build would ship an ungated commit"; exit 1; }
if sed -n "${t}p;$((t+1)),$((t+80))p" "$f" | grep -m1 '^ if: ' | grep -q "mode != 'delegate'"; then
echo "::error::the test gate is guarded by \`inputs.mode != 'delegate'\` — delegate would skip it"; exit 1
fi
echo "OK: test gate at line $t, delegate POST at line $d, gate not delegate-guarded"
- name: publishable
# bin/publishable refuses a `build_secrets` name that has not declared itself
# public. That matters because a build_secret is baked in as a --build-arg,
# which `docker history` prints to anyone who can pull — so the key is a
# publishing decision wearing the word "secret". Until this, the only check
# in the fleet lived inside hanzoai/ui's own Dockerfile, for one image.
run: bash bin/publishable_test.sh
- name: publishable-rule-is-one-rule
# The rule is spelled twice on purpose, and this is what keeps it one rule.
#
# build.yml cannot call bin/publishable, because the tools checkout and the
# workflow resolve from DIFFERENT places: a caller pins the workflow at a ref
# its own forge resolves, while the tools step derives its ref from
# GITHUB_WORKFLOW_REF — which git.hanzo.ai does not set, so it falls back to
# `v1` and clones github.com. Measured on run 36473: `derived ref=v1`,
# `cloned https://github.com/hanzoai/ci@v1`. So a step calling a NEW file
# under $CI_HOME is broken on every forge run until a tag moves on a host the
# caller never named. The pipeline has to carry its own rule.
#
# Which leaves the pattern in two files, which is the same shape as the two
# copies of build.yml and gets the same treatment: demand they are identical
# and let the gate, not a reader, be the thing that notices.
run: |
set -e
a=$(grep -oE '^[[:space:]]*PUBLISHABLE_\*\|[^)]*\)' .github/workflows/build.yml | tr -d '[:space:]')
b=$(grep -oE '^[[:space:]]*PUBLISHABLE_\*\|[^)]*\)' bin/publishable | tr -d '[:space:]')
[ -n "$a" ] && [ -n "$b" ] || { echo "::error::publishable rule not found in build.yml ($a) or bin/publishable ($b) — an edit renamed it, fix this assertion"; exit 1; }
[ "$a" = "$b" ] || { echo "::error::the publishable rule differs between build.yml and bin/publishable — build.yml is what runs, bin/publishable is what is tested, so a difference means the tested rule is not the enforced one:
build.yml: $a
bin/publishable: $b"; exit 1; }
echo "OK: one publishable rule, two spellings — $a"
- 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