build bridge-server on the forge, and refuse an image that quotes a mint

.github/workflows/server.yml could not ship this repo, three ways over:

- Its pool `lux-build-amd64` has had no GitHub-side runner since 2026-07-30.
  Jobs queue and cancel at the 24h timeout, which is what every `cancelled
  24h0m2s` run on this repo is. Nothing goes red; it just never happens.
- It only ran `kubectl rollout restart`, which delivers NOTHING against
  bridge-server: the live deployment is digest-pinned with
  imagePullPolicy IfNotPresent.
- It named no namespace, while the deployment lives in `lux-bridge`.

The forge runners are alive (16 of them) and advertise both `lux-build-amd64`
and `lux-build-linux-amd64` — read them back with
`kubectl -n hanzo exec git-runner-0 -- cat /data/.runner`. So the build moves to
.hanzo/workflows, which is where native CI belongs, and there is one lane again
rather than a live one and a dead one.

BUILD ≠ DEPLOY: this publishes an image and stops. bridge-server is governed by
Hanzo CD, so shipping is an edit to universe/deploy/lux-bridge/bridge-server.yaml.
Patching the live object from CI would be reverted ~90s later, which fails in the
worst way — the pod rolls and the old digest quietly returns. The manifest-verify
step prints the digest to pin.

The smoke is the point. A quote is not advisory: on an exit,
handlerUtilaPayoutAction reads back quotes.receive_amount as payoutAmount and
bridgeMints exactly it, and bridge-server serves /api for bridge.zoo.network,
bridge.zoo.ngo and bridge.pars.network — where 1000 LZOO quoted 47,142,857 ZOO
for months. So the image must answer 990 to that exact request, and 400 to an
unroutable pair, before a manifest is allowed to exist. Both run against the
built container, not the source tree.

