serve hanzo.sh from hanzoai/static, not Pages or a CF Worker

.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-sh:<sha> and does not
deploy — the operator CR in hanzoai/universe names which tag is live.

The GitHub Pages workflow it replaces never served this host: Pages reports
status null (no build ever succeeded) and hanzo.sh resolves to Cloudflare, where
an assets Worker answers. Its two jobs asked for hanzo-build-linux-amd64 and
hanzo-deploy-linux-amd64, retired labels nothing answers. public/CNAME goes with
it: that file is a Pages control file and means nothing to hanzoai/static.

The Dockerfile ends FROM ghcr.io/hanzoai/static:v0.5.1 with dist at /public, and
asserts the polyglot before the image exists: dist/index.html must keep its
`#!/bin/sh` first line, its `<!DOCTYPE html>`, and parse under bash -n, because
`curl hanzo.sh | bash` runs the same bytes the browser renders. Measured: static
returns the live 25426-byte polyglot byte-identical as text/html, and the served
response still runs the installer under bash.

wrangler.toml stays until the image is promoted; removing it first would strand
the live host.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 08:45:51 -07:00
co-authored by Hanzo Dev
parent 17550d6dbb
commit 39c4967259
6 changed files with 247 additions and 134 deletions
-57
View File
@@ -1,57 +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: hanzo-build-linux-amd64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Setup Pages
uses: actions/configure-pages@v5
- 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: hanzo-deploy-linux-amd64
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.sh.
# 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.sh/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
+44
View File
@@ -0,0 +1,44 @@
name: deploy
# Builds hanzo.sh 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 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-sh:${tag}"
echo "${REGISTRY_TOKEN}" | docker login ghcr.io -u hanzo-dev --password-stdin
# The Dockerfile asserts the polyglot contract, so a build that reaches
# push has already proved `curl hanzo.sh | bash` still parses.
docker build -t "${image}" .
docker push "${image}"
echo "published ${image} — set spec.image.tag in universe crs/hanzo-sh.yaml to ${tag}"
+32
View File
@@ -0,0 +1,32 @@
# hanzo.sh — a Vite build served by hanzoai/static, the house static server (a Go
# binary on scratch). No GitHub Pages, no Cloudflare: the site is an image the
# operator runs behind hanzoai/ingress like every other Hanzo surface.
#
# dist/index.html is a POLYGLOT — an HTML document AND the installer that
# `curl hanzo.sh | bash` executes. scripts/build-polyglot.js wraps the built HTML
# in a heredoc and appends public/install.sh. hanzoai/static answers with file
# bytes verbatim (Go ServeContent: no rewrite, no minify, no injection), so the
# contract survives being served from here — measured byte-identical against the
# live response, and the served bytes still run under bash.
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
# Both halves, or there is no image. A shell that cannot parse index.html breaks
# `curl hanzo.sh | bash`; a document that does not open with <!DOCTYPE html>
# breaks the browser. bash, not sh — the installer uses bash arrays, which is
# why the documented contract pipes into bash explicitly.
RUN head -1 dist/index.html | grep -qx '#!/bin/sh' \
&& grep -qx '<!DOCTYPE html>' dist/index.html \
&& bash -n dist/index.html
# hanzoai/static defaults to -port 3000 -root /public, so it needs no arguments.
# No -spa: the app has exactly one route, so a mistyped path must 404 rather than
# hand back the installer under a name that promises something else.
FROM ghcr.io/hanzoai/static:v0.5.1
COPY --from=build /src/dist /public
+84 -31
View File
@@ -1,37 +1,90 @@
# Hanzo Hanzo.Sh
# hanzo.sh
## Overview
**URL**: https://hanzo.app/projects/dedf1346-02b9-4baa-9273-6df78b702788/improve
The install surface: one page that is also one installer. `curl hanzo.sh | bash`
installs the Hanzo toolkit; opening hanzo.sh in a browser renders the landing
page. Both are the same file.
## Tech Stack
- **Language**: TypeScript/JavaScript
## The polyglot
`pnpm build` is `vite build && node scripts/build-polyglot.js`. The second step
rewrites `dist/index.html` into a file that is simultaneously:
- a shell script — line 1 is `#!/bin/sh`, line 2 opens a `<<\EOF` heredoc that
swallows the whole HTML document, and the installer (`public/install.sh`, minus
its own shebang) follows the closing `EOF`;
- an HTML document — the heredoc content is the real page, and `</html>` is
rewritten to `</html><!--` so the shell half is an HTML comment.
The installer body uses bash arrays, so the contract is `curl hanzo.sh | bash`,
not `| sh`. The Dockerfile asserts all three properties (shebang, `<!DOCTYPE
html>`, `bash -n`) and fails the build if any is lost — nothing downstream can
catch it, because everything downstream is bytes.
`public/install.sh` is also served at `/install.sh` for anyone who wants the
installer alone; `public/{bot,cli,agents,dev,full,mcp,node,python,rust}` are
per-bundle shims.
## 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 -> polyglot assert
-> FROM ghcr.io/hanzoai/static:v0.5.1
docker push ghcr.io/hanzoai/hanzo-sh:<short-sha>
-> hanzoai/universe infra/k8s/operator/crs/hanzo-sh.yaml
spec.image.tag: <short-sha> set by a human, never by the build
-> hanzoai/ingress hanzo.sh -> Service hanzo-sh:80 -> :3000
```
`hanzoai/static` is a Go binary on scratch. It defaults to `-port 3000 -root
/public` and answers with `http.ServeContent`, so a file goes out byte-for-byte
with no rewrite, minify or injection — which is the only reason a polyglot can be
served from an ordinary static server at all. It runs WITHOUT `-spa`: this app has
exactly one route, so a mistyped path must 404 rather than return the installer.
`HANZO_STATIC_CSP` in the CR is a response header only; it cannot touch the body.
It sets `script-src 'self'`, which also means the third-party
`the scaffold generator's injected script` tag still sitting in `index.html` does not load —
that is intended, and the app does not depend on it.
## Not live from here yet
hanzo.sh is served today by a Cloudflare assets Worker named `hanzo-sh`
(`wrangler.toml`, `custom_domain = true`), published by hand with
`pnpm build && npx wrangler@3 deploy`. `wrangler.toml` stays until the image is
promoted; deleting it first would strand the live host.
Two things must be reconciled BEFORE anyone pins a tag in the CR:
1. **The live bytes are not on `main`.** The running Worker was published from
commit `340fd8f`, which lives on `origin/rescue/main-local`, not on `main`.
`main` still `@import`s Geist from `cdn.jsdelivr.net` (`src/index.css` lines
1-2) where `rescue/main-local` self-hosts it. The CR's CSP therefore allows
`cdn.jsdelivr.net` for styles and fonts; land the self-hosting commit on
`main` and both entries come out, leaving the policy same-origin.
2. **Promotion order.** publish an image -> set `spec.image.tag` in
`crs/hanzo-sh.yaml` -> add `- hanzo-sh.yaml` to `crs/kustomization.yaml` ->
confirm the pod is Running and that `curl <pod>/ | bash -s -- --help` prints
the installer usage -> only then repoint hanzo.sh DNS off the Worker -> then
delete `wrangler.toml`.
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.
## Stack
Vite 5 + React 19 + Tailwind 4, one route (`src/pages/Index.tsx`). pnpm 9;
`pnpm-lock.yaml` is what the build resolves (`--frozen-lockfile`).
## Build & Run
```bash
pnpm install && pnpm build
pnpm test
pnpm install
pnpm dev # vite, :8080
pnpm build # -> dist/, including the polyglot rewrite
pnpm lint
```
## Structure
```
hanzo.sh/
LICENSE
README.md
bun.lockb
components.json
eslint.config.js
index.html
package-lock.json
package.json
pnpm-lock.yaml
postcss.config.js
public/
scripts/
src/
tailwind.config.ts
tsconfig.app.json
```
## Key Files
- `README.md` -- Project documentation
- `package.json` -- Dependencies and scripts
`scripts/polyglot.sh` is an unwired duplicate of `scripts/build-polyglot.js`;
only the `.js` is in `pnpm build`.
-1
View File
@@ -1 +0,0 @@
hanzo.sh