ci: build on the native runners, and make the tree buildable again

The search fix (be64271) has been sitting on main unpublished because CI on
this forge could not produce an image. Three independent faults, each hidden
behind the one in front of it.

1. The credential. .github/workflows/build-lux.yml logged in to ghcr.io with
   secrets.GITHUB_TOKEN. On Hanzo Git that is a Hanzo Git token and ghcr.io has
   never heard of it, so every run died in about a minute with

     Get "https://ghcr.io/v2/": denied: denied

   The build was not queueing forever — it ran, and it failed. The pair that
   works here is GHCR_USER / GHCR_TOKEN, the org secrets luxfi/node already
   publishes with.

2. The lockfile. d319792b took react-icons out and put @hanzogui/lucide-icons-2
   in, and moved @luxfi/ui to 7.4.9, but pnpm-lock.yaml was never regenerated —
   it still specified react-icons and @luxfi/ui 7.3.2. The Dockerfile installs
   with --frozen-lockfile, so the image build could not have succeeded even with
   a valid registry credential:

     ERR_PNPM_OUTDATED_LOCKFILE  ... don't match specs in package.json

   Regenerated against the committed package.json; `pnpm install
   --frozen-lockfile` is green.

3. The stub. f3833dfc pointed four Turbopack resolve aliases at
   ./stubs/empty.js and the file was never added. It exists on the author's
   disk, so it builds there and nowhere else. Added.

2 and 3 are the same failure twice: a commit landed with a piece missing, and
because CI could not run, nothing said so. That is the actual cost of a broken
pipeline — not the delay, the silence.

CI moves to .hanzo/workflows, the one path this forge reads, mirroring
luxfi/node:

  release.yml  gates on the unit suite, builds, proves the image is a runnable
               build whose baked NEXT_PUBLIC_GIT_TAG equals the tag being
               published, pushes, then proves the manifest resolves. v1.1.14 and
               v1.1.15 are package.json bumps with no image behind them; the
               inverse — an image whose footer misreports its own version — is
               the one that misleads in production, and both are now unpublishable.
  ci.yml       the unit suite on push and PR.

build-lux.yml is deleted rather than repaired. Besides the dead credential its
second job deployed by driving kubectl straight at the cluster, and those
objects are reconciled by hanzo-cd — an imperative apply is drift by
construction. Deployment is a tag bump in luxfi/universe plus a CD sync; CI's
job ends at a published, resolvable image.

lint:tsc is deliberately not gated: it is red on main on its own account
(TS2304 across ui/**/*.pw.tsx, plus the unrelated vite/ scratch app). It needs
a fix, not a permanently-red job standing in for one.

