Compare commits

...
Author SHA1 Message Date
hanzo-dev 401e415c01 fix(ci): make images: optional in hanzo.yml (lint-only / deploy-elsewhere repos)
The reusable hard-subscripted `hanzo.yml['images']` in the delegate, buildx, and
deploy steps, so a repo that ships no container image (e.g. a static site deployed
via its own Cloudflare Pages deploy.yml, importing this reusable only for the
`test:` lint gate) failed with a Python KeyError before any build ran.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Upgrade the naive 'git config --global' to the robust pattern proven in
hanzoai/iam: fresh per-job GIT_CONFIG_GLOBAL + GIT_CONFIG_NOSYSTEM=1, probe
from a neutral dir. A plain global config is overridden on shared arc
runners by stale ~/.gitconfig state and actions/checkout's persisted
extraheader. Still a no-op without GH_PAT.
2026-06-22 17:21:04 -07:00
3 changed files with 316 additions and 25 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="ci">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">ci</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Reusable CI/CD: build/test/deploy any repo from its hanzo.yml on arc</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+278 -25
View File
@@ -15,7 +15,19 @@ on:
the build, metered as build minutes). Bring-your-own: pass the labels
of your own self-hosted arc runners instead.
type: string
default: '["self-hosted","linux","amd64"]'
default: '["hanzo-build-linux-amd64"]'
mode:
description: >-
Build execution mode. `buildx` (default) runs the full buildx →
test → deploy pipeline ON the arc runner. `delegate` instead POSTs the
build to platform.hanzo.ai (`/v1/arcd/enqueue`) — 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).
type: string
default: buildx
permissions:
contents: read
@@ -27,48 +39,289 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Provision parse toolchain (jq + PyYAML)
# This reusable parses the caller's hanzo.yml with python3 + PyYAML and
# slices JSON with jq. The stock arc runner image
# (ghcr.io/actions/actions-runner:latest) is minimal and ships NEITHER,
# so provision them here. Guarded (a no-op the moment a runner image bakes
# them in) — this keeps the reusable self-contained: any org can import it
# onto a bare runner and it just works.
run: |
set -e
need=0
python3 -c 'import yaml' 2>/dev/null || need=1
command -v jq >/dev/null 2>&1 || need=1
if [ "$need" = 1 ]; then
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml jq
fi
python3 -c 'import yaml; print("PyYAML", yaml.__version__)'
jq --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/arcd/enqueue`). 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/arcd/enqueue' }}
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.
python3 -c "import yaml,json;print(json.dumps(yaml.safe_load(open('hanzo.yml')).get('images') or []))" | 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" // ""')
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.
# We deliberately do NOT set GOPRIVATE: that would route these public
# modules `direct` and bypass the sumdb, re-introducing the re-publish
# poisoning we just removed. This step only adds a GH_PAT git credential so
# the proxy's `direct` fallback (and any genuinely-private repo added later,
# via a narrow GOPRIVATE) authenticates instead of hitting the anon rate
# limit. No-op when GH_PAT is absent.
#
# Robustness (learned the hard way in hanzoai/iam): a plain
# `git config --global` is defeated on shared self-hosted arc runners by
# (a) a stale insteadOf/credential left in ~/.gitconfig by a prior run and
# (b) actions/checkout's persisted http.<github>.extraheader (the repo-
# scoped GITHUB_TOKEN) in the checked-out repo's local config. So use a
# FRESH per-job GIT_CONFIG_GLOBAL + GIT_CONFIG_NOSYSTEM=1 and verify from
# a NEUTRAL dir (matches where go clones modules — GOMODCACHE).
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
if [ -z "${GH_PAT:-}" ]; then
echo "GH_PAT not set — public proxy+sumdb handles everything"; exit 0
fi
CFG="$RUNNER_TEMP/gitconfig-private"; : > "$CFG"
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GIT_CONFIG_GLOBAL=$CFG"
echo "GIT_CONFIG_NOSYSTEM=1"
} >> "$GITHUB_ENV"
( cd "$RUNNER_TEMP" && GIT_CONFIG_GLOBAL="$CFG" GIT_CONFIG_NOSYSTEM=1 \
git ls-remote https://github.com/hanzoai/authz >/dev/null 2>&1 ) \
&& echo "git auth OK" \
|| echo "::warning::GH_PAT set but repo probe failed"
- name: Log in to GHCR (automatic workflow token)
if: inputs.mode != 'delegate'
# GHCR push needs no stored credential: GitHub injects a per-job
# GITHUB_TOKEN scoped to THIS repo, and `permissions: packages: write`
# (above) lets it push the repo's own package (e.g. zooai/node →
# ghcr.io/zooai/node, same org). First push creates the package
# repo-linked. No KMS, no PAT for the push path.
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- 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 }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_WORKSPACE: ${{ vars.KMS_WORKSPACE }}
KMS_ORG: ${{ vars.KMS_ORG }}
run: |
set -euo pipefail
TOKEN=$(curl -sf "$KMS_ENDPOINT/api/v1/auth/universal-auth/login" \
# This step is BEST-EFFORT (see below): GHCR push already works via the
# workflow token, and deploy creds are optional. GitHub wraps `run:` in
# `bash -eo pipefail`, so we MUST explicitly `set +e` — otherwise an
# unguarded curl (e.g. a KMS secret 404 at this org/path) aborts the
# step and fails the whole build. Keep -u/-o pipefail; drop -e.
set +e
set -uo pipefail
# Canonical luxfi/kms surface — /v1/kms (the Infisical /api/* surface was
# removed when KMS migrated to luxfi/kms, MPC-rooted). Auth = an IAM
# client_credentials JWT minted by the per-org `<org>-kms` application;
# secrets are org-scoped and fetched one at a time (no bulk /secrets/raw).
#
# This step is BEST-EFFORT: the automatic workflow token already
# authorizes GHCR push, so a repo with no cross-org private deps and no
# deploy can build without KMS. KMS provides only:
# GITHUB_TOKEN — a cross-org PAT for cloning OTHER orgs' private Go
# modules (luxfi/dex, luxfi/precompile from a zooai build), which the
# repo-scoped automatic token cannot read. Handed to buildx as the
# `gh_token` BuildKit secret.
# KUBECONFIG — cluster access for the deploy step.
ORG="$(python3 -c "import yaml;print((yaml.safe_load(open('hanzo.yml')).get('kms') or {}).get('org','') )" 2>/dev/null || true)"
: "${ORG:=${KMS_ORG:-}}"
# Systemic default: when neither hanzo.yml `kms.org` nor the KMS_ORG var
# is set, derive the KMS org from the GitHub owner. The org → KMS-org
# relation is a small, stable first-party fact; keeping it here (ONE
# place) is why every repo gets the canonical KMS deploy-cred path with
# zero per-repo config — before this, ORG was always empty and this whole
# step short-circuited, so no build ever fetched its private-dep git
# credential from KMS (it silently fell back to GH_PAT and failed on
# private hanzoai/cloud). hanzo.yml `kms.org` and KMS_ORG still override.
if [ -z "$ORG" ]; then
case "${{ github.repository_owner }}" in
hanzoai) ORG=hanzo ;;
luxfi) ORG=lux ;;
zooai) ORG=zoo ;;
*) ORG="${{ github.repository_owner }}" ;;
esac
fi
ENV="$(python3 -c "import yaml;print((yaml.safe_load(open('hanzo.yml')).get('kms') or {}).get('environment','prod'))" 2>/dev/null || echo prod)"
PATHQ="$(python3 -c "import yaml;print((yaml.safe_load(open('hanzo.yml')).get('kms') or {}).get('path','/deploy').strip('/'))" 2>/dev/null || echo deploy)"
if [ -z "${KMS_CLIENT_ID:-}" ] || [ -z "$ORG" ]; then
echo "::notice::KMS not configured (no KMS_CLIENT_ID or org) — skipping; GHCR push uses the workflow token"; exit 0
fi
TOKEN=$(curl -sf "$KMS_ENDPOINT/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r '.accessToken')
ENV="$(python3 -c "import yaml;print((yaml.safe_load(open('hanzo.yml')).get('kms') or {}).get('environment','prod'))")"
PATHQ="$(python3 -c "import yaml;print((yaml.safe_load(open('hanzo.yml')).get('kms') or {}).get('path','/deploy'))")"
SECRETS=$(curl -sf "$KMS_ENDPOINT/api/v3/secrets/raw?workspaceId=$KMS_WORKSPACE&secretPath=$PATHQ&environment=$ENV" \
-H "Authorization: Bearer $TOKEN")
get() { echo "$SECRETS" | jq -r ".secrets[]|select(.secretKey==\"$1\")|.secretValue // empty"; }
GHCR_USER=$(get GHCR_USER); GHCR_TOKEN=$(get GHCR_TOKEN); KUBECONFIG_B64=$(get KUBECONFIG)
: "${GHCR_USER:=${{ github.actor }}}"
if [ -z "$GHCR_TOKEN" ]; then echo "::error::GHCR_TOKEN not found in KMS"; exit 1; fi
echo "::add-mask::$GHCR_TOKEN"
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r '.accessToken // empty')
if [ -z "$TOKEN" ]; then echo "::warning::KMS login failed (org=$ORG client=$KMS_CLIENT_ID) — cross-org private deps & deploy unavailable"; exit 0; fi
get() { curl -sf "$KMS_ENDPOINT/v1/kms/orgs/$ORG/secrets/$PATHQ/$1?env=$ENV" -H "Authorization: Bearer $TOKEN" | jq -r '.secret.value // empty'; }
# Cross-org private Go module read token → buildx `gh_token` secret.
# GIT_TOKEN is not a reserved name, so it's safe in GITHUB_ENV.
GIT_TOKEN=$(get GITHUB_TOKEN)
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
- name: Build & push images (per hanzo.yml)
if: inputs.mode != 'delegate'
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# `images:` is OPTIONAL. A repo that ships no container image — e.g. a
# static site deployed via Cloudflare Pages (its own deploy.yml), which
# imports this reusable purely for the `test:` lint gate — omits the key
# entirely. `.get('images') or []` yields an empty list, the loop below
# runs zero times, and no cross-org GHCR push is attempted (so a repo in
# a different GitHub org never hits `denied: permission_denied`).
# 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)
VER="${{ github.ref_name }}"; VER="${VER#v}"
python3 -c "import yaml,json;print(json.dumps(yaml.safe_load(open('hanzo.yml'))['images']))" | jq -c '.[]' | while read -r img; do
python3 -c "import yaml,json;print(json.dumps(yaml.safe_load(open('hanzo.yml')).get('images') or []))" | 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)
sfx=$(echo "$img"|jq -r '."tag-suffix"')
TAGS="-t $repo:sha-${SHORT}-amd64-${sfx} -t $repo:${sfx}-latest"
[ "$IS_TAG" = 1 ] && TAGS="$TAGS -t $repo:${VER}-amd64-${sfx}"
echo "::group::build $name → $repo (${sfx})"
docker buildx build --platform linux/amd64 --push $TAGS -f "$df" "$ctx"
# 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(",")')
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"
[ "$IS_TAG" = 1 ] && TAGS="$TAGS -t $repo:${VER}-amd64${sfx:+-$sfx}"
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"
[ "$IS_TAG" = 1 ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
fi
echo "::group::build $name → $repo (${sfx}) [$plats]"
# 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" ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
echo "::endgroup::"
done
- 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
# arc runner image (ghcr.io/actions/actions-runner) ships no Go, so a Go
# gate dies with `go: command not found` (exit 127). Provision the repo's
# 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') != ''
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Provision C toolchain (cgo test gates)
# CGO_ENABLED=1 gates (e.g. go-sqlite3, which bundles the sqlite
# amalgamation) need a C compiler; the minimal arc runner ships none
# (`cgo: gcc not found`). Same guarded provision the parse-toolchain step
# above uses — a no-op when gcc is already present.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
run: command -v gcc >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq gcc; }
- name: Authenticate runner git for private Go modules (test gates)
# The Test step runs `go vet`/`go test` ON the runner (not in buildx), so
# `go` fetches private hanzoai/* modules (GOPRIVATE → direct) through the
# runner's git, which needs a credential. The IMAGE build authenticates
# via the KMS `gh_token` inside buildx (GIT_TOKEN); the earlier GH_PAT
# git-auth step is a no-op for repos that rely on that KMS token (GH_PAT
# 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') != ''
env:
GH_PAT: ${{ secrets.GH_PAT }}
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/"
{ echo "GIT_CONFIG_GLOBAL=$CFG"; echo "GIT_CONFIG_NOSYSTEM=1"; } >> "$GITHUB_ENV"
echo "runner git authenticated for private Go modules"
- name: Test (per hanzo.yml)
if: inputs.mode != 'delegate'
run: |
set -euo pipefail
python3 -c "import yaml,json;print(json.dumps(yaml.safe_load(open('hanzo.yml')).get('test') or []))" | jq -c '.[]' | while read -r t; do
@@ -77,7 +330,7 @@ jobs:
done
- name: Deploy (per hanzo.yml)
if: github.event_name != 'pull_request' && steps.kms.outputs.kubeconfig != ''
if: inputs.mode != 'delegate' && github.event_name != 'pull_request' && steps.kms.outputs.kubeconfig != ''
env:
KUBECONFIG: ${{ steps.kms.outputs.kubeconfig }}
run: |
@@ -90,12 +343,12 @@ jobs:
fi
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
NS=$(python3 -c "import yaml;print(yaml.safe_load(open('hanzo.yml'))['deploy']['namespace'])")
python3 -c "import yaml,json;print(json.dumps(yaml.safe_load(open('hanzo.yml'))['images']))" > /tmp/imgs.json
python3 -c "import yaml,json;print(json.dumps(yaml.safe_load(open('hanzo.yml')).get('images') or []))" > /tmp/imgs.json
python3 -c "import yaml,json;print(json.dumps(yaml.safe_load(open('hanzo.yml'))['deploy']['services']))" | jq -c '.[]' | while read -r s; do
svc=$(echo "$s"|jq -r .name); imgname=$(echo "$s"|jq -r .image)
repo=$(jq -r --arg n "$imgname" '.[]|select(.name==$n)|.repo' /tmp/imgs.json)
sfx=$(jq -r --arg n "$imgname" '.[]|select(.name==$n)|."tag-suffix"' /tmp/imgs.json)
ref="$repo:sha-${SHORT}-amd64-${sfx}"
sfx=$(jq -r --arg n "$imgname" '.[]|select(.name==$n)|."tag-suffix" // ""' /tmp/imgs.json)
ref="$repo:sha-${SHORT}-amd64${sfx:+-$sfx}"
echo "rolling $svc → $ref"
kubectl -n "$NS" set image "deployment/$svc" "*=$ref"
kubectl -n "$NS" rollout status "deployment/$svc" --timeout=180s
+29
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="ci" width="880"></p>
# hanzoai/ci
One reusable CI/CD workflow for every Hanzo / Lux / Zoo repo. Build + test +
@@ -50,6 +52,33 @@ build minutes). To run on **your own** self-hosted arc runners, pass their label
secrets: inherit
```
## Delegate to platform (skip runner buildx)
By default the build runs buildx **on** the arc runner. To instead hand the build
to **platform.hanzo.ai** — which builds in-cluster with BuildKit and rolls the
service itself — pass `mode: delegate`:
```yaml
uses: hanzoai/ci/.github/workflows/build.yml@v1
with:
mode: delegate
secrets: inherit
```
The GitHub job then just POSTs each image in `hanzo.yml` to platform's direct
build webhook (`/v1/arcd/enqueue`) and exits in **seconds** — no runner buildx,
no KMS, no runner-side deploy. Platform creates the build job, launches an
in-cluster BuildKit Job on its own pool, pushes to the registry, and patches the
operator `Service` CR to roll it. It's the same build path as the platform
GitHub-App webhook — one build path, two front doors.
Requires one extra secret, `PLATFORM_BUILD_CALLBACK_TOKEN` (org- or repo-level,
picked up via `secrets: inherit`). Override the endpoint with the
`PLATFORM_ENQUEUE_URL` repo/org variable (default `https://platform.hanzo.ai/v1/arcd/enqueue`).
`mode: buildx` (the default) is unchanged — existing repos keep running buildx on
arc, so delegation is strictly opt-in.
## Credentials
The only GitHub secrets a repo sets are `KMS_CLIENT_ID` / `KMS_CLIENT_SECRET`