deploy: publish on push, and fail unless hanzo.sh actually serves it

hanzo.sh is a Cloudflare assets Worker that was published by hand. That is the
reason the installer fix in e300d78 never reached anyone: main was correct for
weeks while `curl hanzo.sh | bash` kept handing out a copy that predated it. The
repo and the bytes users get were two different states, and nothing in CI had an
opinion about the difference.

So the deploy runs on every push to main, and — the part that matters — it does
not pass because wrangler exited 0. It re-fetches https://hanzo.sh and compares
its md5 to the file just built, retrying while the edge revalidates, and fails
the job if they differ. A green deploy over a stale site is the failure being
fixed here; a deploy step that cannot detect it would reproduce it.

It also re-asserts the polyglot against the LIVE response, not just the artifact:
parses under dash, and carries no astral.sh or `uv tool install`. Cloudflare
serves this host with cf-cache-status: HIT, so the purge is attempted first, but
best-effort — the token may not carry Cache Purge, and the live-bytes check is
what actually decides.

Runs on GitHub's runner because this org registers no self-hosted runner and the
only copy of the Cloudflare credential is a GitHub org secret. It builds no
container image; the image path (Dockerfile -> hanzoai/static -> universe CR) is
a separate unfinished migration, documented in LLM.md.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Hanzo Dev
2026-08-01 11:32:25 -07:00
parent 56a87c061a
commit ac0964f8f1
+109
View File
@@ -0,0 +1,109 @@
name: deploy
# hanzo.sh is served by the Cloudflare assets Worker named `hanzo-sh`, and until
# this file existed it was published BY HAND (`pnpm build && npx wrangler@3
# deploy`). That is why a correct fix could sit merged on main for weeks while
# `curl hanzo.sh | sh` kept handing out the old installer: the repo was right and
# the bytes users got were not. A host that only updates when someone remembers
# is a host that is stale by default.
#
# So: every push to main republishes, and the job REFUSES to pass unless the live
# URL actually serves the new bytes. Deploying without checking is how you get a
# green build over a stale site — the exact failure this replaces.
#
# Runs on GitHub's runner: this org registers no self-hosted runner, and the only
# copy of the Cloudflare credential lives in GitHub org secrets. It builds no
# container image — the image path (Dockerfile -> hanzoai/static -> universe CR)
# is a separate, unfinished migration documented in LLM.md.
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-hanzo-sh
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: record the bytes users get right now
id: before
run: |
set -euo pipefail
echo "md5=$(curl -sS https://hanzo.sh | md5sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- run: pnpm build
# The same four asserts the Dockerfile makes. dist/index.html is a polyglot
# — an HTML document AND the installer — and both halves have to survive.
# dash as well as bash: the installer is POSIX and the page publishes
# `| sh`, and a bash-only check is what let `set -o pipefail` ship in a file
# whose first line says #!/bin/sh.
- name: the polyglot is still both things
run: |
set -euo pipefail
head -1 dist/index.html | grep -qx '#!/bin/sh'
grep -qx '<!DOCTYPE html>' dist/index.html
dash -n dist/index.html
bash -n dist/index.html
! grep -qiE 'astral\.sh|uv tool install' dist/index.html
- name: publish
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx wrangler@3 deploy
# Cloudflare answers this host with `cf-cache-status: HIT`, so a fresh
# deploy is not the same thing as a fresh response. Best-effort: the token
# may not carry Cache Purge, and the check below is what actually decides.
- name: purge the edge
continue-on-error: true
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
zone=$(curl -sS -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=hanzo.sh" \
| sed -n 's/.*"id":"\([0-9a-f]\{32\}\)".*/\1/p' | head -1)
[ -n "$zone" ] || { echo "no zone id — leaving it to revalidation"; exit 0; }
curl -sS -X POST -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' \
"https://api.cloudflare.com/client/v4/zones/$zone/purge_cache" | head -c 300
# The only statement that matters: what does the live URL hand a stranger?
- name: the live bytes are the new bytes
run: |
set -euo pipefail
want=$(md5sum dist/index.html | cut -d' ' -f1)
for i in $(seq 1 30); do
got=$(curl -sS https://hanzo.sh | md5sum | cut -d' ' -f1)
[ "$got" = "$want" ] && break
sleep 10
done
echo "before : ${{ steps.before.outputs.md5 }}"
echo "built : $want"
echo "live : $got"
[ "$got" = "$want" ] || {
echo "::error::hanzo.sh is still serving different bytes than were just built"
exit 1; }
# And it is still an installer, not just matching bytes.
curl -sS https://hanzo.sh -o /tmp/live.sh
dash -n /tmp/live.sh
! grep -qiE 'astral\.sh|uv tool install' /tmp/live.sh
echo "hanzo.sh serves the build from ${GITHUB_SHA:0:8}"