Files
zeekayandHanzo Dev 38881e6607 ci: a runner label names the platform, not the org
hanzo-build-linux-amd64, lux-build-linux-amd64, zoo-build-linux-amd64 and
every other org-prefixed spelling all meant the same machine. Which org a
job belongs to does not change which host can build it, so the org bought
one pool name per org per arch and each could go stale on its own -- the
evo host alone answered to fourteen names for one label.

One vocabulary now: linux-amd64, linux-arm64, macos-arm64, win-amd64. The
generic labels every plain workflow uses (ubuntu-latest, self-hosted) are
untouched, so naive CI keeps working.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 12:09:45 -07:00

399 lines
18 KiB
YAML

name: Release
# Builds ghcr.io/luxfi/aml and ghcr.io/luxfi/aml-ui on the native runners
# (git.hanzo.ai → git-runner). Two images because they are two deploy units: the
# daemon answers api.<brand>/v1/aml, the console is a static bundle on its own
# host. They share a tag so a release names one state of the product.
#
# A tag push vX.Y.Z is a release: the image carries the semver tags plus the sha
# tag; a main push carries only the sha tag. The image must pass the smoke below
# before it is allowed to exist at the registry, and the pushed manifest is
# proven resolvable — so a tag can never point at an image nothing built. A
# version that claims an artifact it does not have is worse than no version,
# because a deploy pin will believe it.
#
# The daemon is built once per architecture, each on its own native pool, and
# the two are joined into one manifest list under the release names. The lab
# runs both architectures, so an amd64-only tag deploys on half the fleet and
# the other half never starts. Each arch is compiled AND smoked on the machine
# it ships to; nothing is emulated, so the smoke answers "does this run there"
# rather than "does QEMU agree".
#
# Registry is ghcr.io/luxfi/aml. The container-registry rule is not mixed: a Lux
# product publishes under ghcr.io/luxfi/*, exactly as luxfi/kms does. This is
# also the only path with credentials provisioned on the forge, so it is the one
# that actually builds rather than the one that reads well.
#
# The /v2/ in the OCI registry API is the OCI Distribution Specification's, not
# ours. Every route this product itself serves is /v1/.
#
# Auth: GHCR_USER / GHCR_TOKEN — org Actions secrets on this forge, sourced from
# KMS. The instance GITHUB_TOKEN is a Hanzo Git token and is not a registry
# credential. The luxfi/ namespace is the engine's; Hanzo AML is a thin wrapper
# over it, the way hanzoai/kms wraps luxfi/kms.
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
# One job per architecture on its own native pool. The daemon is pure Go on
# alpine, so both arches build from the same Dockerfile with nothing
# cross-compiled and nothing translated.
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
runs-on: ['lux-build-linux-${{ matrix.arch }}']
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Name this arch's image
id: meta
# A per-arch name, never a release name: the release names are a manifest
# list and only the merge job may write them. A tag that resolves to one
# architecture is worse than a tag that does not exist yet, because it
# pulls on that architecture and fails everywhere else.
run: |
set -euo pipefail
echo "image=ghcr.io/luxfi/aml:sha-${GITHUB_SHA:0:7}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
echo "version=sha-${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
fi
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build (plain docker, loaded for smoke, not yet pushed)
# Plain `docker build` against the runner's own daemon, NOT
# setup-buildx-action with the docker-container driver. The container
# driver nests a buildkit container inside the runner's DinD, which fails
# at setup here (the pure-Go test job on the same runner passes; only the
# buildx-container build failed, producing no step log). This job builds
# its own architecture and no other, which is exactly what the daemon
# serves and what `docker run` below needs loaded, so the extra nesting
# would buy nothing but a failure mode.
run: |
set -euo pipefail
img="${{ steps.meta.outputs.image }}"
docker build --build-arg VERSION=${{ steps.meta.outputs.version }} -t "$img" -f Dockerfile .
echo "built $img"
- name: Smoke — it serves and it refuses
# An image that builds but cannot answer deploys and half-works. Beyond
# liveness this checks the behaviour that has actually regressed in this
# engine: an unauthenticated read must be refused, not served somebody's
# tenant data. It runs on the arch that built it, so a binary that starts
# only under emulation cannot pass here.
#
# AML_CLIENT_ID is required — the daemon refuses to start without the
# audience it pins every token to. Ingesting a transaction now needs a
# brand-issued IAM bearer, which this smoke cannot mint, so it exercises
# the two facts that need no token: the process serves /v1/aml/health, and
# an unauthenticated read of /v1/aml/cases is refused with 401. The
# benign-payment-clears check lives in the engine's own tests, where a
# signed token can be constructed.
#
# The probe shares THIS job container's network namespace instead of
# publishing a port. The step runs inside a job container while the docker
# daemon is the runner's own, so `-p 127.0.0.1:…` binds in the runner's
# namespace and the loopback here never reaches it — measured, the probe
# got no connection at all while the container logged that it was serving.
# Sharing the namespace makes the two loopbacks the same one and needs
# nothing published.
#
# The token key is hex from od/tr, not xxd: xxd is not in the runner image,
# and the substitution failed quietly to an EMPTY key rather than failing
# the step, so the daemon was smoked without the key it is given in
# production.
run: |
set -euo pipefail
img="${{ steps.meta.outputs.image }}"
docker run --rm --entrypoint sh "$img" -c 'command -v amld'
self="$(hostname)"
docker inspect "$self" >/dev/null 2>&1 \
|| { echo "::error::cannot resolve this job container ($self) to share its network"; exit 1; }
key=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
[ ${#key} -eq 64 ] || { echo "::error::token key is ${#key} hex chars, want 64"; exit 1; }
cid=$(docker run -d \
-e AML_TENANT_HEADER=X-Org-Id \
-e AML_CLIENT_ID=aml-release-smoke \
-e AML_TOKEN_KEY="$key" \
--network "container:$self" "$img")
trap 'docker logs "$cid" | tail -60; docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
ready=""
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://127.0.0.1:8090/v1/aml/health; then ready=1; break; fi
sleep 2
done
[ -n "$ready" ] || { echo "::error::amld never answered /v1/aml/health"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/v1/aml/cases)
[ "$code" = "401" ] || { echo "::error::unauthenticated read returned $code, want 401"; exit 1; }
echo "serves, and refuses an unauthenticated read"
- name: Push this arch
run: docker push "${{ steps.meta.outputs.image }}"
- name: Verify this arch's pushed image resolves
# The tag is a receipt for an image that exists. Proving it resolves
# after the push is what makes it one.
run: |
set -euo pipefail
img="${{ steps.meta.outputs.image }}"
for i in 1 2 3 4 5 6; do
if docker manifest inspect "$img" >/dev/null 2>&1; then
echo "$img is resolvable"; exit 0
fi
echo "manifest not visible yet (attempt ${i}/6) — retrying"; sleep 5
done
echo "::error::$img not pullable after push"; exit 1
merge:
needs: build
# Registry-side only: imagetools reads the two per-arch manifests and writes
# a list that points at both. No builder and no platform of its own, so which
# pool runs this does not matter.
runs-on: [linux-amd64]
timeout-minutes: 15
steps:
- name: Compute the release names
id: meta
run: |
set -euo pipefail
SHA_TAG="ghcr.io/luxfi/aml:sha-${GITHUB_SHA:0:7}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
{
echo "tags<<EOF"
echo "ghcr.io/luxfi/aml:v${VERSION}"
echo "ghcr.io/luxfi/aml:${VERSION}"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
{
echo "tags<<EOF"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
fi
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Join both architectures under every release name
run: |
set -euo pipefail
base="ghcr.io/luxfi/aml:sha-${GITHUB_SHA:0:7}"
args=''
while IFS= read -r t; do
[ -n "$t" ] && args="$args -t $t"
done <<< "${{ steps.meta.outputs.tags }}"
# shellcheck disable=SC2086
docker buildx imagetools create $args "${base}-amd64" "${base}-arm64"
- name: Prove every release name carries BOTH architectures
# A manifest list with one entry pulls fine on the arch that built it and
# fails everywhere else, which is exactly the state this replaces.
# Counting the platforms is what makes "multi-arch" a fact rather than an
# intention.
run: |
set -euo pipefail
while IFS= read -r t; do
[ -z "$t" ] && continue
got=$(docker buildx imagetools inspect "$t" --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}')
echo "$t -> ${got}"
for want in linux/amd64 linux/arm64; do
case " $got " in
*" $want "*) ;;
*) echo "::error::$t does not carry $want"; exit 1 ;;
esac
done
done <<< "${{ steps.meta.outputs.tags }}"
- name: Mirror to oci.hanzo.ai (the sovereign copy)
# ghcr.io/luxfi is canonical; oci.hanzo.ai holds our own copy so a deploy
# never depends on a third party staying reachable — the same reason the
# rest of this stack self-hosts. It authenticates against Hanzo IAM
# (realm iam.hanzo.ai/v1/iam/registry/token), a different credential from
# ghcr, so it is provisioned separately as OCI_USER / OCI_TOKEN.
#
# imagetools copies the manifest LIST across registries. `docker tag` +
# `docker push` cannot: it only knows the single image the daemon holds
# locally, and this job holds none, so the sovereign copy would carry one
# architecture while ghcr carried two.
#
# This is best-effort and LOUD, never silent: if the IAM credential is
# not yet provisioned the step warns and the release still succeeds with
# the canonical ghcr image, rather than failing the whole release over a
# copy. A skipped mirror is announced so nobody reads its absence as
# "the sovereign copy is current".
if: ${{ secrets.OCI_USER != '' && secrets.OCI_TOKEN != '' }}
run: |
set -euo pipefail
echo "${{ secrets.OCI_TOKEN }}" | docker login oci.hanzo.ai \
-u "${{ secrets.OCI_USER }}" --password-stdin
while IFS= read -r t; do
[ -z "$t" ] && continue
docker buildx imagetools create -t "oci.hanzo.ai/${t#*/}" "$t"
done <<< "${{ steps.meta.outputs.tags }}"
echo "sovereign copy mirrored to oci.hanzo.ai/luxfi/aml"
- name: Note when the sovereign copy was skipped
if: ${{ secrets.OCI_USER == '' || secrets.OCI_TOKEN == '' }}
run: echo "::warning::oci.hanzo.ai copy SKIPPED — OCI_USER/OCI_TOKEN not provisioned on this org. Canonical ghcr.io/luxfi/aml image is published; provision the IAM registry credential to enable the sovereign copy."
console:
# The console: the same bundle every brand is served, with its deployment's
# API and clientId templated into /config.json at container start. It holds
# no secret and cannot — it is a static file anyone may read.
#
# amd64 only, and not by choice: ui/Dockerfile serves the bundle from
# ghcr.io/hanzoai/static:v0.5.1, and that tag is a single amd64 manifest —
# `--platform=linux/arm64` cannot resolve it and the build dies there. The
# bundle itself is architecture-free. hanzoai/static publishes both
# architectures from 0.5.15 on, so this becomes the same matrix the daemon
# uses the moment ui/Dockerfile moves to a tag that has an arm64 manifest.
runs-on: [linux-amd64]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Compute tags
id: meta
run: |
set -euo pipefail
SHA_TAG="ghcr.io/luxfi/aml-ui:sha-${GITHUB_SHA:0:7}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
{
echo "tags<<EOF"
echo "ghcr.io/luxfi/aml-ui:v${VERSION}"
echo "ghcr.io/luxfi/aml-ui:${VERSION}"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=ghcr.io/luxfi/aml-ui:v${VERSION}" >> "$GITHUB_OUTPUT"
else
{
echo "tags<<EOF"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=$SHA_TAG" >> "$GITHUB_OUTPUT"
fi
- name: Typecheck and build the bundle
# The type error is caught here, before an image exists to deploy.
run: |
set -euo pipefail
npm --prefix ui ci
npm --prefix ui run build
test -f ui/dist/index.html
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build
run: |
set -euo pipefail
args=""
while IFS= read -r t; do [ -n "$t" ] && args="$args -t $t"; done <<< "${{ steps.meta.outputs.tags }}"
docker build $args -f ui/Dockerfile ui/
- name: Smoke — it serves, it answers deep links, and it is configured by nothing
# Four facts, each of which has to hold for the console to be both usable
# and safe: the bundle is served; an unknown path returns index.html so a
# refresh on /cases does not 404; there is NO config.json, because the
# API origin is derived from the host and the clientId comes from the
# API; and the bundle names no clientId of its own, so the same image is
# every brand's.
#
# Same namespace sharing as the daemon's smoke, for the same measured
# reason: a published port lands in the runner's network namespace, not
# this job container's, so the loopback probe could never reach a console
# that was serving perfectly well.
run: |
set -euo pipefail
img="${{ steps.meta.outputs.probe }}"
self="$(hostname)"
docker inspect "$self" >/dev/null 2>&1 \
|| { echo "::error::cannot resolve this job container ($self) to share its network"; exit 1; }
cid=$(docker run -d --network "container:$self" "$img")
trap 'docker logs "$cid" | tail -40; docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
ready=""
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://127.0.0.1:3000/healthz; then ready=1; break; fi
sleep 1
done
[ -n "$ready" ] || { echo "::error::the console never answered /healthz"; exit 1; }
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|| { echo "::error::/ did not serve the bundle"; exit 1; }
curl -sf http://127.0.0.1:3000/cases/anything | grep -q '<div id="root">' \
|| { echo "::error::a deep link did not fall back to index.html"; exit 1; }
# A config.json would mean the API origin is a setting again, and a
# setting that points every authenticated request somewhere is the one
# worth attacking. In SPA mode an absent file falls back to index.html,
# so the check is that it is NOT a config document.
if curl -sf http://127.0.0.1:3000/config.json | grep -q '"api"'; then
echo "::error::a config.json is being served — the API origin is derived, not configured"; exit 1
fi
# The same image is every brand's, so it can name none of them.
if docker run --rm --entrypoint sh "$img" -c 'grep -rl "hanzo-aml" /public 2>/dev/null | head -1' | grep -q .; then
echo "::error::the bundle names a clientId; it must come from /v1/aml/config"; exit 1
fi
echo "serves, answers deep links, and carries no configuration"
- name: Push
run: |
set -euo pipefail
while IFS= read -r t; do
[ -n "$t" ] && docker push "$t"
done <<< "${{ steps.meta.outputs.tags }}"
- name: Verify the pushed manifest resolves
run: |
set -euo pipefail
img="${{ steps.meta.outputs.probe }}"
for i in 1 2 3 4 5 6; do
if docker manifest inspect "$img" >/dev/null 2>&1; then
echo "$img is resolvable"; exit 0
fi
echo "manifest not visible yet (attempt ${i}/6) — retrying"; sleep 5
done
echo "::error::$img not pullable after push"; exit 1