docker.yml is left alone: it builds ghcr.io/luxfi/bridge from the root
Dockerfile, a different image and a different lane.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 17:45:21 -07:00
co-authored by Hanzo Dev
parent 2c178a9152
commit e52e3428dc
2 changed files with 171 additions and 41 deletions
-41
View File
@@ -1,41 +0,0 @@
name: server
on:
push:
branches:
- main
paths:
- app/server/**
- pkg/core/**
jobs:
build_and_deploy:
runs-on: lux-build-amd64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.0
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Set up kubectl
uses: azure/setup-kubectl@v3
with:
version: v1.30.6
- name: Deploy to Kubernetes
env:
KUBE_YAML: ${{ secrets.KUBE_YAML }}
run: 'echo "$KUBE_YAML" > kubeconfig.yaml
export KUBECONFIG=$(pwd)/kubeconfig.yaml
pnpm run k8s:restart
'
working-directory: app/server
+171
View File
@@ -0,0 +1,171 @@
name: Server
# Builds ghcr.io/luxfi/bridge-server on the native runners (git.hanzo.ai →
# git-runner), which is the only fleet that currently serves this repo: the
# GitHub-side pool `lux-build-amd64` has had no runner since 2026-07-30, so
# .github/workflows jobs queue and cancel at the 24h timeout without ever
# reporting a failure. The forge runners advertise both `lux-build-amd64` and
# `lux-build-linux-amd64`; read them back with
# kubectl -n hanzo exec git-runner-0 -- cat /data/.runner
#
# BUILD ≠ DEPLOY. This publishes an image and stops. bridge-server is governed
# by Hanzo CD (`apps.hanzo.ai/installation-id: hanzo-cd`) and runs a
# DIGEST-PINNED image, so shipping is a separate edit to
# universe/deploy/lux-bridge/bridge-server.yaml. A `kubectl rollout restart`
# from CI — which is what .github/workflows/server.yml does — delivers nothing
# against a pinned digest, and patching the live object would be reverted by CD.
#
# ghcr auth: GHCR_USER / GHCR_TOKEN, org Actions secrets on this forge. The
# instance GITHUB_TOKEN is a Hanzo Git token and is NOT valid at ghcr.io.
on:
push:
branches: [main]
paths:
- 'app/server/**'
- 'pkg/core/**'
- '.hanzo/workflows/server.yml'
tags: ['v*']
workflow_dispatch:
concurrency:
group: server-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: [lux-build-linux-amd64]
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Compute tags
id: meta
run: |
set -euo pipefail
SHA_TAG="ghcr.io/luxfi/bridge-server: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/bridge-server:v${VERSION}"
echo "ghcr.io/luxfi/bridge-server:${VERSION}"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=ghcr.io/luxfi/bridge-server:v${VERSION}" >> "$GITHUB_OUTPUT"
else
{
echo "tags<<EOF"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=$SHA_TAG" >> "$GITHUB_OUTPUT"
fi
- uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build (load for smoke, not yet pushed)
uses: docker/build-push-action@v6
with:
# The Dockerfile installs the pnpm workspace, so the context is the
# repo root and the file is addressed from it.
context: .
file: app/server/docker/Dockerfile
platforms: linux/amd64
load: true
push: false
provenance: false
tags: ${{ steps.meta.outputs.tags }}
- name: Smoke — it serves, and it does not quote a mint
# Two things are checked, and the second is the reason this step exists.
#
# A quote is not advisory: on an exit, handlerUtilaPayoutAction reads
# back quotes.receive_amount as payoutAmount and bridgeMints exactly it.
# bridge-server serves /api for bridge.zoo.network, bridge.zoo.ngo and
# bridge.pars.network, and it shipped for months quoting 1000 LZOO as
# 47,142,857 ZOO because a failed price lookup answered $1.00. An image
# that would do that again must not reach the registry.
#
# POSTGRES_URL only has to be SET, not reachable: PrismaClient reads it
# at construction and would abort startup without it, but connects
# lazily, and neither /health nor /api/quote touches the database.
run: |
set -euo pipefail
img="${{ steps.meta.outputs.probe }}"
cid=$(docker run -d -p 3000:3000 \
-e PORT=3000 \
-e POSTGRES_URL="postgresql://smoke:[email protected]:5432/smoke" \
"$img")
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
ok=""
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/health >/dev/null 2>&1; then ok=1; break; fi
sleep 2
done
if [ -z "$ok" ]; then
docker logs "$cid" 2>&1 | tail -40
echo "::error::bridge-server never answered /health"; exit 1
fi
echo "health ok"
# 1000 LZOO out of Lux is a 1% exit fee on a wrap: exactly 990.
got=$(curl -sf -G http://localhost:3000/api/quote \
--data-urlencode 'source_network=LUX_MAINNET' \
--data-urlencode 'source_token=LZOO' \
--data-urlencode 'destination_network=ZOO_MAINNET' \
--data-urlencode 'destination_token=ZOO' \
--data-urlencode 'amount=1000' \
--data-urlencode 'refuel=false' \
--data-urlencode 'use_deposit_address=false' \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).data.quote.receive_amount))})')
if [ "$got" != "990" ]; then
echo "::error::1000 LZOO quoted ${got}, want 990 — this image would mint it"; exit 1
fi
echo "quote ok: 1000 LZOO -> 990 ZOO"
# An unroutable pair must be refused, not priced.
code=$(curl -s -o /dev/null -w '%{http_code}' -G http://localhost:3000/api/quote \
--data-urlencode 'source_network=LUX_MAINNET' \
--data-urlencode 'source_token=LBTC' \
--data-urlencode 'destination_network=ZOO_MAINNET' \
--data-urlencode 'destination_token=ZETH' \
--data-urlencode 'amount=1')
if [ "$code" != "400" ]; then
echo "::error::LBTC->ZETH answered ${code}, want 400"; exit 1
fi
echo "unroutable pair refused with 400"
- 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
# build-push can exit 0 while the manifest is not yet resolvable, and a
# CR pinned to a phantom tag becomes ImagePullBackOff.
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"
echo "pin this in universe/deploy/lux-bridge/bridge-server.yaml:"
docker manifest inspect -v "$img" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(" digest: "+(j.Descriptor?.digest||j.descriptor?.digest||"(unknown)"))})'
exit 0
fi
echo "manifest not visible yet (attempt ${i}/6) — retrying"; sleep 5
done
echo "::error::$img not pullable after push"; exit 1