Compare commits

..
9 Commits
Author SHA1 Message Date
zeekay b047a7a569 build: the forge lane is the lane that runs
Hanzo CI/CD / cicd (push) Successful in 1m25s
CI/CD / gate (push) Successful in 1m26s
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
5 changed files with 511 additions and 180 deletions
+173 -90
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'
@@ -222,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.
@@ -335,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 }}
@@ -375,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 }}" ;;
@@ -391,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
@@ -425,28 +413,41 @@ 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
# ask KMS twice.
SITE_DECL="$(yq -r '.site | type' hanzo.yml 2>/dev/null || echo '!!null')"
if [ -n "$(yq -r '.bucket // ""' hanzo.yml 2>/dev/null || true)" ] || [ "$SITE_DECL" = '!!map' ]; then
# The registry credential comes from KMS like every other secret —
# the ONLY things that live in GitHub are KMS_CLIENT_ID/SECRET.
# Named here so a repo with no kubeconfig (most of them) can still
# reach registry.hanzo.ai; the kubeconfig read below stays as the
# fallback 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
for k in S3_ADMIN_ACCESS_KEY S3_ADMIN_SECRET_KEY; do
v=$(get "$k")
if [ -n "$v" ]; then echo "::add-mask::$v"; echo "$k=$v" >> "$GITHUB_ENV"; fi
@@ -601,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
@@ -615,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 || {
@@ -640,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)
@@ -657,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)
@@ -867,7 +868,16 @@ jobs:
# 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.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
#
# 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 }}
@@ -876,13 +886,14 @@ jobs:
set -uo pipefail
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
wrote=0
if [ -n "${FORGE_TOKEN:-}" ] && [ "$SERVER" != "https://github.com" ]; then
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x:${FORGE_TOKEN}@${SERVER#https://}/${OWNER}/".insteadOf \
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
"https://github.com/${OWNER}/"
echo "${OWNER}/* modules resolve from ${SERVER}"
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
wrote=1
fi
@@ -917,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)'
@@ -946,6 +957,78 @@ 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
+173 -90
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'
@@ -222,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.
@@ -335,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 }}
@@ -375,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 }}" ;;
@@ -391,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
@@ -425,28 +413,41 @@ 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
# ask KMS twice.
SITE_DECL="$(yq -r '.site | type' hanzo.yml 2>/dev/null || echo '!!null')"
if [ -n "$(yq -r '.bucket // ""' hanzo.yml 2>/dev/null || true)" ] || [ "$SITE_DECL" = '!!map' ]; then
# The registry credential comes from KMS like every other secret —
# the ONLY things that live in GitHub are KMS_CLIENT_ID/SECRET.
# Named here so a repo with no kubeconfig (most of them) can still
# reach registry.hanzo.ai; the kubeconfig read below stays as the
# fallback 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
for k in S3_ADMIN_ACCESS_KEY S3_ADMIN_SECRET_KEY; do
v=$(get "$k")
if [ -n "$v" ]; then echo "::add-mask::$v"; echo "$k=$v" >> "$GITHUB_ENV"; fi
@@ -601,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
@@ -615,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 || {
@@ -640,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)
@@ -657,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)
@@ -867,7 +868,16 @@ jobs:
# 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.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
#
# 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 }}
@@ -876,13 +886,14 @@ jobs:
set -uo pipefail
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
wrote=0
if [ -n "${FORGE_TOKEN:-}" ] && [ "$SERVER" != "https://github.com" ]; then
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x:${FORGE_TOKEN}@${SERVER#https://}/${OWNER}/".insteadOf \
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
"https://github.com/${OWNER}/"
echo "${OWNER}/* modules resolve from ${SERVER}"
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
wrote=1
fi
@@ -917,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)'
@@ -946,6 +957,78 @@ 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
+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
+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