Files
docs/.hanzo/workflows/deploy.yml
T
zeekay bb38ff54ad Send docs visitors to hanzo.ai, not hanzo.id, and count the plan click
The landing's "Sign up free" opened hanzo.id/signup, and "Sign in" opened
/login, whose authorize step sent the reader to hanzo.id too. No surface
sends a visitor there. Sign-in is hanzo.ai/login now (the /login address is
a redirect rule), sign-out clears the session in this browser instead of
carrying the reader to the issuer's end-session page, and the conversion
CTA reads "Choose plan" and opens hanzo.ai/pricing.

The click fires plan_clicked {source: 'docs'} on the client that counts the
page views, named from the @hanzo/events catalog that @hanzo/docs-analytics
now re-exports.

The ingest key comes from @hanzo/event's keyring, resolved from the page's
host, rather than a KMS value inlined at build. docs.hanzo.ai resolves the
hanzo org's key, the one the rest of the funnel reports under, and a sibling
docs site on a host no brand claims reports nothing rather than filing its
visitors under hanzo. The deploy resolves the same key, asks /v1/event
whether it names a project, and greps the export for it.
2026-09-29 21:44:35 -07:00

156 lines
6.9 KiB
YAML

name: deploy
# docs.hanzo.ai is a static export on the Sites plane. This job builds
# apps/docs/out and publishes it as project `docs-hanzo-ai`, whose prefix
# (hanzo/docs-hanzo-ai) the docs.hanzo.ai route serves. No image, no pod.
# RELEASE.md has the whole path.
#
# It runs on git.hanzo.ai (hanzoai/docs, a pull mirror of this repo), which reads
# .hanzo/workflows/. GitHub reads only .github/workflows/, so it runs none of it.
on:
push:
branches: [main]
paths:
- 'apps/docs/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- 'scripts/check-export.sh'
- '.hanzo/workflows/deploy.yml'
workflow_dispatch:
# One publish at a time: a newer push waits for the running one to finish, then
# the newest waiting commit publishes. Two publishes at once would each delete
# the other's files when they complete.
concurrency:
group: deploy-docs
cancel-in-progress: false
jobs:
site:
# A static export names no architecture; the label picks the host.
# linux-amd64 runs on the amd64 runner nodes, 32Gi each. linux-arm64 is a
# 4-core, 16Gi node the sandboxes share.
runs-on: linux-amd64
# A runner pod is capped at 6 cores and 24Gi. The compile used to peak at
# 24 GB in one process, because the API reference's code examples were
# highlighted into the bundle token by token, and the last failed run died
# in it. They are highlighted as each page renders now
# (apps/docs/lib/rehype-example.ts): measured with this job's settings, the
# compile peaks near 10 GB and the build, render workers included, near
# 14 GB, and the export is 6 GB rather than 19. It compiles two tasks wide
# and renders with two workers. The runner pod itself ends at 4 hours.
timeout-minutes: 210
steps:
- uses: actions/checkout@v4
with:
# Team sections (hanzo-docs/<team>) are submodules under
# apps/docs/content/docs/; without them whole sections are missing.
submodules: recursive
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
# build:pre and build:post run under bun. An exact version is fetched from
# its release by name; "latest" first asks api.github.com for the tag list,
# which answers the runner's shared address 403 once its anonymous rate
# limit is spent.
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
- run: pnpm install --frozen-lockfile
# The ingest key is the hanzo org's publishable key from @hanzo/event's
# keyring, which the page resolves from its own host (packages/analytics).
# This resolves it the same way for docs.hanzo.ai. A key that names a
# deleted project answers 403 while the site looks perfect, so ask the
# endpoint before building: a lost answer (000/429/5xx) warns, a refusal
# stops the run.
- name: ingest key
run: |
set -euo pipefail
key="$(cd packages/analytics && node --input-type=module \
-e "import { keyFor } from '@hanzo/event'; process.stdout.write(keyFor('docs.hanzo.ai') ?? '')")"
case "$key" in
pk-*) ;;
*) echo "::error::@hanzo/event resolves no pk- key for docs.hanzo.ai"; exit 1 ;;
esac
code="$(curl -s -o /tmp/ingest.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":[]}')" || code=000
case "$code" in
200) ;;
000|429|5*) echo "::warning::ingest endpoint answered $code; key unverified" ;;
*) echo "::error::the ingest key is refused ($code): $(cat /tmp/ingest.json)"; exit 1 ;;
esac
echo "INGEST_KEY=$key" >> "$GITHUB_ENV"
# The workspace packages first, on their own, so a failure here is not
# read as a failure of the site. `docs^...` is turbo's word for "what docs
# depends on, not docs".
- name: build the packages the app imports
run: pnpm turbo run build --filter=docs^...
# The export, run directly rather than through turbo: turbo holds a task's
# output until the task ends, and a build that dies mid-way then reports
# nothing at all — which is how the first run of this lane failed with a
# log that stopped seven minutes before the job did.
#
# The heartbeat is there for the same reason. It prints the memory in use
# every 15 seconds, so a run that hits the runner's memory limit says so
# in its last line instead of simply stopping: the cgroup's number where
# the runtime exposes one, and under visor, which does not, the sandbox's
# own /proc/meminfo. That figure leaves out what the pod is also charged
# for, visor's sentry (about 1.4G) and the page cache of files the job
# wrote. After it come the five largest processes by resident memory, so
# the line also says which part of the build holds it.
#
# TURBO_TASKS_AVAILABLE_PARALLELISM sets how many tasks Turbopack runs at
# once and how many Node processes it starts for loaders, each with its
# own heap. Two keeps the loaders near 1.2G together.
- name: build the export
run: |
set -euo pipefail
( while :; do
printf '[usage] %s mem=%s disk=%s%s\n' "$(date -u +%H:%M:%S)" \
"$(cat /sys/fs/cgroup/memory.current 2>/dev/null || awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} END{print (t-a)"k"}' /proc/meminfo)" \
"$(df -Pk . | awk 'NR==2{print $4"k free"}')" \
"$(awk '/^Name:/{n=$2} /^VmRSS:/{print $2, n}' /proc/[0-9]*/status 2>/dev/null | sort -rn | head -5 | awk '{printf " %s=%dM", $2, $1/1024}')"
sleep 15
done ) &
heartbeat=$!
trap 'kill "$heartbeat" 2>/dev/null || true' EXIT
pnpm --filter docs build
env:
NEXT_EXPORT: '1'
HANZO_DOCS_SYNC: '0'
NEXT_TELEMETRY_DISABLED: '1'
NODE_OPTIONS: --max-old-space-size=8192
TURBO_TASKS_AVAILABLE_PARALLELISM: '2'
- name: export gate
run: |
set -eu
sh scripts/check-export.sh apps/docs/out
grep -rqF "$INGEST_KEY" apps/docs/out \
|| { echo "::error::the ingest key is not in the export"; exit 1; }
# The slug is the host's name, as hanzo-ai is hanzo.ai's and cloud-hanzo-ai
# is cloud.hanzo.ai's. `docs` cannot be a project: it is a reserved label
# (cloud apps/sites/reserved.go), so creating it answers 400, and the first
# run to reach this step failed there with the export built and gated.
- name: publish
uses: hanzoai/ci/.github/actions/site@v1
with:
slug: docs-hanzo-ai
dir: apps/docs/out
jobs: '48'
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}