Version 1.1.15 -> 1.1.16, the patch above the last published tag v1.1.13.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-04 10:25:23 -07:00
co-authored by Hanzo Dev
parent 2c14a255f9
commit c6e940442e
6 changed files with 5670 additions and 665 deletions
-173
View File
@@ -1,173 +0,0 @@
name: Build and Deploy LUX Explorer
# Builds the Lux-branded explorer image only. The source supports
# white-label brand selection at runtime (lib/white-label.ts inspects
# hostname or NEXT_PUBLIC_BRAND), so a single image serves any brand
# at deploy time. Per-brand images for hanzo / zoo / pars are built
# from each brand's own org repo (ghcr.io/hanzoai, ghcr.io/zooai, …) —
# never from this repo.
on:
push:
branches:
- main
tags:
- "v*"
paths-ignore:
- "docs/**"
- "**/*.md"
- "**/*.mdx"
pull_request:
branches:
- main
workflow_dispatch:
concurrency:
group: lux-build-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
jobs:
build:
runs-on: lux-build
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/luxfi/explore
flavor: |
latest=auto
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
- name: Resolve app version
id: appversion
# Footer shows NEXT_PUBLIC_GIT_TAG. On a branch push there is no git tag,
# so bake the package.json version (single source) as v<version>.
run: |
v=$(grep -m1 '"version"' package.json | sed -E 's/.*"version": *"([^"]+)".*/\1/')
echo "tag=v${v}" >> "$GITHUB_OUTPUT"
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha,scope=lux
cache-to: type=gha,mode=max,scope=lux
provenance: false
push: ${{ github.event_name != 'pull_request' }}
build-args: |
GIT_COMMIT_SHA=${{ github.sha }}
GIT_TAG=${{ github.ref_type == 'tag' && github.ref_name || steps.appversion.outputs.tag }}
NEXT_PUBLIC_BRAND=lux
deploy:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: lux-build
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: .github
- name: Fetch deploy credentials from KMS
id: kms
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
HANZO_API_KEY: ${{ secrets.HANZO_API_KEY }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
DO_FALLBACK_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
run: |
if [ -n "${KMS_CLIENT_ID}" ] && [ -n "${KMS_CLIENT_SECRET}" ]; then
# Must not abort the step under `bash -eo pipefail` when KMS login
# fails — swallow curl/jq errors so the DO_FALLBACK_TOKEN path runs.
HANZO_API_KEY=$(curl -sf "${KMS_ENDPOINT}/api/v1/auth/universal-auth/login" \
-H "Content-Type: application/json" \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" 2>/dev/null | jq -r '.accessToken' 2>/dev/null || echo "")
fi
response=$(curl -sf "${KMS_ENDPOINT}/api/v3/secrets/raw?workspaceId=e1359bf4-31b4-4dfa-bb90-323e2c298ad8&secretPath=/deploy&environment=prod" \
-H "Authorization: Bearer ${HANZO_API_KEY}" 2>/dev/null || echo "")
if [ -n "$response" ]; then
token=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="DO_API_TOKEN") | .secretValue // empty' 2>/dev/null || echo "")
fi
# KMS is the source of truth for the DO token; fall back to the repo's
# DigitalOcean token secret when KMS has no /deploy DO_API_TOKEN entry.
if [ -z "${token:-}" ]; then
token="${DO_FALLBACK_TOKEN}"
fi
if [ -z "${token:-}" ]; then
echo "::warning::Deploy credentials not found. Image was pushed but deployment skipped."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::add-mask::${token}"
echo "token=${token}" >> "$GITHUB_OUTPUT"
- name: Deploy to production
if: steps.kms.outputs.skip != 'true'
env:
DO_TOKEN: ${{ steps.kms.outputs.token }}
run: |
INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"
export PATH="$INSTALL_DIR:$PATH"
if ! command -v doctl &>/dev/null; then
DOCTL_VERSION=$(curl -sI https://github.com/digitalocean/doctl/releases/latest | grep -i '^location:' | sed 's|.*/v||;s/\r//')
curl -sL "https://github.com/digitalocean/doctl/releases/download/v${DOCTL_VERSION}/doctl-${DOCTL_VERSION}-linux-amd64.tar.gz" | tar xz -C "$INSTALL_DIR"
fi
if ! command -v kubectl &>/dev/null; then
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl && mv kubectl "$INSTALL_DIR/kubectl"
fi
doctl auth init -t "$DO_TOKEN"
doctl kubernetes cluster kubeconfig save lux-k8s
for ns in lux-mainnet lux-testnet lux-devnet; do
kubectl create namespace $ns 2>/dev/null || true
done
kubectl apply -f deploy/k8s/stats.yaml -n lux-mainnet || true
kubectl rollout restart deployment/lux-stats -n lux-mainnet 2>/dev/null || true
# The Lux-branded FE image built by this run. metadata-action tags it
# sha-<7-char-short> (see the build job). IfNotPresent + a fresh
# immutable sha tag guarantees each rollout pulls the new bits.
IMAGE="${REGISTRY}/luxfi/explore:sha-$(echo "${GITHUB_SHA}" | cut -c1-7)"
echo "Rolling explore-fe-lux -> ${IMAGE}"
# explore-fe-lux is the Deployment that serves explore.lux.network (see
# deploy/k8s/explore-fe/deployments-mainnet.yaml + the mainnet
# ingressroute). Roll it across every network; testnet/devnet best-effort.
kubectl -n lux-testnet set image deployment/explore-fe-lux explore="${IMAGE}" 2>/dev/null || true
kubectl -n lux-devnet set image deployment/explore-fe-lux explore="${IMAGE}" 2>/dev/null || true
kubectl -n lux-mainnet set image deployment/explore-fe-lux explore="${IMAGE}"
kubectl -n lux-mainnet rollout status deployment/explore-fe-lux --timeout=300s
+61
View File
@@ -0,0 +1,61 @@
name: Tests
# The repo's unit suite, on the native runners (git.hanzo.ai → git-runner).
#
# This replaces the runnable half of .github/workflows/checks.yml. Every job
# here names a label the fleet actually carries, and every job here is green on
# main — a job no runner can claim, or one that is perpetually red, is worse
# than no job at all, because it trains everyone to ignore the colour.
#
# Deliberately NOT gated here:
#
# lint:tsc — `tsc -p tsconfig.json` is red on main independently of any change
# in this workflow: ~dozens of TS2304 "Cannot find name 'Box'" in
# ui/**/*.pw.tsx (Playwright fixtures the app tsconfig pulls in but
# does not give React/Chakra globals to) plus the unrelated vite/
# scratch app (missing `wouter`, ImportMeta.env). Gating on it
# would make this workflow red on arrival and prove nothing. It is
# a real defect and it needs its own fix, not a permanently-failing
# job standing in for one.
#
# test:pw — Playwright needs a browser image and a built app; it belongs in
# its own workflow with its own timeout, not bolted onto the unit
# gate.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer commit makes the older run irrelevant.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
vitest:
runs-on: [lux-build-linux-amd64]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.14.0'
- name: Install pnpm
run: |
set -euo pipefail
corepack enable
corepack prepare [email protected] --activate
pnpm --version
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Unit tests
run: pnpm vitest run
+184
View File
@@ -0,0 +1,184 @@
name: Release
# Builds ghcr.io/luxfi/explore on the native runners (git.hanzo.ai → git-runner).
#
# This replaces .github/workflows/build-lux.yml, which could never publish from
# this forge for two independent reasons:
#
# 1. It authenticated to ghcr.io with `secrets.GITHUB_TOKEN`. On Hanzo Git that
# token is a Hanzo Git token; ghcr.io has never heard of it. Every run died
# at the login step with `Get "https://ghcr.io/v2/": denied: denied` — the
# build ran, took a minute, and failed. The credential that works here is the
# GHCR_USER / GHCR_TOKEN org secret pair, sourced from KMS, exactly as
# luxfi/node uses.
#
# 2. Its second job deployed by driving kubectl straight at the cluster. Those
# objects are reconciled by hanzo-cd (the live Deployment carries
# apps.hanzo.ai/installation-id: hanzo-cd), so an imperative apply is drift
# by construction — it wins until the next sync and then silently loses.
# Deployment is a tag bump in luxfi/universe
# (deploy/lux-mainnet/explore-fe-lux.yaml) plus a CD sync. CI's job ends at a
# published, resolvable image.
#
# 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.
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
build:
runs-on: [lux-build-linux-amd64]
# Next.js production build of the full explorer. Measured runs sit well
# under this; the ceiling is here so a hung build releases the runner.
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.14.0'
- name: Install pnpm
run: |
set -euo pipefail
corepack enable
corepack prepare [email protected] --activate
pnpm --version
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The gate. The search crash this repo exists to fix is a unit-testable
# contract — /search/quick answers with a paginated envelope, the frontend
# used to iterate it as a bare list — and the suite covers it directly
# (lib/api/unwrapItems.spec.ts, ui/snippets/searchBar/useQuickSearchQuery.spec.tsx).
# An image that cannot pass this must not become a tag.
- name: Unit tests
run: pnpm vitest run
- name: Compute tags
id: meta
run: |
set -euo pipefail
SHA_TAG="ghcr.io/luxfi/explore: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/explore:v${VERSION}"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=ghcr.io/luxfi/explore:v${VERSION}" >> "$GITHUB_OUTPUT"
echo "git_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
else
{
echo "tags<<EOF"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=$SHA_TAG" >> "$GITHUB_OUTPUT"
# No tag on a branch push, so the footer's version string comes from
# package.json — the single source for "which release is this".
echo "git_tag=v$(node -p 'require("./package.json").version')" >> "$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:
context: .
file: Dockerfile
platforms: linux/amd64
load: true
push: false
provenance: false
tags: ${{ steps.meta.outputs.tags }}
build-args: |
GIT_COMMIT_SHA=${{ github.sha }}
GIT_TAG=${{ steps.meta.outputs.git_tag }}
NEXT_PUBLIC_BRAND=lux
# Which code is actually in this artifact, answerable from the registry
# alone via `docker buildx imagetools inspect` — without pulling it.
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=${{ github.ref_name }}
org.opencontainers.image.source=https://git.hanzo.ai/luxfi/explore
- name: Smoke — the image is a runnable build, and its stamp is not a lie
# v1.1.14 and v1.1.15 exist as package.json bumps with no image behind
# them, because CI has been unable to publish since July. The inverse
# failure — an image whose footer reports a version other than its own
# tag — is the one that misleads in production, since the footer is how
# anyone reads which build they are looking at. Both are checkable here
# from the image config, without booting: the entrypoint downloads
# external assets and validates runtime envs, so a bare `docker run`
# proves nothing about the build and fails for reasons of its own.
run: |
set -euo pipefail
img="${{ steps.meta.outputs.probe }}"
# The standalone server the CMD names must exist.
docker run --rm --entrypoint sh "$img" -c 'test -f server.js' \
|| { echo "::error::server.js missing — not a usable standalone build"; exit 1; }
# Client chunks must have been emitted; an empty .next/static means the
# browser bundle never built and every page would render blank.
chunks=$(docker run --rm --entrypoint sh "$img" -c 'ls -1 .next/static/chunks 2>/dev/null | wc -l')
echo "client chunks: ${chunks}"
if [ "${chunks}" -lt 1 ]; then
echo "::error::no client chunks in .next/static — the browser bundle is missing"
exit 1
fi
# The version baked at build time must be the version being published.
baked=$(docker run --rm --entrypoint sh "$img" -c 'printenv NEXT_PUBLIC_GIT_TAG' || true)
want='${{ steps.meta.outputs.git_tag }}'
echo "baked NEXT_PUBLIC_GIT_TAG=${baked:-<unset>} want=${want}"
if [ "${baked}" != "${want}" ]; then
echo "::error::image stamps ${baked:-<unset>} but is published as ${want} — the footer would misreport the build"
exit 1
fi
- 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
# A tag that names nothing pullable is how a Deployment ends up pinned to
# a phantom: the pin applies, the pod never starts, and the tag looks like
# a release. Prove it before the job is green.
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@luxfi/explore",
"version": "1.1.15",
"version": "1.1.16",
"private": false,
"homepage": "https://github.com/luxfi/explore#readme",
"engines": {
+5416 -491
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
// The browser's answer to a Node builtin.
//
// `configs/multichain/config.nodejs.ts` reaches for `fs`/`path` behind a
// `NEXT_RUNTIME === 'nodejs'` guard, and MetaMask's SDK reaches for a React
// Native storage module. Webpack was told to answer those with nothing
// (`resolve.fallback: { fs: false }`); Turbopack has no `fallback`, so it needs
// a module to point at. This is that module — same answer, both bundlers.
module.exports = {};