Compare commits

...
6 Commits
Author SHA1 Message Date
hanzo-dev 5e0e4f906c hanzo-inc resolves to the KMS org it actually seals to
Hanzo CI/CD / cicd (push) Successful in 2m15s
CI/CD / gate (push) Successful in 2m15s
The owner -> KMS-org relation knew hanzoai, luxfi and zooai. hanzo-inc — pay
and the other company surfaces — fell through to the default branch and
became the literal owner, which is not a KMS org.

That was survivable by accident: the read takes its org from the TOKEN, so
the flat route still returned the right secret. What was wrong was the
fallback URL and, more expensively, every error message this step can print
— they named org=hanzo-inc, which sends whoever reads them looking for a
missing tenant rather than a missing credential.

One line, in the one place the comment above it already says this relation
belongs.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:40:56 -07:00
hanzo-dev f098b39ecd a documented example is not an expression to evaluate
Hanzo CI/CD / cicd (push) Successful in 46s
CI/CD / gate (push) Successful in 47s
`v1` has failed at load, on every caller in the fleet, since 0e1a56b. Not a
test failure — a startup failure: zero jobs, zero seconds, and GitHub showing
the file's PATH where its name should be, because it never got far enough to
read `name:`.

The `tests:` input's description quotes what a caller writes, and quoted it
literally:

    `tests: ${{ github.ref_type != 'tag' }}`

Prose or not, that is an expression, and it sits under `on.workflow_call.inputs`
where NO context exists. GitHub validates the whole file before it schedules
anything, refuses `github` there, and drops the run — so every repo importing
`hanzoai/ci@v1` stopped running CI entirely while reporting only a red X with
nothing inside it to read.

The irony is exact: 0e1a56b is titled "the test step has never run, and the gate
that would have said so never ran either." It made that true of every gate at
once.

Say it without the sigil. Same meaning to a reader, nothing to evaluate.
Confirmed with actionlint: clean at 23862e5c (before the break), the same error
at v1, v1.0.34 and v2, clean here.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 14:28:04 -07:00
hanzo-dev 674fabdcba split the server, do not strip a scheme you assumed
Hanzo CI/CD / cicd (push) Successful in 1m0s
CI/CD / gate (push) Successful in 1m0s
v1.0.33 built the forge substitution with `${SERVER#https://}`. GITHUB_SERVER_URL
on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's in-cluster
address, plain http — so the strip matched nothing, passed the whole URL through,
and produced `https://http://hanzo-git.hanzo.svc/hanzoai/`. git then went looking
for a host called `http`:

  fatal: unable to access 'https://http://hanzo-git.hanzo.svc/hanzoai/authz/':
  Could not resolve host: http

Take the scheme and the host as the two values they are, and compare the HOST
against github.com so the guard cannot be fooled by a spelling either. Checked
against http/https, in-cluster and public, with and without a trailing slash
before pushing this time.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 06:34:42 -07:00
hanzo-dev 3f65c9e24f the test gate reaches our modules at the address they live at
Hanzo CI/CD / cicd (push) Successful in 1m43s
CI/CD / gate (push) Successful in 1m44s
The gate's first real execution died in `go vet`, before one test ran, on
`github.com/hanzoai/dashscopego v0.6.0` — an indirect dependency of
hanzoai/cloud that git.hanzo.ai serves and github.com answers `Repository not
found` for.

A module path is a NAME, not an address. This step authenticated to github.com
and only github.com, so on a forge runner it dialled the mirror instead of the
canonical host. Its own caller already knew better: cloud's `containment` and
`reach` jobs each make exactly this substitution, carrying a comment about the
nine consecutive releases that one sibling module's drifted GitHub ACL once
blocked. The gate that runs the tests reached the modules through a different
door, so the law was written twice and applied where it was not needed.

go.sum is what makes it safe rather than merely convenient: the forge serves the
same objects, so the fetched zip hashes to the h1: line already committed, and a
forge serving different bytes fails the build loudly instead of shipping them.

