mirror of
https://github.com/hanzoai/ci.git
synced 2026-10-27 09:19:34 +00:00
A comment written as a changelog — "this used to sit fourth", "the lane it replaced was dead", "until this file existed" — asks the reader to hold two versions of the code in their head and only describes one of them. The version it describes is the one that is gone. Each of these now states the rule the code enforces today and the mechanism that makes it necessary, which is the part that was doing the work: a `while read` at the end of a pipeline is a child shell, so `set -e` cannot reach the step; /deploy reads any body as bytes, so JSON sent there is sniffed as a tar; /v1/keys answers identically for a bogus and a valid machine key, so it cannot gate a publish. All 15 gates in hanzo.yml pass.
261 lines
16 KiB
Bash
Executable File
261 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# sitepublish — publish a built static export as an immutable Release on the
|
|
# Hanzo Sites plane. One implementation, every `site:` in the fleet.
|
|
#
|
|
# sitepublish <slug> <dir> # e.g. sitepublish hanzo-console out
|
|
#
|
|
# WHY THIS EXISTS: the bytes travel cloud's own publish routes on the credential
|
|
# CI already holds. Staging straight into hanzoai/s3 would need an admin access
|
|
# key and secret, names KMS holds for no org — so that lane fails closed for
|
|
# every caller, and repos that need to ship grow their own build→zip→publish
|
|
# workflow. Per-repo build logic is the one thing this repo exists to prevent.
|
|
#
|
|
# THE TWO STEPS:
|
|
#
|
|
# POST /v1/projects/<slug>/deploy Content-Type: application/zip, zip as body
|
|
# POST /v1/sites/<slug>/publish {"source":"<slug>"}
|
|
#
|
|
# The first lands the export at the org's site prefix; the second promotes that
|
|
# prefix into an immutable Release and flips it live. `source` is ORG-RELATIVE
|
|
# and the org segment is prepended server-side from the validated principal
|
|
# (cloud apps/projects/blob.go:60, `sitePrefix(org, slug) = org + "/" + slug`),
|
|
# which is why "<slug>" is the whole of it and why a caller cannot address
|
|
# another tenant's bytes by writing a longer path.
|
|
#
|
|
# ONE CREDENTIAL, ALREADY MINTED. HANZO_API_TOKEN is the IAM JWT the workflow's
|
|
# KMS step already holds: cloud's /v1/kms/auth/login is a broker that performs
|
|
# the IAM client_credentials exchange and returns IAM's own JWT verbatim, so the
|
|
# token that reads this org's secrets is the token that publishes this org's
|
|
# site. api.hanzo.ai validates it and mints X-Org-Id from the `owner` claim —
|
|
# identity is never a header this script sends, so there is nothing here to
|
|
# spoof. Do NOT reintroduce S3_ADMIN_*; a standing bucket key was what this
|
|
# replaced.
|
|
#
|
|
# THE SIZE BOUNDARY IS THE SERVER'S, NOT A PREFERENCE. cloud's public edge sets
|
|
# BodyLimit from GATEWAY_BODY_LIMIT, default 16 MiB (cloud internal/edge/edge.go
|
|
# :38, bound to the door's fasthttp socket at cmd/cloud/main.go:124), and
|
|
# fasthttp refuses an oversized POST BEFORE any handler runs — it surfaces as an
|
|
# opaque 400 "Error when parsing request" that reads like a malformed payload
|
|
# rather than a size cap (cloud apps/projects/grant.go:5 tells that story about
|
|
# a ~170 MB export). So the zip is measured HERE and refused HERE, with the
|
|
# number and the alternative, instead of being sent to fail unreadably. Measured
|
|
# across the estate's 24 built exports, 22 fit; hanzo.ai (27.9 MiB zipped, 8536
|
|
# files) and trillerfest.com (76.7 MiB) do not. Those go to bin/sitedeploy,
|
|
# which streams per-file against a presigned grant and posts no whole body — only
|
|
# its completion manifest crosses the edge, and that is measured there for the
|
|
# same reason. hanzo.ai is over the server's own 5000-entry cap anyway, so no
|
|
# transport makes it a Release.
|
|
#
|
|
# ENV: HANZO_API_TOKEN (required) the IAM bearer; the workflow's KMS step mints it
|
|
# HANZO_API (https://api.hanzo.ai)
|
|
# GATEWAY_BODY_LIMIT (16777216) the edge's own name for its own limit
|
|
# SITEPUBLISH_PLAN=1 print what it would send and exit; no network. Test seam.
|
|
set -euo pipefail
|
|
|
|
slug="${1:?usage: sitepublish <slug> <dir>}"
|
|
dir="${2:?usage: sitepublish <slug> <dir>}"
|
|
api="${HANZO_API:-https://api.hanzo.ai}"
|
|
|
|
# Server-side caps, mirrored so a breach is named in CI instead of arriving as a
|
|
# 413 (or, for the body limit, as an unreadable 400). cloud apps/projects/blob.go
|
|
# :28-32. bin/sitepublish_test.sh pins these numbers; if cloud moves them the
|
|
# test is what catches the drift.
|
|
#
|
|
# The body limit carries the SERVER'S OWN NAME — one value, one name, wherever it
|
|
# travels. bin/sitedeploy reads the same variable for the same edge, so a runner
|
|
# told a different limit is told it once and both lanes obey.
|
|
#
|
|
# READ THE WAY THE SERVER READS IT. edge.envInt (edge.go:40) is TrimSpace, then
|
|
# strconv.Atoi, then `err != nil || n <= 0` falls back to the default — so the
|
|
# server SHRUGS at a malformed value and enforces 16 MiB. Bash does not shrug:
|
|
# `[ "$zbytes" -le "$max_body" ]` with a non-numeric operand is an error, the
|
|
# `||` branch fires, and the publish refuses against a limit that was never in
|
|
# force, printing a number that does not exist. One typo in one variable would
|
|
# stop every publisher, fail-CLOSED. Mirrored so the two cannot disagree about
|
|
# the value of the same variable.
|
|
max_body="${GATEWAY_BODY_LIMIT-}" # 16 << 20, cloud internal/edge/edge.go:38
|
|
max_body="${max_body#"${max_body%%[![:space:]]*}"}" # TrimSpace
|
|
max_body="${max_body%"${max_body##*[![:space:]]}"}"
|
|
max_body="${max_body#+}" # Atoi accepts a leading sign
|
|
case "$max_body" in ''|*[!0-9]*) max_body=16777216 ;; esac
|
|
# 18 digits: past that bash's own `-gt` errors, exactly where Atoi returns an
|
|
# error and Go keeps its default. The two agree everywhere below 10^18 bytes.
|
|
{ [ "${#max_body}" -le 18 ] && [ "$max_body" -gt 0 ]; } || max_body=16777216
|
|
max_files=5000 # maxFiles
|
|
max_file_bytes=$((64 << 20)) # maxFileBytes, 64 MiB
|
|
max_total_bytes=$((512 << 20)) # maxTotalBytes uncompressed, 512 MiB
|
|
|
|
die() { echo "::error::$*"; exit 1; }
|
|
# LC_ALL=C because under a comma-decimal locale awk renders "16,0 MiB". The
|
|
# number in a refusal is read by people and matched by tests, and neither wants
|
|
# it to depend on the runner's locale.
|
|
human() { LC_ALL=C awk -v b="$1" 'BEGIN{ split("B KiB MiB GiB", u, " "); i = 1
|
|
while (b >= 1024 && i < 4) { b /= 1024; i++ }
|
|
printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'; }
|
|
|
|
# The slug is a URL path segment AND an S3 key segment, so it is held to the
|
|
# grammar cloud validates project slugs with — checked before anything is built
|
|
# or sent, because a bad slug is a typo to fix and not a 404 to interpret.
|
|
echo "$slug" | grep -qE '^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$' \
|
|
|| die "slug '$slug' is not a project slug (^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?\$)"
|
|
[ -d "$dir" ] || die "'$dir' is not a directory — the build produced no export"
|
|
|
|
# A site is a thing with an index. cloud enforces it however the bytes arrive;
|
|
# checking here turns a failed build into a failed publish with a readable cause
|
|
# rather than a promoted release that 404s at its own root.
|
|
[ -f "$dir/index.html" ] || die "'$dir' has no index.html at its root — /v1/sites refuses a source without one"
|
|
|
|
# CNAME does not travel: a GitHub Pages artifact that means nothing to S3 and
|
|
# would ship a stale hostname claim into the bucket. Same exclusion bin/sitedeploy
|
|
# makes, for the same reason.
|
|
# `tr -d` because BSD wc pads its count to a fixed width and GNU wc does not, so
|
|
# this is "2" on a runner and " 2" on a dev machine — and the number goes
|
|
# into a message and into the plan line, where the padding is the difference
|
|
# between a readable count and a broken-looking one.
|
|
files=$(cd "$dir" && find . -type f ! -name CNAME | wc -l | tr -d ' ')
|
|
[ "$files" -gt 0 ] || die "'$dir' contains no files — refusing to publish an empty release"
|
|
[ "$files" -le "$max_files" ] \
|
|
|| die "'$dir' holds $files files; cloud caps one artifact at $max_files (apps/projects/blob.go maxFiles). Use bin/sitedeploy — but note the Releases plane has the same cap, so this export cannot become a Release."
|
|
|
|
bytes=$(cd "$dir" && find . -type f ! -name CNAME -exec wc -c {} + | awk '$2!="total"{s+=$1} END{print s+0}')
|
|
[ "$bytes" -le "$max_total_bytes" ] \
|
|
|| die "'$dir' is $bytes bytes uncompressed; cloud caps an artifact at $max_total_bytes (maxTotalBytes)"
|
|
|
|
# Per-file cap, checked with find rather than a loop so a 9000-file export costs
|
|
# one traversal. -size uses 512-byte blocks with `c` for bytes; +N c is "strictly
|
|
# greater than N bytes", which is the cap's own boundary.
|
|
#
|
|
# No `-printf`: it is a GNU extension, and on BSD find it does not merely skip
|
|
# the check — find exits 1, `set -o pipefail` propagates it, and `set -e` ends the
|
|
# script here, before it has done anything. One path per line is the whole report
|
|
# this needs. `head` runs on the RESULT rather than in the pipeline, so a fourth
|
|
# oversized file cannot SIGPIPE the find that found it.
|
|
big=$(cd "$dir" && find . -type f ! -name CNAME -size +${max_file_bytes}c)
|
|
[ -z "$big" ] || die "these files exceed cloud's $(human "$max_file_bytes") per-file cap (maxFileBytes):
|
|
$(printf '%s\n' "$big" | head -3)"
|
|
|
|
if [ -n "${SITEPUBLISH_PLAN:-}" ]; then
|
|
printf 'slug=%s dir=%s files=%s bytes=%s api=%s source=%s\n' \
|
|
"$slug" "$dir" "$files" "$bytes" "$api" "$slug"
|
|
exit 0
|
|
fi
|
|
|
|
# No apostrophe in this message: inside "${VAR:?word}" a single quote opens a
|
|
# quoted section for the PARSER, and the script dies at EOF with "unexpected EOF
|
|
# while looking for matching quote" — a syntax error reported at the last line,
|
|
# nowhere near the one that caused it.
|
|
: "${HANZO_API_TOKEN:?HANZO_API_TOKEN is unset. It is the IAM bearer minted by the KMS step of the reusable workflow, from KMS_CLIENT_ID/KMS_CLIENT_SECRET; there is no second credential to seal.}"
|
|
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
zipf="$tmp/site.zip"
|
|
|
|
# -X drops extra file attributes (uid/gid/timestamps beyond the DOS fields) so
|
|
# the same tree zips to the same bytes on any runner. Entries are stored
|
|
# RELATIVE to the export root — cloud strips a leading "./" and refuses absolute
|
|
# or ".."-escaping names (blob.go:362-370), and strips a common top-level prefix
|
|
# if one exists, so a flat archive is what it expects and what this sends.
|
|
( cd "$dir" && zip -qXr "$zipf" . -x './CNAME' ) || die "zip of '$dir' failed"
|
|
zbytes=$(wc -c < "$zipf" | tr -d ' ') # BSD wc pads its count, GNU does not
|
|
|
|
# The refusal the whole size story is about. Above this the POST dies at the
|
|
# edge with a 400 that names nothing.
|
|
# Exact bytes beside the human number, because the band where an export FIRST
|
|
# crosses is the ~100 KiB just above the cap, and there both sides round to
|
|
# "16.0 MiB" — a refusal that reads as 16.0 over 16.0 explains nothing.
|
|
[ "$zbytes" -le "$max_body" ] || die "publish aborted: the zipped release is $(human "$zbytes") ($zbytes bytes) for $files files, over the $(human "$max_body") ($max_body bytes) edge limit (GATEWAY_BODY_LIMIT, cloud internal/edge/edge.go:38). fasthttp refuses the POST before any handler runs and answers only 'Error when parsing request', which names neither size nor cause — so it is refused here, where the number is. Publish this export with bin/sitedeploy, which streams per-file against a presigned grant and posts no whole body."
|
|
|
|
# One line, every publish, whether or not it is anywhere near the limit. Same
|
|
# shape bin/sitedeploy prints, because it is the same fact about the same edge.
|
|
echo "release: $(human "$bytes") / $files files; zip $(human "$zbytes") of the $(human "$max_body") edge limit"
|
|
|
|
# ---- 1. the bytes -----------------------------------------------------------
|
|
# Every response body is KEPT and printed on failure. cloud answers these routes
|
|
# with a specific status per cause — 402 hosting gate, 403 wrong org, 404 no such
|
|
# site for this tenant, 409 the source moved mid-publish, 413 past the caps, 503
|
|
# storage unconfigured — and each is a different fix, so discarding the body
|
|
# discards the answer.
|
|
code=$(curl -sS -o "$tmp/deploy.json" -w '%{http_code}' \
|
|
-X POST "$api/v1/projects/$slug/deploy" \
|
|
-H "Authorization: Bearer $HANZO_API_TOKEN" \
|
|
-H 'Content-Type: application/zip' \
|
|
--data-binary "@$zipf") \
|
|
|| die "POST $api/v1/projects/$slug/deploy did not complete"
|
|
case "$code" in
|
|
2??) ;;
|
|
*) echo "::error::upload $slug → HTTP $code"; head -c 800 "$tmp/deploy.json"; echo; exit 1 ;;
|
|
esac
|
|
|
|
# ---- 2. promote + activate --------------------------------------------------
|
|
# `source` is the org-relative prefix the upload just landed at. Content types
|
|
# and cache policy are not ours to set: copyRelease rewrites the metadata on
|
|
# every object it promotes, so a release serves identically however its bytes
|
|
# were staged.
|
|
code=$(curl -sS -o "$tmp/publish.json" -w '%{http_code}' \
|
|
-X POST "$api/v1/sites/$slug/publish" \
|
|
-H "Authorization: Bearer $HANZO_API_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$(jq -nc --arg s "$slug" '{source:$s}')") \
|
|
|| die "POST $api/v1/sites/$slug/publish did not complete"
|
|
case "$code" in
|
|
2??) ;;
|
|
*) echo "::error::publish $slug → HTTP $code"; head -c 800 "$tmp/publish.json"; echo; exit 1 ;;
|
|
esac
|
|
|
|
# Guard the SHAPE before reading a field out of it. `jq -r '.releaseId // empty'`
|
|
# on an error body, an array, or a null yields "" and every later test on it
|
|
# passes vacuously — which is the defect class this whole script is written
|
|
# against. Assert it is an object carrying the field, then read it.
|
|
jq -e 'type == "object" and (.releaseId | type) == "string" and (.releaseId | length) > 0' \
|
|
"$tmp/publish.json" >/dev/null \
|
|
|| { echo "::error::publish answered $code but the body is not a release object with a releaseId:"; head -c 800 "$tmp/publish.json"; echo; exit 1; }
|
|
rid=$(jq -r '.releaseId' "$tmp/publish.json")
|
|
objs=$(jq -r '.objects // 0' "$tmp/publish.json")
|
|
url=$(jq -r '.url // ""' "$tmp/publish.json")
|
|
|
|
# ---- 3. verify the release we just made is the one that is live -------------
|
|
# A 200 from publish says the request was accepted, not that this release is
|
|
# serving. The list is the only thing that can say so.
|
|
#
|
|
# THE LIST IS A BARE JSON ARRAY — `type projectsReleases []projectsRelease`
|
|
# (cloud apps/projects/release.go:518). `.releases[]` against it resolves
|
|
# NOTHING, and an assertion that resolves nothing is an assertion that cannot
|
|
# fail for the reason it was written. Hence `.[]`, and hence the shape guard
|
|
# first: if cloud ever wraps this in an object, THAT is what goes red, loudly,
|
|
# instead of the check quietly matching zero releases forever.
|
|
code=$(curl -sS -o "$tmp/releases.json" -w '%{http_code}' \
|
|
"$api/v1/sites/$slug/releases" \
|
|
-H "Authorization: Bearer $HANZO_API_TOKEN") \
|
|
|| die "GET $api/v1/sites/$slug/releases did not complete"
|
|
case "$code" in
|
|
2??) ;;
|
|
*) echo "::error::list releases for $slug → HTTP $code"; head -c 800 "$tmp/releases.json"; echo; exit 1 ;;
|
|
esac
|
|
jq -e 'type == "array" and length > 0' "$tmp/releases.json" >/dev/null \
|
|
|| { echo "::error::GET /v1/sites/$slug/releases did not answer a non-empty JSON array (it is the bare-array shape from release.go:518). Body:"; head -c 800 "$tmp/releases.json"; echo; exit 1; }
|
|
|
|
# Exactly one release is active. Counting first is what makes "none active"
|
|
# distinguishable from "the wrong one is active" — a bare grep for '"active":true'
|
|
# matches ANY release in the list and would pass on both.
|
|
nactive=$(jq '[.[] | select(.active == true)] | length' "$tmp/releases.json")
|
|
[ "$nactive" = 1 ] \
|
|
|| { echo "::error::expected exactly 1 active release for $slug, found $nactive:"; jq -c '[.[]|{releaseId,active}]' "$tmp/releases.json"; exit 1; }
|
|
|
|
active=$(jq -r 'map(select(.active == true))[0].releaseId // ""' "$tmp/releases.json")
|
|
# Both sides proven non-empty BEFORE they are compared. `[ "$a" = "$b" ]` with
|
|
# two empty strings is TRUE, so an equality test on unguarded values reports
|
|
# success precisely when it learned nothing.
|
|
[ -n "$active" ] || { echo "::error::the active release for $slug carries no releaseId:"; jq -c '.' "$tmp/releases.json"; exit 1; }
|
|
[ -n "$rid" ] || die "publish returned no releaseId to verify against"
|
|
[ "$active" = "$rid" ] \
|
|
|| { echo "::error::published $rid but $active is live for $slug — the flip did not take"; exit 1; }
|
|
|
|
echo "live: $rid ($objs objects) → ${url:-no public host — that slug is claimed; rename the site or bind a domain}"
|
|
|
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
{ echo "### Site — \`$slug\`"; echo
|
|
echo "| release | objects | files | zipped | url |"
|
|
echo "|---|---|---|---|---|"
|
|
echo "| \`$rid\` | $objs | $files | $zbytes B | ${url:-—} |"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
fi
|