ci: one GHA workflow + hanzo.yml (arc + KMS build/test/deploy), drop .platform.yml

Replaces .platform.yml / docker-release.yml / platform-build.yml / ci.yml with:
- hanzo.yml (root): declarative images + test + deploy, read by the workflow
  and by platform.hanzo.ai. Multi-image (api+web), unlike .platform.yml.
- .github/workflows/cicd.yml: runs on our self-hosted arc runners
  ([self-hosted, linux, amd64]) — offloaded from GitHub-hosted. Pulls GHCR +
  kubeconfig creds from KMS via Universal Auth at run time (only secrets in
  GitHub are KMS_CLIENT_ID/SECRET + var KMS_WORKSPACE). Builds + tests + rolls
  the images onto the cluster. Self-contained — no cross-org reusable workflow.

One way: every repo gets the same cicd.yml and declares its specifics in hanzo.yml.
This commit is contained in:
2026-06-19 19:42:26 +00:00
parent f9e6323c4c
commit 7ecff237e2
6 changed files with 153 additions and 328 deletions
-146
View File
@@ -1,146 +0,0 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test-api:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: bootnode
POSTGRES_PASSWORD: bootnode
POSTGRES_DB: bootnode_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies
working-directory: ./api
run: |
uv venv
uv pip install -e ".[dev]"
- name: Run tests
working-directory: ./api
env:
DATABASE_URL: postgresql://bootnode:bootnode@localhost:5432/bootnode_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-key-for-ci
API_KEY_SALT: test-salt-for-ci
run: |
.venv/bin/pytest -v --tb=short || echo "No tests yet"
test-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install dependencies
working-directory: ./web
run: pnpm install --frozen-lockfile
- name: Type check & Build
working-directory: ./web
run: pnpm run build
e2e-test:
runs-on: ubuntu-latest
needs: [test-api, test-web]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: bootnode
POSTGRES_PASSWORD: bootnode
POSTGRES_DB: bootnode
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.12
- name: Install API dependencies
working-directory: ./api
run: |
uv venv
uv pip install -e ".[dev]"
- name: Start API server
working-directory: ./api
env:
DATABASE_URL: postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-key-for-ci
API_KEY_SALT: test-salt-for-ci
run: |
.venv/bin/python -c "from bootnode.db import init_db; import asyncio; asyncio.run(init_db())"
.venv/bin/uvicorn bootnode.main:app --host 0.0.0.0 --port 8100 &
sleep 10
- name: Run E2E tests
run: bash test-e2e.sh
# Build, push, and deploy are handled by docker-release.yml
# which triggers on CI completion via workflow_run
+104
View File
@@ -0,0 +1,104 @@
# Hanzo CI/CD — build + test + deploy, driven by the repo's root hanzo.yml.
#
# Runs on our self-hosted arc runners ([self-hosted, linux, amd64]) — offloaded
# from GitHub-hosted compute. Credentials (GHCR push token, cluster kubeconfig)
# are pulled from KMS at run time via Universal Auth; the only GitHub secrets are
# KMS_CLIENT_ID / KMS_CLIENT_SECRET, and the repo var KMS_WORKSPACE. Self-
# contained (no cross-org reusable workflow) so it works from any org.
name: CI/CD
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
concurrency:
group: cicd-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
packages: write
jobs:
cicd:
runs-on: [self-hosted, linux, amd64]
steps:
- uses: actions/checkout@v4
- name: Fetch deploy credentials from KMS
id: kms
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 }}
run: |
set -euo pipefail
TOKEN=$(curl -sf "$KMS_ENDPOINT/api/v1/auth/universal-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:=hanzoai}"
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
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)
run: |
set -euo pipefail
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
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"
echo "::endgroup::"
done
- name: Test (per hanzo.yml)
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
name=$(echo "$t"|jq -r .name); cmd=$(echo "$t"|jq -r .run)
echo "::group::test $name"; bash -c "$cmd"; echo "::endgroup::"
done
- name: Deploy (per hanzo.yml)
if: github.event_name != 'pull_request' && steps.kms.outputs.kubeconfig != ''
env:
KUBECONFIG: ${{ steps.kms.outputs.kubeconfig }}
run: |
set -euo pipefail
REF="${{ github.ref_name }}"
ON=$(python3 -c "import yaml,json;print(json.dumps((yaml.safe_load(open('hanzo.yml')).get('deploy') or {}).get('on') or []))")
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
if [ "$IS_TAG" != 1 ] && ! echo "$ON" | jq -e --arg b "$REF" 'index($b)' >/dev/null; then
echo "branch $REF not in deploy.on — skipping deploy"; exit 0
fi
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
NS=$(python3 -c "import yaml;print(yaml.safe_load(open('hanzo.yml'))['deploy']['namespace'])")
# map image name → repo + suffix
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'))['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}"
echo "rolling $svc → $ref"
kubectl -n "$NS" set image "deployment/$svc" "*=$ref"
kubectl -n "$NS" rollout status "deployment/$svc" --timeout=180s
done
-67
View File
@@ -1,67 +0,0 @@
# Self-contained image build on the self-hosted arcd pools.
#
# Previously this delegated to hanzoai/.github's reusable docker-build.yml, but
# bootnode is in a separate GitHub org and that workflow's access is org-scoped,
# so every run failed "workflow was not found" (broken since ~2026-05-19). The
# build steps are inlined here — same self-hosted runners, no cross-org
# reusable dependency. (platform.hanzo.ai via .platform.yml is the canonical
# path; this is the retained GHA fallback. No GitHub-hosted runners.)
name: Docker Release
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
concurrency:
group: docker-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
jobs:
build:
runs-on: [self-hosted, linux, amd64]
strategy:
fail-fast: false
matrix:
include:
- { app: api, context: ./api }
- { app: web, context: ./web }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: tags
run: |
BASE=ghcr.io/hanzoai/bootnode
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
# *-latest is what hanzo/universe pins; sha tag is for traceability.
TAGS="$BASE:${{ matrix.app }}-latest,$BASE:sha-${SHORT}-amd64-${{ matrix.app }}"
if [ "${{ github.ref_type }}" = "tag" ]; then
V="${{ github.ref_name }}"; V="${V#v}"
TAGS="$TAGS,$BASE:${V}-amd64-${{ matrix.app }}"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build and push ${{ matrix.app }}
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.context }}/Dockerfile
push: true
tags: ${{ steps.tags.outputs.tags }}
platforms: linux/amd64
provenance: false
-94
View File
@@ -1,94 +0,0 @@
name: Platform Build (arcd executor)
# Dispatched by platform.hanzo.ai (NOT by GitHub on push). Platform receives
# the repo webhook, schedules the build, and triggers this workflow via
# workflow_dispatch with the inputs below — pinning the arcd runner pool per
# build target. On completion this workflow calls back into platform, which
# records the outcome and (on success) rolls the image out via the operator.
#
# This is the per-repo half of the platform-native CI/CD escape from GHA as a
# single point of failure. The build muscle still runs on self-hosted arcd.
on:
workflow_dispatch:
inputs:
build-job-id:
description: Platform buildJob id (echoed back on completion)
required: true
target:
description: "Build target, e.g. linux/amd64"
required: true
runner-pool:
description: "arcd pool label, e.g. hanzoai-linux-amd64"
required: true
image:
description: "Fully-resolved image ref, e.g. ghcr.io/hanzoai/zip:<sha>"
required: true
dockerfile:
description: Path to the Dockerfile
required: true
default: ./Dockerfile
context:
description: Build context
required: true
default: .
push:
description: Whether to push the built image
required: true
default: "true"
jobs:
build:
runs-on: ${{ inputs.runner-pool }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
if: ${{ inputs.push == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: ${{ inputs.push }}
tags: ${{ inputs.image }}
provenance: false
- name: Report success to platform
if: success()
env:
TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
run: |
curl -fsS -X POST "https://platform.hanzo.ai/v1/build-callback" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -nc \
--arg id '${{ inputs.build-job-id }}' \
--arg inst '${{ github.event.installation.id }}' \
--arg img '${{ inputs.image }}' \
'{buildJobId:$id, outcome:"success", installationId:$inst, log:("built "+$img)}')"
- name: Report failure to platform
if: failure()
env:
TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
run: |
curl -fsS -X POST "https://platform.hanzo.ai/v1/build-callback" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -nc \
--arg id '${{ inputs.build-job-id }}' \
--arg inst '${{ github.event.installation.id }}' \
'{buildJobId:$id, outcome:"failure", installationId:$inst, error:"build step failed"}')"
-21
View File
@@ -1,21 +0,0 @@
# Platform-native CI/CD — platform.hanzo.ai owns the build, escaping the
# cross-org GitHub Actions reusable workflow (hanzoai/.github docker-build.yml),
# which bootnode (a separate GitHub org) cannot access. Platform receives the
# repo webhook, schedules the build onto the self-hosted arcd pool, and pushes
# the image. See hanzoai/platform docs/PLATFORM_CI.md.
#
# Scope: the API image — the v1.0.0 backend (Lux/Hanzo/Zoo node deploy,
# contracts, subgraphs, mpc, launch). bootnode also ships a `web` image; the
# .platform.yml schema is single-build per repo, so web needs a follow-up
# (a multi-build schema extension, or its own config). Deploy is intentionally
# omitted: bootnode-k8s rolls via universe, not a hanzo-operator Service CR
# (the deploy block's target), so this is build-only until that's wired.
build:
matrix:
- { os: linux, arch: amd64 }
dockerfile: ./Dockerfile
context: ./api
image: ghcr.io/hanzoai/bootnode
tag-pattern: "{{git.sha}}-amd64-api"
push: true
dispatch: native
+49
View File
@@ -0,0 +1,49 @@
# Hanzo CI/CD — one declarative build + test + deploy config.
#
# Read by .github/workflows/cicd.yml (which runs on our self-hosted arc runners,
# not GitHub-hosted) and by platform.hanzo.ai. Replaces the old .platform.yml.
# Registry + cluster credentials come from KMS at build time (the repo only sets
# KMS_CLIENT_ID / KMS_CLIENT_SECRET + KMS_WORKSPACE), so no long-lived secrets
# live in GitHub.
images:
- name: api
context: ./api
dockerfile: ./api/Dockerfile
repo: ghcr.io/hanzoai/bootnode
tag-suffix: api
- name: web
context: ./web
dockerfile: ./web/Dockerfile
repo: ghcr.io/hanzoai/bootnode
tag-suffix: web
# Commands run on the arc runner after the image builds. Non-zero fails the run.
test:
- name: api
run: >-
docker compose -f compose.yml up -d postgres redis >/dev/null 2>&1;
cd api &&
DATABASE_URL=postgresql+asyncpg://bootnode:bootnode@localhost:5432/bootnode
JWT_SECRET=ci API_KEY_SALT=ci
uv run pytest -q --tb=short
# Roll the freshly-built images onto the cluster. Only on the listed branches /
# on v* tags. The operator (or kubectl, via the KMS kubeconfig) patches each
# Service to the new image. Omit this block for build-only repos.
deploy:
cluster: bootnode-k8s
namespace: bootnode
on:
- main
services:
- name: web3-platform-api
image: api
- name: web3-platform-web-bootnode
image: web
# KMS source for /deploy secrets (GHCR_TOKEN, KUBECONFIG). workspace + endpoint
# are supplied as repo vars so this file carries no secrets.
kms:
path: /deploy
environment: prod