serve hanzo.network from hanzoai/static, not Pages or CF Pages

.github/workflows now holds sync.yml alone, carrying refs to git.hanzo.ai; every
build runs natively there from .hanzo/workflows.

.hanzo/workflows/deploy.yml publishes ghcr.io/hanzoai/hanzo-network:<sha> and does
not deploy — the operator CR in hanzoai/universe names which tag is live.

The GitHub Pages workflow it replaces is dead, not moved: Pages did not survive
the hanzoai -> hanzo-apps transfer, so the repo's pages API is 404 and every run
since 2026-03-04 ends "Failed to create deployment (status: 404) … Ensure GitHub
Pages has been enabled". Relocating a guaranteed 404 to the forge buys nothing.
public/CNAME goes with it: that file is a Pages control file, and neither
hanzoai/static nor Cloudflare reads it.

The live host is a Cloudflare Pages project built through CF's Git integration,
outside every workflow file here — nothing in this commit changes what serves
today.

The Dockerfile ends FROM ghcr.io/hanzoai/static:v0.5.1 with dist at /public and
runs it with -spa: 98 BrowserRouter routes plus the app's own path="*" NotFound
need the fallback, or every deep link 404s. Measured against the image: -spa
returns 200 for /account/billing, and without it a missing path 404s.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 08:46:57 -07:00
co-authored by Hanzo Dev
parent 252b9a3b22
commit 1c0c67e696
6 changed files with 191 additions and 99 deletions
-52
View File
@@ -1,52 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+87 -45
View File
@@ -1,60 +1,102 @@
name: Sync to git.hanzo.ai
# DIRECTION: this repo on git.hanzo.ai is a PULL MIRROR fetching github.com/hanzoai/hanzo.network.
# GitHub is UPSTREAM, git.hanzo.ai is DOWNSTREAM. The giveaway is what this file does:
# mirror-sync tells Gitea to PULL, which only makes sense if GitHub is the source.
name: sync
# Carries pushed refs to git.hanzo.ai, the canonical forge, and does nothing
# else: build, test and release run natively there.
#
# This matters beyond labeling: a pull mirror force-updates its refs from upstream on
# every fetch, so anything committed directly on git.hanzo.ai is DISCARDED on the next
# sync. Do not treat the mirror as writable, and do not reword this into a "native on
# git.hanzo.ai" claim — that is the intended end state, not today's.
#
# What IS true: CI/CD runs on git.hanzo.ai — arcd executes .hanzo/workflows/ on the
# Hanzo git-runner. Making git.hanzo.ai canonical means converting the repo
# pull-mirror -> native and flipping the write path; an ops migration, not a config edit.
#
# So this nudge is a LATENCY fix, not a canonicality one: it asks Gitea to pull now
# instead of waiting out the ~10-minute mirror interval, so arcd sees the new commit
# sooner. Without this file the repo still mirrors — just later.
#
# IDEMPOTENT: mirror-sync just tells Gitea to pull HEAD, so N nudges == 1 (same latest
# state); concurrency coalesces a burst of pushes to a single in-flight sync. FAST: no
# checkout, one bounded curl, hard timeouts. FAIL-SOFT: a missing token or hiccup never
# fails the push (the App webhook still mirrors). Set HANZO_GIT_TOKEN to enable the nudge.
# Repo-agnostic. owner/name come from ${{ github.repository }}, so this file is
# dropped into every repo unchanged as .github/workflows/sync.yml and is the
# only workflow GitHub is permitted to run.
on:
push:
branches: [main]
# '**' matches slashes; '*' does not, and would skip feature/foo.
branches: ['**']
tags: ['**']
workflow_dispatch:
# Only the newest push's sync needs to run — cancel any older in-flight one (it would
# pull a now-stale HEAD). This is what makes rapid pushes idempotent + cheap.
# Serialize per ref. Never cancel: a cancelled run drops that ref and nothing
# reconciles it afterwards.
concurrency:
group: sync-git-hanzo-ai
cancel-in-progress: true
group: sync-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
sync:
# `.github/workflows` is also on Gitea's WORKFLOW_DIRS, so with Actions enabled on
# the mirror this file runs THERE too on every sync — where asking git.hanzo.ai to
# pull is circular. github.server_url is setting.AppURL on Gitea
# (services/actions/context.go:89), so this is the exact test for "am I upstream".
if: ${{ github.server_url == 'https://github.com' }}
# .github/workflows is also on the forge's own workflow search path, so this
# file executes there too in any repo without .hanzo/workflows, where it
# would push the forge to itself. server_url is the forge's AppURL, which
# makes this the test for "running on GitHub".
if: github.server_url == 'https://github.com'
# GitHub-hosted on purpose. This is the one workflow that must run ON GitHub,
# and the self-hosted pool that used to serve it is retired — a job asking for
# it now queues forever against a label nothing answers. Carrying refs is a
# push, not a build, so it does not belong on the build fabric anyway: builds
# run natively on git.hanzo.ai, which is the whole point of this file.
runs-on: ubuntu-latest
timeout-minutes: 2
timeout-minutes: 15
steps:
- name: Nudge git.hanzo.ai to pull HEAD (mirror-sync)
continue-on-error: true
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Push refs to git.hanzo.ai
env:
HANZO_GIT_TOKEN: ${{ secrets.HANZO_GIT_TOKEN }}
FORGE: https://git.hanzo.ai
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
set -u
if [ -z "${HANZO_GIT_TOKEN:-}" ]; then
echo "HANZO_GIT_TOKEN not set — relying on the mirror App webhook. Skipping nudge."
exit 0
set -euo pipefail
if [ -z "${GIT_TOKEN}" ]; then
echo "::error::GIT_TOKEN is unset, so this push never reached the canonical forge. Add the org secret."
exit 1
fi
if curl -fsS --max-time 20 --retry 2 --retry-delay 2 -X POST \
-H "Authorization: token ${HANZO_GIT_TOKEN}" \
"https://git.hanzo.ai/api/v1/repos/hanzoai/hanzo.network/mirror-sync"; then
echo "git.hanzo.ai mirror-sync triggered"
else
echo "mirror-sync nudge failed (non-fatal) — the App webhook still mirrors"
REMOTE="${FORGE}/${GITHUB_REPOSITORY}.git"
# The forge reads the token from the basic-auth password and ignores
# the username. base64 of a secret is not masked automatically.
AUTH="$(printf 'x-access-token:%s' "${GIT_TOKEN}" | base64 -w0)"
echo "::add-mask::${AUTH}"
# Host-scoped, so the forge token is never sent to github.com.
git config --local "http.${FORGE}/.extraheader" "Authorization: Basic ${AUTH}"
# Reachability is the only transient failure here, so it is the only
# thing retried. ls-remote exits 0 on an existing empty repo and
# non-zero when the forge is down or the repo is absent.
ERR="$(mktemp)"
REACHED=""
for attempt in 1 2 3; do
if git ls-remote "${REMOTE}" >/dev/null 2>"${ERR}"; then
REACHED=1
break
fi
echo "forge probe failed (attempt ${attempt}/3): $(cat "${ERR}")"
if [ "${attempt}" -lt 3 ]; then
sleep $((attempt * 5))
fi
done
if [ -z "${REACHED}" ]; then
echo "::error::git.hanzo.ai is unreachable or ${GITHUB_REPOSITORY} does not exist there. $(cat "${ERR}")"
exit 1
fi
# Additive and fast-forward-only: no leading '+', no --force, no
# --prune, no --mirror. Tags go every run so a tag that missed its own
# event still lands; git refuses to move one that already differs.
REFS=('refs/tags/*:refs/tags/*')
case "${GITHUB_REF}" in
refs/heads/*) REFS+=("${GITHUB_REF}:${GITHUB_REF}") ;;
esac
# Not --atomic: a diverged ref must not hold back the refs that are
# clean. Rejections still fail the job.
if ! git push "${REMOTE}" "${REFS[@]}"; then
echo "::error::git.hanzo.ai rejected a ref for ${GITHUB_REPOSITORY}. The forge is canonical and is never force-updated from here; reconcile the diverged branch or tag by hand."
exit 1
fi
+42
View File
@@ -0,0 +1,42 @@
name: deploy
# Builds hanzo.network and publishes the image the operator runs. This replaces
# the GitHub Pages workflow that used to live in .github/workflows: the site is
# served by hanzoai/static behind hanzoai/ingress, so there is no Pages and no
# Cloudflare Pages in the path.
#
# git.hanzo.ai runs this file natively. The image tag is the run's commit, and
# the operator CR in hanzoai/universe names which tag is live — a build never
# deploys itself.
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
image:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: build and push
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [ -z "${REGISTRY_TOKEN:-}" ]; then
echo "::error::REGISTRY_TOKEN is unset, so nothing was published. Add the org secret."
exit 1
fi
tag="$(git rev-parse --short HEAD)"
image="ghcr.io/hanzoai/hanzo-network:${tag}"
echo "${REGISTRY_TOKEN}" | docker login ghcr.io -u hanzo-dev --password-stdin
docker build -t "${image}" .
docker push "${image}"
echo "published ${image} — set spec.image.tag in universe crs/hanzo-network.yaml to ${tag}"
+18
View File
@@ -0,0 +1,18 @@
# hanzo.network — a Vite SPA served by hanzoai/static, the house static server (a
# Go binary on scratch). No GitHub Pages, no Cloudflare Pages: the site is an
# image the operator runs behind hanzoai/ingress like every other Hanzo surface.
FROM node:22 AS build
WORKDIR /src
RUN npm i -g pnpm@9
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# hanzoai/static defaults to -port 3000 -root /public. -spa is required: this is a
# 98-route BrowserRouter with its own `path="*"` NotFound, so an unknown path must
# reach index.html for the router to resolve it. Without it every deep link 404s.
FROM ghcr.io/hanzoai/static:v0.5.1
COPY --from=build /src/dist /public
ENTRYPOINT ["/static", "-spa"]
+44 -1
View File
@@ -46,8 +46,51 @@ The homepage highlights:
- Provider marketplace (contribute idle compute, set prices)
- Stats: 100K+ GPU hours, 50+ regions, 99.9% SLA, $0.10/GPU-hour
## Serving chain
```
push to main
-> .github/workflows/sync.yml carries refs to git.hanzo.ai (nothing else)
-> .hanzo/workflows/deploy.yml runs natively on the forge
docker build . Dockerfile: pnpm build
-> FROM ghcr.io/hanzoai/static:v0.5.1
docker push ghcr.io/hanzoai/hanzo-network:<short-sha>
-> hanzoai/universe infra/k8s/operator/crs/hanzo-network.yaml
spec.image.tag: <short-sha> set by a human, never by the build
-> hanzoai/ingress hanzo.network -> Service hanzo-network:80 -> :3000
```
`hanzoai/static` is a Go binary on scratch, run with `-spa` because this is a
98-route `BrowserRouter`: without the fallback every deep link 404s instead of
reaching `index.html` for the router (and for the app's own `path="*"`
NotFound). `HANZO_STATIC_CSP` in the CR allows `fonts.googleapis.com` /
`fonts.gstatic.com` for the Inter stylesheet `index.html` links, and
`api.hanzo.ai` in `connect-src` for the pricing fetch and the chat widgets.
## Not live from here yet
hanzo.network is served today by the Cloudflare Pages project `hanzo-network`
(`hanzo-network.pages.dev` is byte-identical to the live host), built through CF's
Git integration — outside every workflow file in this repo. That is the deploy
path this migration retires.
Reconcile BEFORE anyone pins a tag in the CR: **the live bytes are not built from
`main`.** The served `index.html` links `/favicon.svg`, `/favicon.ico` and
`/logo.svg`; `main`'s still links `img/28d53ec4-….png`. Those exact
lines come from `origin/fix/broken-links` (`0efe650`, the real @hanzo/brand
blocky-H). Building an image from `main` as it stands would regress branding, so
land that branch first. CF Pages is not rebuilding `main` on push — `main` has
been pushed repeatedly since 2026-06-23 and the live bytes never changed — which
is also why pushing this migration cannot disturb the live site.
Promotion order: publish an image -> set `spec.image.tag` in
`crs/hanzo-network.yaml` -> add `- hanzo-network.yaml` to
`crs/kustomization.yaml` -> confirm the pod is Running and a deep link returns
200 -> only then repoint hanzo.network DNS off CF Pages. The CR is committed
INERT (empty tag, absent from `kustomization.yaml`); promoting an App with no
image tag takes the host down instead of leaving it alone.
## Notes
- Shares the same component library and routes as hanzo.app, hanzo.id, hanzo.one, and sensei.group. Only `NetworkLanding.tsx` and `index.html` metadata are unique.
- No Cloudflare Pages middleware (unlike hanzo.id). Standard static SPA deployment.
- Brand color is cyan (#06b6d4) in the dev console message, vs red for other Hanzo sites.
-1
View File
@@ -1 +0,0 @@
hanzo.network