mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
Publish docs.hanzo.ai from hanzo.yml's site lane through hanzoai/ci build.yml@v2
The forge's KMS identity is granted HANZO_DEPLOY_TOKEN at /deploy only (hanzoai/universe kms-grants.yaml). deploy.yml published through hanzoai/ci .github/actions/site@v1, which reads the flat /HANZO_DEPLOY_TOKEN, and KMS answers that read 403. build.yml@v2 reads the token at /deploy. - .hanzo/workflows/cicd.yml calls build.yml@v2 with submodules: recursive and the org's KMS_CLIENT_ID/SECRET, one run per ref at a time, never cancelled. - hanzo.yml declares site: (slug docs-hanzo-ai, dir apps/docs/out, build scripts/site.sh, on main) and a test: that runs the docs vitest project against the packages it imports, built first. - scripts/site.sh does what deploy.yml did: bun 1.3.11, pnpm install, the ingest key check, the package build, the export under a memory heartbeat, check-export.sh and the key grep. - deploy.yml is removed, so one lane publishes the prefix. - images: is removed. No route serves the image, and its build_secrets read PUBLISHABLE_KEY, which this identity is not granted. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
name: CI/CD
|
||||
|
||||
# Tests and publishes docs.hanzo.ai through hanzoai/ci, which reads hanzo.yml:
|
||||
# `test:` must pass, then `site:` builds apps/docs/out and publishes it to the
|
||||
# Sites plane. It runs on git.hanzo.ai (hanzoai/docs, a pull mirror of this
|
||||
# repo) with that org's KMS identity. RELEASE.md has the whole path.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# One publish at a time, and a push waits for the running one rather than
|
||||
# cancelling it: completing a deployment deletes the objects its manifest does
|
||||
# not name, so two publishes at once would each delete the other's files.
|
||||
# git.hanzo.ai applies this group to the run; it does not apply the reusable's
|
||||
# own job-level group.
|
||||
concurrency:
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v2
|
||||
with:
|
||||
# apps/docs/content/docs/studio is a submodule (hanzo-docs/studio-docs);
|
||||
# scripts/check-export.sh refuses an export without its pages.
|
||||
submodules: recursive
|
||||
secrets:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
@@ -1,155 +0,0 @@
|
||||
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 }}
|
||||
+47
-34
@@ -1,14 +1,15 @@
|
||||
# Publishing docs.hanzo.ai
|
||||
|
||||
One lane. A push to `main` builds the static export and publishes it to the
|
||||
Sites plane, and the bytes it uploads are what the site serves a second later.
|
||||
There is no image to tag and no pin to move.
|
||||
One lane. A push to `main` tests the docs app, builds the static export and
|
||||
publishes it to the Sites plane, and the bytes it uploads are what the site
|
||||
serves a second later. There is no image to tag and no pin to move.
|
||||
|
||||
push main
|
||||
-> git.hanzo.ai/hanzoai/docs a pull mirror of github.com/hanzo-docs/docs
|
||||
-> .hanzo/workflows/deploy.yml the forge reads this directory natively
|
||||
-> pnpm build --filter=docs NEXT_EXPORT=1 -> apps/docs/out
|
||||
-> hanzoai/ci .github/actions/site POST /v1/projects/docs-hanzo-ai/deployments
|
||||
push main -> github.com/hanzo-docs/docs
|
||||
-> git.hanzo.ai/hanzoai/docs a pull mirror, Actions unit on
|
||||
-> .hanzo/workflows/cicd.yml hanzoai/ci .github/workflows/build.yml@v2
|
||||
-> hanzo.yml `test:` vitest --project docs, on built packages
|
||||
-> hanzo.yml `site:` bash scripts/site.sh -> apps/docs/out
|
||||
-> hanzoai/ci bin/site POST /v1/projects/docs-hanzo-ai/deployments
|
||||
-> s3://hanzo-sites/hanzo/docs-hanzo-ai the prefix the docs.hanzo.ai route serves
|
||||
|
||||
The route is an `IngressRoute` + `Middleware` pair in hanzoai/universe
|
||||
@@ -18,46 +19,55 @@ replicas, no image. A site is files and a route.
|
||||
|
||||
The project is named for its host, as `hanzo-ai` is hanzo.ai's. `docs` is a
|
||||
reserved label on the Sites plane (cloud `apps/sites/reserved.go`), so no project
|
||||
can be created with it: the publish step asks for the project first, and that ask
|
||||
answered 400 "slug is a reserved subdomain" the first time a run got that far.
|
||||
can be created with it.
|
||||
|
||||
Every workflow lives in `.hanzo/workflows/` and runs on git.hanzo.ai, which reads
|
||||
that directory first. GitHub reads only `.github/workflows/`, so none of them can
|
||||
queue there (Actions is also disabled on hanzo-docs/docs).
|
||||
|
||||
## The credential
|
||||
|
||||
The publish job logs in to KMS with the forge `hanzoai` org's
|
||||
`KMS_CLIENT_ID`/`KMS_CLIENT_SECRET` (org-level Actions secrets on git.hanzo.ai,
|
||||
holder `hanzo-admin-agent`) and reads `HANZO_DEPLOY_TOKEN` at `/deploy`, env
|
||||
`prod`, in KMS org `hanzo`. KMS opens a value only to a holder that
|
||||
hanzoai/universe `charts/app/values/hanzo/kms-grants.yaml` grants it, and that
|
||||
holder is granted `/deploy` only: a read of the flat `/HANZO_DEPLOY_TOKEN`, which
|
||||
is what hanzoai/ci `.github/actions/site` does, answers 403 "may not read". No
|
||||
secret lives in this repo.
|
||||
|
||||
## Fire it
|
||||
|
||||
Push to GitHub `main`, then pull the forge and dispatch. Nothing does either on
|
||||
its own today, measured 2026-09-27: the forge's copy of `main` sat at
|
||||
`da72bf0527` for a day while GitHub moved (its `update_mirrors` cron logs every
|
||||
ten minutes and moves nothing), the repo carries no Actions unit so a push
|
||||
queues no run, and every recent deploy run is a `workflow_dispatch`.
|
||||
A push to GitHub `main` reaches the forge on its 10-minute mirror tick, and the
|
||||
mirrored push runs `cicd.yml`. To move it now, or to re-run without a push:
|
||||
|
||||
```sh
|
||||
curl -X POST -H "Authorization: token $FORGE_TOKEN" \
|
||||
https://git.hanzo.ai/v1/repos/hanzoai/docs/mirror-sync
|
||||
curl -X POST -H "Authorization: token $FORGE_TOKEN" \
|
||||
https://git.hanzo.ai/v1/repos/hanzoai/docs/actions/workflows/deploy.yml/dispatches \
|
||||
-d '{"ref":"refs/heads/main"}'
|
||||
https://git.hanzo.ai/v1/repos/hanzoai/docs/actions/workflows/cicd.yml/dispatches \
|
||||
-d '{"ref":"main"}'
|
||||
```
|
||||
|
||||
`FORGE_TOKEN` is minted on the forge pod, as the git user:
|
||||
`gitd admin user generate-access-token --username <you> --token-name <name>
|
||||
--scopes write:repository --raw`. The forge also takes your Hanzo IAM login in
|
||||
its place, `curl -u "<you>:$(hanzo auth token)"`, which carries
|
||||
`write:repository` and was answered 200 on `mirror-sync` and 204 on the
|
||||
dispatch (2026-09-28). KMS `deploy/FORGE_TOKEN` is `read:repository` only, and
|
||||
`mirror-sync` refuses it with 403. Run status: `GET
|
||||
/v1/repos/hanzoai/docs/actions/runs/<id>` with either credential, or
|
||||
`action_run` in the forge's Postgres (status 1 ok, 2 failed, 6 running).
|
||||
`FORGE_TOKEN` needs `write:repository`: mint one on the forge pod as the git user
|
||||
(`gitd admin user generate-access-token --username <you> --token-name <name>
|
||||
--scopes write:repository --raw`), or pass your Hanzo IAM login
|
||||
(`curl -u "<you>:$(hanzo auth token)"`). KMS `deploy/FORGE_TOKEN` is
|
||||
`read:repository` only, and `mirror-sync` refuses it with 403. Run status:
|
||||
`GET /v1/repos/hanzoai/docs/actions/runs?limit=20`, then `.../runs/<id>/jobs`
|
||||
and `.../actions/jobs/<job>/logs`.
|
||||
|
||||
One publish runs at a time (`concurrency: deploy-docs`, no cancel): a newer push
|
||||
One publish runs at a time (`concurrency: cicd-<ref>`, no cancel): a newer push
|
||||
waits for the running one, and the newest waiting commit is the one that
|
||||
publishes. Two at once would each delete the other's files on completion, because
|
||||
completing a deployment reconciles the prefix against the manifest it sends.
|
||||
publishes. Two at once would each delete the other's files on completion,
|
||||
because completing a deployment reconciles the prefix against the manifest it
|
||||
sends.
|
||||
|
||||
## What the job proves before it publishes
|
||||
|
||||
- **The docs tests pass.** `vitest --project docs` checks the SDK method names,
|
||||
MCP tool names, CLI commands and models the pages print against real generated
|
||||
clients, a real tools/list answer and the model catalogue.
|
||||
- **The ingest key resolves.** The page sends the hanzo org's publishable key
|
||||
from `@hanzo/event`'s keyring, resolved from its host (`packages/analytics`).
|
||||
The job resolves the same key for `docs.hanzo.ai` and POSTs it to `/v1/event`
|
||||
@@ -89,16 +99,19 @@ object store serves it.
|
||||
|
||||
## The image lane is cold
|
||||
|
||||
`ghcr.io/hanzoai/docs`, the root `Dockerfile`, `hanzo.yml`'s `images:` block and
|
||||
the `docs` app in hanzoai/universe are the previous way: an image wrapping the
|
||||
`ghcr.io/hanzoai/docs`, the root `Dockerfile` and the `docs` app in
|
||||
hanzoai/universe are the previous way: an image wrapping the
|
||||
same export, served by hanzoai/static in a pod. The pod still runs and **nothing
|
||||
routes to it** — the `docs.hanzo.ai` router names the Sites middleware. Do not
|
||||
build or pin it to publish; retiring it is a universe change.
|
||||
build or pin it to publish; hanzo.yml no longer declares the image, so no lane
|
||||
builds it. Retiring the pod is a universe change.
|
||||
|
||||
## Sibling sites in this repo
|
||||
|
||||
`apps/cloud` publishes Sites project `hanzo-cloud` through the same action
|
||||
(`.hanzo/workflows/deploy-cloud.yml`). The remaining `deploy-*-docs.yml`
|
||||
`apps/cloud` publishes Sites project `hanzo-cloud` through hanzoai/ci
|
||||
`.github/actions/site@v1` (`.hanzo/workflows/deploy-cloud.yml`), which reads the
|
||||
deploy token at the KMS org root, so under this org's identity it answers 403
|
||||
until it moves to a `site:` lane of its own. The remaining `deploy-*-docs.yml`
|
||||
workflows still `wrangler pages deploy` their app; each is the only deploy of its
|
||||
host, so they stay until that host has a Sites project of its own. The path for
|
||||
each is the one above: give the app `output: 'export'`, publish a slug, then move
|
||||
|
||||
@@ -1,87 +1,35 @@
|
||||
# Canonical CI config for hanzo-docs/docs. THREE programs read this file:
|
||||
# hanzo/platform (the webhook lane that builds what is serving), hanzoai/ci (the
|
||||
# GitHub lane, .github/workflows/cicd.yml), and hanzoai/cloud's forge push
|
||||
# orchestrator. All three read `images:`; only ci reads `test:`. They do not agree
|
||||
# on the tag — see below, because that disagreement is the whole contract.
|
||||
#
|
||||
# The repo builds ONE image: the Fumadocs monorepo exported static and served by
|
||||
# hanzoai/static. The recipe lives in the Dockerfile and nowhere else — APP
|
||||
# selects the workspace, so every static site here is built the same way. This
|
||||
# file only declares WHAT is built; it does not restate the recipe.
|
||||
#
|
||||
# THE TAG CONTRACT. hanzoai/universe pins this image in
|
||||
# charts/app/values/hanzo/docs.yaml, and the five builders that can produce it do
|
||||
# not agree on what to call it:
|
||||
#
|
||||
# platform webhook, THIS file <sha40>-amd64-docs ← what universe pins
|
||||
# POST /v1/runner whatever the caller names
|
||||
# hanzoai/ci (buildx) sha-<sha7>-amd64 + latest
|
||||
# hanzoai/ci (mode: delegate) sha-<sha7>-amd64
|
||||
# forge push orchestrator sha-<sha7>-amd64-docs (dormant)
|
||||
#
|
||||
# Three of those are three programs reading THIS file and disagreeing twice over:
|
||||
# whether an absent `tag-suffix` falls back to `name` (platform: yes; cloud's
|
||||
# apps/git/build_on_push.go: yes; hanzoai/ci: no, it reads `."tag-suffix" // ""`),
|
||||
# and whether the sha is abbreviated (platform: no; the other two: yes). Adding
|
||||
# `tag-suffix: docs` below would close the first disagreement and not the second
|
||||
# — deliberately NOT done, because it changes the tag shape of lanes that have
|
||||
# never run. RELEASE.md has the table and the reasoning.
|
||||
#
|
||||
# Whichever one publishes, the pin has to be told its name — and the line that
|
||||
# actually decides what runs is `digest:`, not `tag:`, because the chart renders
|
||||
# repo:tag@digest and a digest beside a tag is what gets pulled. Read the tag off
|
||||
# the build you actually fired; RELEASE.md writes out the edit for each of the five.
|
||||
images:
|
||||
- name: docs
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/docs
|
||||
# The KMS name IS the build-arg name. Without this the ci lane passes no key
|
||||
# and the Dockerfile gate refuses the build — which is the point: the two
|
||||
# lanes now either both supply it or both fail loudly.
|
||||
build_secrets: [PUBLISHABLE_KEY]
|
||||
# CI config for hanzo-docs/docs, read by hanzoai/ci build.yml (called from
|
||||
# .hanzo/workflows/cicd.yml on git.hanzo.ai). It ships one thing: the static
|
||||
# export of apps/docs, published to the Sites plane and served at docs.hanzo.ai.
|
||||
# RELEASE.md has the whole path.
|
||||
|
||||
test:
|
||||
- name: unit
|
||||
# vitest over packages/* and apps/docs. The apps/docs project is what pins
|
||||
# the four surfaces: the SDK method names the docs print are openapi-
|
||||
# generator's own derivation, and the MCP tool names are MCP's own, both
|
||||
# asserted against real generated clients and a real tools/list answer. A
|
||||
# drift in either naming rule fails here rather than teaching a call that
|
||||
# does not exist.
|
||||
run: pnpm install --frozen-lockfile && pnpm test
|
||||
- name: docs
|
||||
# The vitest project for apps/docs, against the workspace packages it
|
||||
# imports, built first because their package exports resolve to dist/. It
|
||||
# pins the surfaces the site prints: the SDK method names (openapi-
|
||||
# generator's own derivation) and the MCP tool names, asserted against real
|
||||
# generated clients and a real tools/list answer, plus the CLI commands and
|
||||
# models the first-call pages name. A drift in any of them fails here rather
|
||||
# than teaching a call that does not exist.
|
||||
run: >-
|
||||
pnpm install --frozen-lockfile &&
|
||||
pnpm turbo run build --filter='docs^...' &&
|
||||
pnpm vitest run --project docs
|
||||
|
||||
# NOTE — the export gate is not declared here, and not because it was forgotten.
|
||||
#
|
||||
# A static-export site fails by exporting NOTHING, which no image build can
|
||||
# notice: the layer is valid and the site is empty. So the check has to happen
|
||||
# somewhere every builder passes through, and `test:` is not it — hanzoai/ci
|
||||
# runs `test:` AFTER `docker buildx --push`, and buildx builds and pushes in one
|
||||
# invocation with no step in between to hold. A gate declared here would report a
|
||||
# broken export that was already in the registry.
|
||||
#
|
||||
# The gate is therefore inside the Dockerfile (scripts/check-export.sh, plus
|
||||
# apps/docs/export.require naming the sections that must not vanish). A failing
|
||||
# export fails the build, so no builder reaches a push — including this one.
|
||||
#
|
||||
# That placement is also what caught the one thing the GitHub lane got wrong on
|
||||
# its own. apps/docs/content/docs/studio is a submodule (hanzo-docs/studio-docs),
|
||||
# and hanzoai/ci checked out with actions/checkout's default, which fetches none:
|
||||
# the section simply would not exist in the output while page count and nav both
|
||||
# stayed green. It is named in export.require, so that lane failed loudly instead
|
||||
# of shipping a hole — and the fix is upstream, where hanzoai/ci now takes a
|
||||
# `submodules` input (default unchanged) that .github/workflows/cicd.yml sets to
|
||||
# `recursive`. The gate stays, because it is what makes the next such hole loud.
|
||||
# The publish. hanzoai/ci runs `build` on its publish job, a fresh runner, after
|
||||
# `test` is green, then uploads `dir` as Sites project `slug`: prefix
|
||||
# s3://hanzo-sites/hanzo/docs-hanzo-ai, which the docs.hanzo.ai route serves.
|
||||
# The project is named for its host because `docs` is a reserved label on the
|
||||
# Sites plane (cloud apps/sites/reserved.go). scripts/site.sh installs, builds
|
||||
# and checks the export; the checkout carries the submodules it needs
|
||||
# (cicd.yml passes submodules: recursive).
|
||||
site:
|
||||
slug: docs-hanzo-ai
|
||||
dir: apps/docs/out
|
||||
build: bash scripts/site.sh
|
||||
on: [main]
|
||||
|
||||
# No `deploy:` — ON PURPOSE, and this is not the usual "a human should review it".
|
||||
# docs.hanzo.ai is delivered by the hanzo/app chart with `cd.automated: true`, so
|
||||
# Argo is the sole writer of those objects and self-heals anything else that
|
||||
# touches them. A `deploy:` block here would have the platform patch a workload
|
||||
# that gets reverted on the next sync. The rollout IS the commit to universe.
|
||||
|
||||
# The KMS tenant this repo's build_secrets come from. Without it the owner-prefix
|
||||
# derivation yields no tenant on git.hanzo.ai, PUBLISHABLE_KEY fetches empty, and
|
||||
# the Dockerfile refuses to build. Same override console.hanzo.ai carries.
|
||||
# The KMS tenant the publish job reads its deploy token from.
|
||||
kms:
|
||||
org: hanzo
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
# here aborts the build BEFORE any push, whether
|
||||
# that push came from `docker buildx --push`, an
|
||||
# in-cluster BuildKit job, or a plain docker build.
|
||||
# .hanzo/workflows/deploy.yml on apps/docs/out, the export itself, before the
|
||||
# publish step uploads it to the Sites plane. That
|
||||
# lane builds no image; it gates the files.
|
||||
# scripts/site.sh on apps/docs/out, the export itself, before
|
||||
# hanzoai/ci uploads it to the Sites plane. That
|
||||
# lane builds no image; it checks the files.
|
||||
#
|
||||
# Usage: check-export.sh <export-dir> [require-file]
|
||||
#
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds apps/docs/out, the static export that hanzo.yml's `site:` publishes as
|
||||
# Sites project docs-hanzo-ai, and refuses an export that is not a site.
|
||||
#
|
||||
# hanzoai/ci runs it as the site build on its publish job: a fresh linux-amd64
|
||||
# runner with Node and corepack's pnpm, this repo checked out with its
|
||||
# submodules, and nothing installed. RELEASE.md has the whole path.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# build:pre and build:post run under bun. This exact version comes from the npm
|
||||
# registry, which also serves the platform binary its install links.
|
||||
bun_version=1.3.11
|
||||
if [ "$(bun --version 2>/dev/null || true)" != "$bun_version" ]; then
|
||||
bun_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/bun-$bun_version"
|
||||
npm install --silent --no-save --no-audit --no-fund --prefix "$bun_dir" "bun@$bun_version"
|
||||
export PATH="$bun_dir/node_modules/.bin:$PATH"
|
||||
fi
|
||||
echo "node $(node --version), pnpm $(pnpm --version), bun $(bun --version)"
|
||||
|
||||
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 the endpoint is asked before the build:
|
||||
# a lost answer (000/429/5xx) warns, a refusal stops the build.
|
||||
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
|
||||
answer="$(mktemp)"
|
||||
code="$(curl -s -o "$answer" -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 "$answer")"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# The workspace packages first, on their own, so a failure there is not read as
|
||||
# a failure of the site. `docs^...` is turbo's word for "what docs depends on,
|
||||
# not docs".
|
||||
pnpm turbo run build --filter='docs^...'
|
||||
|
||||
# The export, run directly rather than through turbo: turbo holds a task's output
|
||||
# until the task ends, so a build that dies midway would report nothing.
|
||||
#
|
||||
# The heartbeat prints memory and disk every 15 seconds, so a run that reaches
|
||||
# the runner's memory limit says so in its last line instead of stopping: the
|
||||
# cgroup's figure where the runtime exposes one, otherwise the sandbox's own
|
||||
# /proc/meminfo, then the five largest processes by resident memory.
|
||||
#
|
||||
# The compile peaks near 10 GB and the whole build, render workers included,
|
||||
# near 14 GB, inside a runner pod capped at 6 cores and 24Gi.
|
||||
# TURBO_TASKS_AVAILABLE_PARALLELISM=2 runs two Turbopack tasks and two loader
|
||||
# processes at once, which keeps the loaders near 1.2G together.
|
||||
( 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
|
||||
NEXT_EXPORT=1 HANZO_DOCS_SYNC=0 NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 TURBO_TASKS_AVAILABLE_PARALLELISM=2 \
|
||||
pnpm --filter docs build
|
||||
kill "$heartbeat" 2>/dev/null || true
|
||||
|
||||
sh scripts/check-export.sh apps/docs/out
|
||||
grep -rqF "$key" apps/docs/out \
|
||||
|| { echo "::error::the ingest key is not in the export"; exit 1; }
|
||||
Reference in New Issue
Block a user