Files
zooqueenandHanzo Dev 1e77538df0 Analytics sends with the hanzo org key, so each batch is a CORS-simple request
The build read its key from a forge secret that was empty, so the bundle passed
ingestKey "" to @hanzo/event. With no key the SDK posts JSON with credentials
included, and api.hanzo.ai refuses that preflight for hanzo.agency. The site now
passes ORG_KEY.hanzo from the SDK's keyring: each batch goes out with
?ingest_key=, a text/plain body and credentials omitted, and the API answers 200.

The deploy workflow no longer passes the empty secret. The first pk- in the
bundle, which it tests at the ingest endpoint, is now the key the site sends.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 15:41:48 -07:00

105 lines
5.1 KiB
YAML

name: deploy
# hanzo.agency is a SITE, not an App: a static export with no compute. It ships on the
# Sites plane. Not GitHub Pages, not Cloudflare Pages, and not an image — baking
# files that never execute into a container so a Go binary can serve them means an
# image, a push, a tag pinned in a CR and running pods, all to hand back bytes.
#
# build -> dist
# -> site
#
# The publish mechanics are NOT here. They are bin/site in hanzoai/ci,
# reached through its action, and every static surface in the fleet calls that one
# script — ensure-project, enqueue, presigned per-file upload, complete. A
# contract transcribed per repo is a contract that drifts, and this file used to
# carry its own copy of every call. It also SELF-PROVISIONS: the project is
# created idempotently before the enqueue, so committing this file is enough to
# stand the site up.
#
# The deploy key is in KMS and the action reads it there, so this file names no
# token — only the KMS client identity that opens it. It used to pass
# secrets.HANZO_DEPLOY_TOKEN, a forge org secret holding a key that had stopped
# resolving, and a secret nobody can read back is a secret nobody can tell is
# dead. Both credentials are set ON THE FORGE: this file lives under
# .hanzo/workflows/, which only git.hanzo.ai reads, so GitHub's secret store is
# not in this path at all.
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' }}
jobs:
site:
# NOT ubuntu-latest. This file lives in .hanzo/workflows, which GitHub never
# reads — git.hanzo.ai is the only thing that can run it, and its fleet
# advertises linux-amd64. An unmatched label is not an error here, it is
# silence: the job queues until the 24h timeout and nothing says so.
runs-on: [linux-amd64]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: build the static export
run: |
set -euo pipefail
# pnpm-lock.yaml is lockfileVersion 9.0 and package.json pins
# [email protected]; pin the same major so CI and the lockfile cannot resolve
# different trees.
pnpm install --frozen-lockfile
pnpm build
test -s dist/index.html || { echo "::error::vite build produced no dist/index.html"; exit 1; }
# 404.html IS index.html here, and only because this is a client-routed
# SPA: every route below / is resolved by react-router in the browser and
# has no file of its own, so a miss must still boot the app. A real
# per-route export must NEVER do this — it makes a broken link answer 404
# while rendering the front page.
cp dist/index.html dist/404.html
echo "exported $(find dist -type f | wc -l) files"
# Assert on the BYTES THAT SHIP. src/analytics.tsx sends with
# ORG_KEY.hanzo from @hanzo/event, the first pk- in the bundle's keyring,
# so the key tested here is the key every pageview carries.
key=$(grep -rhoE 'pk-[A-Za-z0-9_-]{16,}' dist/assets | head -1 || true)
[ -n "$key" ] || {
echo "::error::no pk- publishable key in dist/assets — this bundle would 401 every anonymous event; refusing to publish"
exit 1
}
# Shape is not liveness. A key can be perfectly formed and name a
# deleted project, which is how three other hosts went dark for weeks
# while their builds stayed green. An empty batch is a no-op the door
# still authenticates: 200 live, 403 naming no project, 401 unreadable.
# Fail closed on an answer, warn on none — an unreachable door is a fact
# about the network, not about the key.
code=$(curl -s -o /tmp/door.json -w '%{http_code}' --max-time 20 \
-X POST "https://api.hanzo.ai/v1/event?ingest_key=$key" \
-H 'content-type: application/json' -d '{"batch":[]}' || echo 000)
case "$code" in
200) echo "the publishable key in this bundle resolves at the ingest door (200)" ;;
000) echo "::warning::could not reach the ingest door; proceeding (network, not the key)" ;;
*) echo "::error::the key in this bundle does not resolve: the door answered $code $(cat /tmp/door.json 2>/dev/null). Publishing would ship a site that loses every event."; exit 1 ;;
esac
# ONE credential. The 202 carries a prefix-scoped, 30-minute presigned POST
# grant, so nothing here holds a bucket key. Do NOT add SITES_S3_* — that is
# the standing shared-bucket credential the grant replaced, and it could
# overwrite every other org's site.
- name: Publish to the Sites plane
uses: hanzoai/ci/.github/actions/site@v1
with:
slug: agency
dir: dist
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}