mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
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]>
399 lines
18 KiB
YAML
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
|