Owner-scoped and host-derived — `${{ github.repository_owner }}` against
GITHUB_SERVER_URL — so it is the same statement for any org on any forge, and a
no-op on github.com or without FORGE_TOKEN. Longest prefix wins in git, so every
other module still resolves from github.com exactly as before.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 06:31:02 -07:00
zeekayandhanzo-dev 8e43277aab sitedeploy self-provisions its project — first run no longer fails
Hanzo CI/CD / cicd (push) Successful in 1m10s
CI/CD / gate (push) Successful in 1m11s
Every never-before-deployed site died at the enqueue: POST /v1/projects/<slug>/deploy
404s when the project does not exist yet, so committing a workflow was never
enough — someone had to POST /v1/projects out of band first, holding a token most
sites' authors don't have. sitedeploy now creates the project itself before the
enqueue: idempotent (200/201 first run, 409 forever after), repo linked because
the git-source enqueue requires it, analytics on by default. A committed workflow
is now sufficient to stand a new site up. The plan/test seam sits above this, so
the 17 offline tests are untouched — verified green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 18:54:01 -07:00
zeekayandClaude Fable 5 d3823df85a sitedeploy: one implementation of the Sites-plane publish
Hanzo CI/CD / cicd (push) Successful in 41s
CI/CD / gate (push) Successful in 42s
Every static surface on the estate was about to carry its own transcription of
enqueue → upload → complete: hanzo.ai, hanzo.app, hips, computer, and each one
after them. bin/imgver exists because that already happened once with image tags
— written eleven times, right ten times, and `sha-<short>` shipped for months.
This is that rule applied to the other half of the fleet.

The contract, once:

  POST /v1/projects/<slug>/deploy       -> 202 {id, bucket, prefix, upload}
  POST <upload.url> per object          -> the bytes, straight to S3
  POST .../deployments/<id>/complete    -> {status, files, bytes, keys}

The bytes never pass through the API: a real export is 128 MB against a 16 MiB
BodyLimit, so an artifact POST is refused by fasthttp before any handler runs and
fails as an opaque 400 that reads like a malformed payload rather than a size cap.

ONE credential. The 202 carries a presigned POST grant that is prefix-scoped
(S3 itself enforces starts-with $key), 30-minute, and size-bounded — so CI holds
no bucket key. Do not reintroduce SITES_S3_*: that was one long-lived key for a
bucket whose only tenant separation is the key prefix, so every repo holding it
could overwrite every org's site. Deletion moved server-side with it: `keys` is
the manifest cloud reconciles the prefix against, which is where
`aws s3 sync --delete` went, and both it and this script fail CLOSED on an empty
manifest rather than deleting a live site because a pipeline produced no lines.

Four defects found by running it end-to-end against hanzo.ai's real export
(8402 files, 105 MB, 54s) rather than by reading it:

  · the grant's `fields` already carries `key` — the starts-with PLACEHOLDER,
    not a destination. Forwarding it verbatim beside the real key posts `key`
    twice and S3 answers 400 for every object: 8402 files, 8402 failures.
  · `mapfile` is bash 4+; macOS ships 3.2, where it is `command not found`, the
    field array stays EMPTY, and the upload is rejected for a malformed
    X-Amz-Credential — an error pointing at the credential rather than at the
    array that was never built.
  · `[.\-_]` is how Go spells the fingerprint class; transcribed into `[[ =~ ]]`
    the backslash is literal inside a bracket expression, so it reads as the
    range \…_ and the shell rejects it. The `if` then merely evaluates false and
    every content-hashed asset silently loses `immutable`.
  · `stat -f%z || stat -c%s` cannot work: on GNU the first arm fails INSIDE a
    pipeline, awk still exits 0, the `||` never fires, and the byte count reports
    0. `wc -c` is the spelling both agree on.

Content-Type is sent per object because the policy carries no condition for it,
so what CI sends is what the object stores and what the edge serves — send
nothing and a browser downloads every page instead of rendering it. Cache-Control
mirrors cloud's apps/sites.CacheControlFor; sitedeploy_test.sh pins those exact
strings so the two cannot drift apart quietly.

