mirror of
https://github.com/hanzo-apps/agency.git
synced 2026-10-30 14:47:29 +00:00
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]>
105 lines
5.1 KiB
YAML
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 }}
|