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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
hanzo.sh
|
||||
Reference in New Issue
Block a user