Tests run offline: SITEDEPLOY_PLAN=1 stops before the first network call and
prints the manifest, so all 17 cases need no token, no bucket and no cluster.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:36:01 -07:00
5 changed files with 518 additions and 10 deletions
+60
View File
@@ -0,0 +1,60 @@
name: sitedeploy
description: Publish a built static export to the Hanzo PaaS Sites plane.
# For every repo whose deploy is "a static export goes live" — hanzo.ai, hanzo.app,
# hips, computer and the ones after them. Those were each about to carry their own
# copy of enqueue → upload → complete, which is how one contract becomes N
# transcriptions that drift (see the imgver action next door for the same story
# told about image tags).
#
# - uses: hanzoai/ci/.github/actions/sitedeploy@v1
# with: { slug: hanzo-ai, dir: out }
# env: { HANZO_DEPLOY_TOKEN: '${{ secrets.HANZO_DEPLOY_TOKEN }}' }
#
# A SITE IS NOT AN APP. This publishes files and stops: no image, no CR, no
# replicas, no registry. Building a container so a Go binary can serve /public is
# the shape the Sites plane exists to retire.
#
# ONE credential. The 202 hands back a prefix-scoped, 30-minute presigned POST
# grant, so CI never holds a bucket key — do NOT add SITES_S3_* here; that is the
# standing shared-bucket credential the grant replaced.
inputs:
slug:
description: The project slug on the Sites plane (POST /v1/projects/<slug>/deploy)
required: true
dir:
description: The built export directory
required: true
api:
description: Cloud API base
required: false
default: https://api.hanzo.ai
jobs:
description: Parallel uploads
required: false
default: '24'
runs:
using: composite
steps:
- name: Fetch sitedeploy
shell: bash
# The action ref is the script ref: an action pinned to @v1 runs v1's
# sitedeploy. Both forges, because this repo is served from each.
run: |
set -euo pipefail
ref="${GITHUB_ACTION_REF:-v1}"
for url in https://git.hanzo.ai/hanzoai/ci https://github.com/hanzoai/ci; do
git clone -q --depth 1 --branch "$ref" "$url" "$RUNNER_TEMP/sitedeploy-ci" 2>/dev/null && break
done
[ -x "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" ] \
|| { echo "::error::could not fetch hanzoai/ci@$ref (bin/sitedeploy)"; exit 1; }
- name: Deploy
shell: bash
env:
HANZO_API: ${{ inputs.api }}
SITEDEPLOY_JOBS: ${{ inputs.jobs }}
SITEDEPLOY_COMMIT: ${{ github.sha }}
SITEDEPLOY_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
bash "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" '${{ inputs.slug }}' '${{ inputs.dir }}'
+70 -5
View File
@@ -73,7 +73,7 @@ on:
A string sidesteps it on both planes with identical meaning, and the
comparison is against 'false' rather than a truthiness test so that the
expression callers already write — hanzoai/cloud's
`tests: ${{ github.ref_type != 'tag' }}`, which renders the strings
`tests:` set from `github.ref_type != 'tag'`, which renders the strings
"true"/"false" — keeps working unchanged.
type: string
default: 'true'
@@ -375,6 +375,15 @@ jobs:
if [ -z "$ORG" ]; then
case "${{ github.repository_owner }}" in
hanzoai) ORG=hanzo ;;
# hanzo-inc is the same TENANT as hanzoai — pay, commerce and the
# rest of the company surfaces bill and seal to KMS org `hanzo`.
# Without this line ORG fell through to the literal owner,
# "hanzo-inc", which is not a KMS org. That still LOOKED fine,
# because the read below takes its org from the TOKEN, not the URL
# — so the only things that were wrong were the fallback URL and
# every error message, which named an org that does not exist and
# sent the reader looking for a tenant instead of a credential.
hanzo-inc) ORG=hanzo ;;
luxfi) ORG=lux ;;
zooai) ORG=zoo ;;
*) ORG="${{ github.repository_owner }}" ;;
@@ -839,16 +848,72 @@ jobs:
# unset) — so `go vet` dies with `could not read Username for github.com`.
# Reuse the SAME token here for the runner's git (GIT_TOKEN, set by the
# KMS step above; GH_PAT fallback). No-op when neither is present.
#
# AND OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. A module path is a NAME,
# not an address: `github.com/<owner>/x` is what the package is called, and
# which host git dials for it is a separate decision. On a forge runner
# that decision is the forge — the forge is where the code canonically
# lives, and github.com is fed by a mirror that trails it, sometimes by
# hours and sometimes not at all.
#
# Measured, on the first run in which this step's gate had ever actually
# executed: hanzoai/cloud's go.mod carries `github.com/hanzoai/dashscopego
# v0.6.0` as an indirect dependency. git.hanzo.ai serves it; github.com
# answers `Repository not found`. So `go vet` died before a single test
# ran, on a module that was never missing. Its own caller already knew the
# answer — cloud's `containment` and `reach` jobs each make this exact
# substitution, with a comment about the nine consecutive releases an
# ACL drift on ONE sibling module once blocked — but the gate that runs
# its tests reached the modules through a different door.
#
# go.sum is what makes this safe rather than merely convenient: the forge
# serves the same objects, so the fetched zip hashes to the h1: line
# already committed. A forge serving different bytes fails the build
# loudly instead of shipping them.
#
# Longest prefix wins in git, so the owner-scoped forge rule outranks the
# bare github.com rule beneath it and every OTHER module still resolves
# from github.com. Both rules are optional and independent: no FORGE_TOKEN
# (or a github.com-hosted run, where the server IS github.com) leaves the
# old behaviour exactly as it was.
#
# SPLIT THE SERVER, do not strip a scheme you assumed. GITHUB_SERVER_URL
# on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's
# in-cluster address, plain http — and a `${SERVER#https://}` that only
# knows the https spelling passes it through whole, producing
# `https://http://hanzo-git.hanzo.svc/...` and a git that resolves the host
# `http`. Take the scheme and the host as the two values they are, and
# compare the HOST against github.com so the guard cannot be fooled by a
# spelling either.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
OWNER: ${{ github.repository_owner }}
run: |
set -uo pipefail
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -z "$TOKEN" ]; then echo "no git token — public modules only"; exit 0; fi
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
wrote=0
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
"https://github.com/${OWNER}/"
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
wrote=1
fi
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -n "$TOKEN" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
echo "github.com modules authenticated"
wrote=1
fi
[ "$wrote" = 1 ] || { echo "no git credential — public modules only"; exit 0; }
{ echo "GIT_CONFIG_GLOBAL=$CFG"; echo "GIT_CONFIG_NOSYSTEM=1"; } >> "$GITHUB_ENV"
echo "runner git authenticated for private Go modules"
+70 -5
View File
@@ -73,7 +73,7 @@ on:
A string sidesteps it on both planes with identical meaning, and the
comparison is against 'false' rather than a truthiness test so that the
expression callers already write — hanzoai/cloud's
`tests: ${{ github.ref_type != 'tag' }}`, which renders the strings
`tests:` set from `github.ref_type != 'tag'`, which renders the strings
"true"/"false" — keeps working unchanged.
type: string
default: 'true'
@@ -375,6 +375,15 @@ jobs:
if [ -z "$ORG" ]; then
case "${{ github.repository_owner }}" in
hanzoai) ORG=hanzo ;;
# hanzo-inc is the same TENANT as hanzoai — pay, commerce and the
# rest of the company surfaces bill and seal to KMS org `hanzo`.
# Without this line ORG fell through to the literal owner,
# "hanzo-inc", which is not a KMS org. That still LOOKED fine,
# because the read below takes its org from the TOKEN, not the URL
# — so the only things that were wrong were the fallback URL and
# every error message, which named an org that does not exist and
# sent the reader looking for a tenant instead of a credential.
hanzo-inc) ORG=hanzo ;;
luxfi) ORG=lux ;;
zooai) ORG=zoo ;;
*) ORG="${{ github.repository_owner }}" ;;
@@ -839,16 +848,72 @@ jobs:
# unset) — so `go vet` dies with `could not read Username for github.com`.
# Reuse the SAME token here for the runner's git (GIT_TOKEN, set by the
# KMS step above; GH_PAT fallback). No-op when neither is present.
#
# AND OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. A module path is a NAME,
# not an address: `github.com/<owner>/x` is what the package is called, and
# which host git dials for it is a separate decision. On a forge runner
# that decision is the forge — the forge is where the code canonically
# lives, and github.com is fed by a mirror that trails it, sometimes by
# hours and sometimes not at all.
#
# Measured, on the first run in which this step's gate had ever actually
# executed: hanzoai/cloud's go.mod carries `github.com/hanzoai/dashscopego
# v0.6.0` as an indirect dependency. git.hanzo.ai serves it; github.com
# answers `Repository not found`. So `go vet` died before a single test
# ran, on a module that was never missing. Its own caller already knew the
# answer — cloud's `containment` and `reach` jobs each make this exact
# substitution, with a comment about the nine consecutive releases an
# ACL drift on ONE sibling module once blocked — but the gate that runs
# its tests reached the modules through a different door.
#
# go.sum is what makes this safe rather than merely convenient: the forge
# serves the same objects, so the fetched zip hashes to the h1: line
# already committed. A forge serving different bytes fails the build
# loudly instead of shipping them.
#
# Longest prefix wins in git, so the owner-scoped forge rule outranks the
# bare github.com rule beneath it and every OTHER module still resolves
# from github.com. Both rules are optional and independent: no FORGE_TOKEN
# (or a github.com-hosted run, where the server IS github.com) leaves the
# old behaviour exactly as it was.
#
# SPLIT THE SERVER, do not strip a scheme you assumed. GITHUB_SERVER_URL
# on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's
# in-cluster address, plain http — and a `${SERVER#https://}` that only
# knows the https spelling passes it through whole, producing
# `https://http://hanzo-git.hanzo.svc/...` and a git that resolves the host
# `http`. Take the scheme and the host as the two values they are, and
# compare the HOST against github.com so the guard cannot be fooled by a
# spelling either.
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
env:
GH_PAT: ${{ secrets.GH_PAT }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
OWNER: ${{ github.repository_owner }}
run: |
set -uo pipefail
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -z "$TOKEN" ]; then echo "no git token — public modules only"; exit 0; fi
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
wrote=0
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
"https://github.com/${OWNER}/"
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
wrote=1
fi
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
if [ -n "$TOKEN" ]; then
GIT_CONFIG_GLOBAL="$CFG" git config --global \
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
echo "github.com modules authenticated"
wrote=1
fi
[ "$wrote" = 1 ] || { echo "no git credential — public modules only"; exit 0; }
{ echo "GIT_CONFIG_GLOBAL=$CFG"; echo "GIT_CONFIG_NOSYSTEM=1"; } >> "$GITHUB_ENV"
echo "runner git authenticated for private Go modules"
Executable
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
# sitedeploy — publish a built static export to the Hanzo PaaS Sites plane.
# One implementation, every static site.
#
# sitedeploy <slug> <dir> # e.g. sitedeploy hanzo-ai out
#
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL: the same forty lines of enqueue →
# upload → complete were about to be pasted into hanzo.ai, hanzo.app, hips,
# computer and every static surface after them. Written N times it is right N-1
# times and then wrong once — which is exactly how `sha-<short>` became the only
# tag eleven repos ever published (see bin/imgver). The plane's contract lives
# here, once.
#
# THE THREE STEPS, and why the bytes never pass through the API:
#
# POST /v1/projects/<slug>/deploy -> 202 {id, bucket, prefix, upload}
# POST <upload.url> per file -> the bytes, straight to S3
# POST /v1/projects/<slug>/deployments/<id>/complete
#
# A real export is large — hanzo.ai is 128 MB across 8403 files — and cloud's
# BodyLimit is 16 MiB, so an artifact POST is refused by fasthttp BEFORE any
# handler runs, and it fails as an opaque 400 "Error when parsing request" that
# reads like a malformed payload rather than a size cap. The git source is the
# documented route for exactly this shape.
#
# NO STANDING S3 CREDENTIAL. The 202 carries `upload`: a presigned POST policy
# that is prefix-scoped (starts-with $key "<org>/<slug>/", enforced by S3 itself),
# short-lived (30 min) and size-bounded. That replaced handing every repo the
# bucket's own long-lived access key — one key for a bucket whose only tenant
# separation is the key prefix, so every repo holding it could overwrite EVERY
# org's site (cloud apps/projects/grant.go). Do not reintroduce SITES_S3_* here.
# HANZO_DEPLOY_TOKEN is the ONE credential this needs.
#
# DELETION IS THE SERVER'S. The grant authorizes writes only, so CI cannot remove
# a file; `keys` in the completion is the manifest cloud reconciles the prefix
# against, and it deletes what the build no longer produces. That is where
# `aws s3 sync --delete` went. It fails CLOSED on an empty manifest, and so does
# this script — a build that enumerated nothing has failed, and honouring it
# literally would delete the live site.
#
# ENV: HANZO_DEPLOY_TOKEN (required) the org-scoped `sk-` key; mint at POST /v1/keys
# HANZO_API (https://api.hanzo.ai)
# SITEDEPLOY_JOBS (24) parallel uploads
# SITEDEPLOY_COMMIT / SITEDEPLOY_BRANCH recorded on the deployment
# SITEDEPLOY_PLAN=1 print the manifest and exit; no network. The test seam.
set -euo pipefail
slug="${1:?usage: sitedeploy <slug> <dir>}"
dir="${2:?usage: sitedeploy <slug> <dir>}"
api="${HANZO_API:-https://api.hanzo.ai}"
jobs="${SITEDEPLOY_JOBS:-24}"
[ -d "$dir" ] || { echo "::error::$dir is not a directory — the build produced no export"; exit 1; }
# ---- content type -----------------------------------------------------------
# The presigned POST carries no Content-Type condition, so whatever CI sends is
# what the object stores — and what it stores is what the edge serves. Send
# nothing and every page is application/octet-stream, which a browser DOWNLOADS
# instead of rendering: a green deploy that serves an unusable site.
ctype() {
case "${1##*.}" in
html|htm) echo 'text/html; charset=utf-8' ;;
css) echo 'text/css; charset=utf-8' ;;
js|mjs) echo 'text/javascript; charset=utf-8' ;;
json) echo 'application/json; charset=utf-8' ;;
xml) echo 'application/xml; charset=utf-8' ;;
txt) echo 'text/plain; charset=utf-8' ;;
svg) echo 'image/svg+xml' ;;
png) echo 'image/png' ;;
jpg|jpeg) echo 'image/jpeg' ;;
gif) echo 'image/gif' ;;
webp) echo 'image/webp' ;;
avif) echo 'image/avif' ;;
ico) echo 'image/x-icon' ;;
woff) echo 'font/woff' ;;
woff2) echo 'font/woff2' ;;
ttf) echo 'font/ttf' ;;
otf) echo 'font/otf' ;;
wasm) echo 'application/wasm' ;;
pdf) echo 'application/pdf' ;;
webmanifest) echo 'application/manifest+json' ;;
map) echo 'application/json' ;;
*) echo 'application/octet-stream' ;;
esac
}
# ---- cache control ----------------------------------------------------------
# Mirrors cloud's apps/sites.CacheControlFor so a site cached the same whichever
# route its bytes took. bin/sitedeploy_test.sh pins these strings; if the server
# changes its policy the test is what catches the drift.
#
# A fingerprinted asset (Vite/Next/webpack emit app.4f3a9c21.js) is immutable: a
# new build changes the hash, so the old URL can be cached forever.
#
# The class is written `[._-]` with the dash LAST and the pattern held in a
# variable. Go's regexp spells the same set `[.\-_]`, and transcribing that
# literally into `[[ =~ ]]` is a silent defect twice over: a backslash is literal
# inside a POSIX bracket expression, so `\-_` reads as the RANGE \…_ and the shell
# rejects it as "invalid character range" — and because the `if` merely evaluates
# false, every fingerprinted asset quietly fell back to max-age=3600 instead of
# immutable. An unquoted inline pattern also has its own quoting hazards; the
# variable form is the one that is read as a regex rather than a glob.
fingerprint_re='[._-][0-9a-fA-F]{8,}\.[a-z0-9]+$'
cachectl() {
local k="$1" base="${1##*/}"
case "${k##*.}" in
html|htm) echo 'public, max-age=60, s-maxage=86400' ;;
js|mjs|css|woff|woff2|png|jpg|jpeg|gif|svg|webp|avif|ico|ttf|otf|wasm|data|pck|unityweb|mem)
if [[ "$base" =~ $fingerprint_re ]]; then
echo 'public, max-age=31536000, immutable'
else
echo 'public, max-age=3600'
fi ;;
*) echo 'public, max-age=3600' ;;
esac
}
# ---- the manifest -----------------------------------------------------------
# Paths RELATIVE to the export root, which is exactly what the completion's
# `keys` must carry (cloud reconciles `keep[rel]` against them).
#
# CNAME does not travel. It is a GitHub Pages artifact that means nothing to S3
# and would ship a stale hostname claim into the bucket.
manifest=$(cd "$dir" && find . -type f ! -name CNAME | sed 's|^\./||' | LC_ALL=C sort)
count=$(printf '%s' "$manifest" | grep -c . || true)
[ "$count" -gt 0 ] || { echo "::error::$dir contains no files — refusing to deploy an empty manifest"; exit 1; }
if [ -n "${SITEDEPLOY_PLAN:-}" ]; then
printf 'slug=%s dir=%s files=%s\n' "$slug" "$dir" "$count"
while IFS= read -r k; do [ -n "$k" ] && printf '%s\t%s\t%s\n' "$k" "$(ctype "$k")" "$(cachectl "$k")"; done <<< "$manifest"
exit 0
fi
: "${HANZO_DEPLOY_TOKEN:?HANZO_DEPLOY_TOKEN is unset. It is the ONE credential this needs; set it on the forge (git.hanzo.ai), which is what reads .hanzo/workflows — not on GitHub. Mint: POST /v1/keys {\"type\":\"secret\"}}"
# ---- 0. ensure the project exists (self-provision) --------------------------
# A brand-new slug has no project, and the enqueue below 404s on a missing one —
# which is why every never-before-deployed site failed its first run. Create it
# idempotently here instead: 200/201 the first time, 409 every time after, both
# success. So a new site needs no out-of-band `POST /v1/projects`; committing the
# workflow is enough. The repo is linked because the git-source enqueue requires
# a linked project (deployGit rejects an unlinked one); SITEDEPLOY_REPO overrides
# the origin for a forge checkout whose remote is not the canonical repo.
repo="${SITEDEPLOY_REPO:-$(git remote get-url origin 2>/dev/null || true)}"
pcode=$(curl -sS -o /tmp/sd-proj.json -w '%{http_code}' \
-X POST "$api/v1/projects" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg s "$slug" --arg u "$repo" --arg b "${SITEDEPLOY_BRANCH:-main}" \
'{slug:$s, name:$s} + (if $u=="" then {} else {repo:{url:$u, branch:$b}} end)')")
case "$pcode" in
200|201|409) : ;; # created now, or already there — either is the state we need
*) echo "::error::ensure project $slug returned HTTP $pcode"; head -c 400 /tmp/sd-proj.json; echo; exit 1 ;;
esac
# ---- 1. enqueue -------------------------------------------------------------
# 202 Accepted is the success code: the deployment is queued, not live. bucket
# and prefix come FROM cloud (sitePrefix(org, slug) is server-side) — never guess
# them, or the upload lands where nothing is served the moment an org or slug
# changes.
code=$(curl -sS -o /tmp/sd-enq.json -w '%{http_code}' \
-X POST "$api/v1/projects/$slug/deploy" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
-d "{\"source\":\"git\",\"commit\":\"${SITEDEPLOY_COMMIT:-}\",\"branch\":\"${SITEDEPLOY_BRANCH:-main}\"}")
if [ "$code" != "202" ]; then
echo "::error::enqueue $api/v1/projects/$slug/deploy returned HTTP $code"; head -c 600 /tmp/sd-enq.json; echo; exit 1
fi
dep=$(jq -r '.id' /tmp/sd-enq.json)
prefix=$(jq -r '.prefix' /tmp/sd-enq.json)
upload_url=$(jq -r '.upload.url // empty' /tmp/sd-enq.json)
if [ -z "$upload_url" ]; then
echo "::error::the 202 carried no upload grant, so there is no way to write the bytes."
echo " cloud mints one only when presigning is configured (S3_ADMIN_* on the cloud deployment)."
exit 1
fi
jq -c '{id,version,status,bucket,prefix}' /tmp/sd-enq.json
# A build that dies after this point would leave the deployment "queued" and the
# project stuck "building" forever. Report the failure so cloud records an honest
# terminal state instead of a lie by omission.
fail() {
curl -sS -X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
-d '{"status":"error","message":"CI upload failed"}' >/dev/null 2>&1 || true
echo "::error::marked deployment $dep as error"
}
trap 'fail' ERR
# ---- 2. the bytes -----------------------------------------------------------
# `file` goes LAST: S3 ignores every field after the file part, so a grant field
# trailing the body is silently dropped and the signature check fails.
#
# `key` is DROPPED from the grant's fields and re-sent per object. The grant
# carries key="<org>/<slug>/" — the starts-with PLACEHOLDER, not a destination —
# and forwarding it verbatim alongside the real key posts `key` twice, which S3
# answers 400 for every object. That is the whole of the first end-to-end run:
# 8403 files, 8403 400s. Everything else in the map (bucket, policy, x-amz-*) is
# covered by the signature and must travel untouched.
jq -r '.upload.fields | to_entries[] | select(.key != "key") | "-F\n\(.key)=\(.value)"' /tmp/sd-enq.json > /tmp/sd-fields
put() {
local rel="$1" args=() line
# A read loop, NOT `mapfile`: mapfile is bash 4+, and macOS ships bash 3.2, so
# on a dev box it fails as `command not found`, the array stays EMPTY, and every
# upload goes out with no policy or signature at all. S3 then rejects it for a
# malformed X-Amz-Credential — an error that points at the credential rather
# than at the array that never got built.
while IFS= read -r line; do args+=("$line"); done < /tmp/sd-fields
curl -sS --fail-with-body -o /dev/null \
-X POST "$UP_URL" \
-F "key=$PREFIX/$rel" "${args[@]}" \
-F "Content-Type=$(ctype "$rel")" -F "Cache-Control=$(cachectl "$rel")" \
-F "file=@$DIR/$rel" \
|| { echo "::error::upload failed: $rel"; return 1; }
}
export -f put ctype cachectl
export UP_URL="$upload_url" PREFIX="$prefix" DIR="$dir" fingerprint_re
echo "uploading $count files to s3://$(jq -r .bucket /tmp/sd-enq.json)/$prefix ($jobs parallel)"
printf '%s\n' "$manifest" | grep . | xargs -P "$jobs" -I{} bash -c 'put "$@"' _ {}
# ---- 3. flip it live --------------------------------------------------------
# `wc -c`, not `stat`: the size flag is spelled -f%z on BSD and -c%s on GNU, and a
# `stat -f%z || stat -c%s` fallback does not work — on Linux the first arm fails
# INSIDE the pipeline, awk still exits 0, and the `||` never fires, so the byte
# count silently reports 0. wc is the one spelling both agree on.
bytes=$(cd "$dir" && find . -type f ! -name CNAME -exec wc -c {} + | awk '$2!="total"{s+=$1} END{print s+0}')
jq -n --arg c "${SITEDEPLOY_COMMIT:-}" --argjson f "$count" --argjson b "${bytes:-0}" \
--args '{status:"live",commit:$c,files:$f,bytes:$b,keys:$ARGS.positional}' \
$(printf '%s\n' "$manifest" | grep .) > /tmp/sd-done.json
code=$(curl -sS -o /tmp/sd-resp.json -w '%{http_code}' \
-X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
--data-binary @/tmp/sd-done.json)
trap - ERR
if [ "$code" != "200" ]; then
echo "::error::complete returned HTTP $code"; head -c 600 /tmp/sd-resp.json; echo; fail; exit 1
fi
jq -c '{status,liveUrl,version,files,bytes}' /tmp/sd-resp.json
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Tests for bin/sitedeploy. Runs OFFLINE: SITEDEPLOY_PLAN=1 stops the script
# before the first network call and prints the manifest it would upload, so every
# case here is deterministic and needs no token, no bucket and no cluster.
# Run: bash bin/sitedeploy_test.sh
set -uo pipefail
cd "$(dirname "$0")/.."
SD="$PWD/bin/sitedeploy"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
fail=0
plan() { SITEDEPLOY_PLAN=1 bash "$SD" a-slug "$1" 2>&1; }
t() { # t <name> <got> <want>
if [ "$2" = "$3" ]; then printf 'ok %-56s -> %s\n' "$1" "$2"
else printf 'FAIL %-56s -> %s (want %s)\n' "$1" "$2" "$3"; fail=1; fi
}
# field <dir> <key> <col> — the ctype (2) or cachectl (3) column for one key
field() { plan "$1" | awk -F'\t' -v k="$2" -v c="$3" '$1==k{print $c}'; }
site="$tmp/site"; mkdir -p "$site/assets" "$site/nested/deep"
echo '<h1>hi</h1>' > "$site/index.html"
echo 'body{}' > "$site/assets/app.4f3a9c21.css"
echo 'x' > "$site/assets/plain.css"
echo 'y' > "$site/assets/chunk-AB12CD34.js"
echo '{}' > "$site/data.json"
echo 'z' > "$site/nested/deep/page.html"
echo 'hanzo.ai' > "$site/CNAME"
# --- the manifest ------------------------------------------------------------
# Keys are RELATIVE to the export root: cloud reconciles keep[rel] against them,
# so a leading ./ or an absolute path would match nothing and the completion
# would prune the entire live site.
t "keys are relative, no leading ./" "$(plan "$site" | awk -F'\t' 'NR>1&&$1~/^\.?\//{print "ABS"}' | head -1)" ""
t "nested paths keep their subdirs" "$(plan "$site" | awk -F'\t' '$1=="nested/deep/page.html"{print "yes"}')" "yes"
# CNAME is a GitHub Pages artifact: it means nothing to S3 and would ship a stale
# hostname claim into the bucket.
t "CNAME does not travel" "$(plan "$site" | awk -F'\t' '$1=="CNAME"{print "leaked"}')" ""
t "file count excludes CNAME" "$(plan "$site" | head -1 | grep -o 'files=[0-9]*')" "files=6"
# --- content type ------------------------------------------------------------
# The presigned POST carries no Content-Type condition, so what CI sends is what
# the object stores and what the edge serves. Send nothing and a browser
# DOWNLOADS every page instead of rendering it.
t "html" "$(field "$site" index.html 2)" "text/html; charset=utf-8"
t "css" "$(field "$site" assets/plain.css 2)" "text/css; charset=utf-8"
t "js" "$(field "$site" assets/chunk-AB12CD34.js 2)" "text/javascript; charset=utf-8"
t "json" "$(field "$site" data.json 2)" "application/json; charset=utf-8"
# --- cache control: mirrors cloud apps/sites.CacheControlFor -----------------
# These strings are the SERVER's policy, pinned here so the two cannot drift
# apart silently. If cloud changes CacheControlFor, this is what goes red.
t "html is short-lived, long at the edge" "$(field "$site" index.html 3)" "public, max-age=60, s-maxage=86400"
t "unfingerprinted asset is an hour" "$(field "$site" assets/plain.css 3)" "public, max-age=3600"
# The regression this pins: Go spells the class [.\-_], and transcribing that
# into [[ =~ ]] makes the shell reject it as an invalid character range. The `if`
# then merely evaluates false, so every hashed asset silently lost `immutable`.
t "fingerprinted .hash. is immutable" "$(field "$site" assets/app.4f3a9c21.css 3)" "public, max-age=31536000, immutable"
t "fingerprinted -HASH- is immutable" "$(field "$site" assets/chunk-AB12CD34.js 3)" "public, max-age=31536000, immutable"
# --- fail closed -------------------------------------------------------------
# reconcilePrefix deletes whatever the manifest omits, so an empty manifest is a
# request to delete the live site. A build that enumerated nothing has failed.
empty="$tmp/empty"; mkdir -p "$empty"
plan "$empty" >/dev/null 2>&1
t "empty export is refused" "$?" "1"
only_cname="$tmp/onlycname"; mkdir -p "$only_cname"; echo x > "$only_cname/CNAME"
plan "$only_cname" >/dev/null 2>&1
t "a dir holding only CNAME is empty too" "$?" "1"
plan "$tmp/does-not-exist" >/dev/null 2>&1
t "missing export dir is refused" "$?" "1"
# --- the credential ----------------------------------------------------------
# Not in PLAN mode (that is the offline seam), but a real run must refuse to
# start rather than enqueue a deployment it cannot complete.
out=$(HANZO_DEPLOY_TOKEN= bash "$SD" a-slug "$site" 2>&1); rc=$?
t "no token: exits non-zero" "$rc" "1"
t "no token: says which secret" "$(printf '%s' "$out" | grep -c HANZO_DEPLOY_TOKEN)" "1"
[ $fail -eq 0 ] && echo "PASS" || echo "FAIL"
exit $fail