Release publishes tabs.hanzo.ai to the Sites plane from the forge

Both workflows live in .hanzo/workflows, which git.hanzo.ai runs. Release
installs with pnpm, typechecks, tests, exports, checks the sheet is in the
export, and publishes `out/` through hanzoai/ci's site action as project
`tabs`, the prefix the tabs.hanzo.ai route serves. The deploy key comes
from KMS; no bucket credential is held.

hanzo.yml's gates install with pnpm, since `catalog:` specs only resolve
there, and package-lock.json goes with npm.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-09-15 11:19:58 -07:00
co-authored by Hanzo Dev
parent 74c97542a0
commit ff7f517985
7 changed files with 84 additions and 10410 deletions
-122
View File
@@ -1,122 +0,0 @@
name: Release
# Publishes tabs.hanzo.ai.
#
# Tabs is a STATIC EXPORT, so a release is a bucket sync, not an image build. The
# server in front of it is hanzoai/static, pinned in the operator values file and
# released on its own cadence — so building a container here produced an image
# that nothing deployed, and the only way a change ever reached the site was by
# hand. That is what this replaces.
#
# The endpoint is the PUBLIC s3.hanzo.ai, not the in-cluster service address the
# serving pod uses: these runners are not in the cluster. Same bucket, same
# bytes, different address.
#
# Runs on Hanzo Git (git.hanzo.ai) native runners — see hanzoai/app/release.yml.
on:
push:
branches: [main]
paths-ignore: ['**.md', '.github/workflows/**']
workflow_dispatch:
permissions: { contents: write }
concurrency: { group: release-tabs, cancel-in-progress: false }
jobs:
publish:
runs-on: [hanzo-build-linux-amd64]
env:
BUCKET: tabs
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: npm ci --no-audit --no-fund
- name: Typecheck
run: npm run typecheck
- name: The layout engine and the bindings
run: npm test
- name: Export
run: npm run build
# A build that emitted no app page is a green run that publishes a blank
# site. Caught here rather than in a browser.
- name: The export is complete
run: |
set -euo pipefail
for f in index.html app/index.html auth/callback/index.html; do
[ -s "out/$f" ] || { echo "::error::missing or empty: out/$f"; exit 1; }
done
- name: Publish to the tabs bucket
# The credential comes from KMS, not from repo secrets. `SITES_S3_*` —
# the names this used to read — are in no org and in no KMS project, so
# the step failed closed on every run since it was written and tabs could
# only be updated by hand. Same pair the serving pod reads (k8s secret
# `s3-credentials`), so CI and the server agree on one identity.
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
run: |
set -euo pipefail
# The org rides the TOKEN, so the secret path is flat. An org-segmented
# /v1/kms/orgs/<org>/secrets/... answers 404, and `curl -fsS` reports
# that as a bare exit 22 that `set -e` turns into a step death with no
# message naming which call failed.
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT} — check KMS_CLIENT_ID/KMS_CLIENT_SECRET on this org"; exit 1; }
kms() { curl -fsS "${KMS_ENDPOINT}/v1/kms/secrets/s3-credentials/$1?env=${KMS_SECRET_ENV}" -H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.value // empty'; }
AWS_ACCESS_KEY_ID=$(kms access-key)
AWS_SECRET_ACCESS_KEY=$(kms secret-key)
if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
echo "::error::s3-credentials/{access-key,secret-key} missing in KMS (env ${KMS_SECRET_ENV}) — cannot sync."
exit 1
fi
echo "::add-mask::${AWS_ACCESS_KEY_ID}"
echo "::add-mask::${AWS_SECRET_ACCESS_KEY}"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
# The runner image ships no aws CLI and nothing in the reusable
# provisions one, so install it here or the step dies `aws: command
# not found` (exit 127) with a perfectly good credential in hand.
#
# NOT apt: noble keeps awscli in universe, which is not enabled on
# these runners — `apt-get install awscli` answers "Package 'awscli'
# has no installation candidate", which reads like a typo rather than a
# disabled component. AWS's own installer needs no repo and no sudo
# when pointed at a user prefix, and gives v2, which is what the sync
# flags below assume.
if ! command -v aws > /dev/null 2>&1; then
curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o /tmp/awscliv2.zip
unzip -q -o /tmp/awscliv2.zip -d /tmp
/tmp/aws/install -i "$HOME/.awscli" -b "$HOME/.local/bin" --update
export PATH="$HOME/.local/bin:$PATH"
fi
aws --version
# Assets first, HTML second, and the order is the point: the hashed
# _next/ files are immutable and safe to cache forever, while the HTML
# that names them must never be cached. Publishing the HTML first would
# briefly serve a page pointing at chunks that are not up yet.
aws s3 sync out/ "s3://${BUCKET}/" \
--endpoint-url https://s3.hanzo.ai --only-show-errors \
--exclude '*.html' --exclude '*.txt' \
--cache-control 'public,max-age=31536000,immutable'
# --delete so a page removed from the export stops being served;
# without it the bucket only ever grows.
aws s3 sync out/ "s3://${BUCKET}/" \
--endpoint-url https://s3.hanzo.ai --only-show-errors \
--delete --cache-control 'no-cache'
- name: Tag the release
run: |
set -euo pipefail
git fetch --tags --force --quiet
max="$(git tag -l 'v0.1.*' | sed 's/^v//' | sort -V | tail -1 || true)"
[ -n "$max" ] || max="0.1.0"
v="v0.1.$(( ${max##*.} + 1 ))"
git tag "$v"
git push origin "$v"
+61
View File
@@ -0,0 +1,61 @@
name: Release
# Publishes tabs.hanzo.ai.
#
# Tabs is a STATIC EXPORT, so a release is a site publish, not an image build.
# hanzoai/ci's site action uploads `out/` to the Sites plane as project `tabs`,
# which lands in s3://hanzo-sites/hanzo/tabs, the prefix the tabs.hanzo.ai route
# serves (universe charts/app/values/hanzo/static-sites.yaml). The action reads
# the deploy key from KMS and uploads under a prefix-scoped presigned grant, so
# no bucket credential belongs here.
on:
push:
branches: [main]
paths-ignore: ['**.md', '.hanzo/workflows/**']
workflow_dispatch:
concurrency: { group: release-tabs, cancel-in-progress: false }
jobs:
publish:
runs-on: [linux-amd64]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# package.json's `packageManager` is the one place pnpm is pinned.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: The layout engine and the bindings
run: pnpm test
- name: Export
run: pnpm build
# A build that emitted no app page is a green run that publishes a blank
# site. Caught here rather than in a browser.
- name: The export is complete
run: |
set -euo pipefail
for f in index.html app/index.html auth/callback/index.html; do
[ -s "out/$f" ] || { echo "::error::missing or empty: out/$f"; exit 1; }
done
# The component sheet is what makes the page drawn rather than bare.
grep -rlq -- '--hanzo-ui-styles' out/_next/static/css \
|| { echo "::error::@hanzo/ui/styles.css is not in the export"; exit 1; }
- name: Publish to the Sites plane
uses: hanzoai/ci/.github/actions/site@v1
with:
slug: tabs
dir: out
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
+4 -4
View File
@@ -63,10 +63,10 @@ by the machine, not by this site.
## Develop
```bash
npm install
npm run dev # http://localhost:3000
npm test # the layout engine and bindings
npm run typecheck
pnpm install
pnpm dev # http://localhost:3000
pnpm test # the layout engine and bindings
pnpm typecheck
```
MIT.
+5 -4
View File
@@ -1,9 +1,10 @@
// Serves the export the way the bucket does, so the guard tests what ships.
//
// hanzoai/static fronts the `tabs` bucket and resolves a directory to its
// index.html; trailingSlash: true in next.config.mjs is what makes every route a
// directory. This does the same two things and nothing else — a test server that
// resolves paths differently from production would pass on pages that 404 live.
// The ingress serves the export from s3://hanzo-sites/hanzo/tabs and resolves a
// directory to its index.html; trailingSlash: true in next.config.mjs is what
// makes every route a directory. This does the same two things and nothing
// else — a test server that resolves paths differently from production would
// pass on pages that 404 live.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { join, extname } from 'node:path';
+14 -13
View File
@@ -1,14 +1,12 @@
# Canonical CI/CD config for hanzoai/tabs — the one file both the hanzoai/ci
# reusable (.hanzo/workflows/cicd.yml) and platform.hanzo.ai read.
#
# Tabs IS deployed from this repo — `.hanzo/workflows/release.yml` syncs the
# static export to the `tabs` bucket on every push to main, and that is what
# tabs.hanzo.ai serves. There is no `images:` and no `deploy:` here because
# neither describes that: the server in front of the bucket is hanzoai/static,
# pinned in universe and released on its own cadence, so this repo builds no
# image and rolls out no workload. Read the absence as "nothing to build", not
# as "nothing ships" — it said the latter once, and cost an afternoon spent
# looking for a lane that was already there.
# Tabs IS deployed from this repo — `.hanzo/workflows/release.yml` publishes the
# static export to the Sites plane (project `tabs`) on every push to main, and
# that is what tabs.hanzo.ai serves. There is no `images:` and no `deploy:` here
# because neither describes that: the ingress serves the export straight from
# s3://hanzo-sites/hanzo/tabs, so this repo builds no image and rolls out no
# workload. Read the absence as "nothing to build", not as "nothing ships".
#
# The gate is typecheck, the jest suite, and the surface guard. Jest is
# deliberately narrow and says so in its own config — "pure logic only: the
@@ -30,11 +28,14 @@ test:
- name: build
run: |
set -e
npm ci
npm run typecheck
npm test
pnpm install --frozen-lockfile
pnpm typecheck
pnpm test
- name: surface
run: |
set -e
npx playwright install --with-deps chromium
npm run test:surface
# The runner image bakes browsers under /opt/playwright as root, so the
# job's own copy goes where the job may write.
export PLAYWRIGHT_BROWSERS_PATH="$PWD/.cache/ms-playwright"
pnpm exec playwright install chromium
pnpm test:surface
-10267
View File
File diff suppressed because it is too large Load Diff