Files
ci/bin/site
T
zeekayandHanzo Dev 628fddb5d1 call it Hanzo Git, which is its name
"Forge" was a borrowed word for the thing that hosts the repos, schedules the
builds and issues the tokens. That thing is Hanzo Git. The word named nothing a
reader could go look at, and it had already drifted: the same paragraph could
say "the forge" and "git.hanzo.ai" and leave you to work out they were one
thing.

forge.go becomes git.go for the same reason — the file reads what Hanzo Git
knows that the run list does not.

Where a second mention would just repeat the name, it says "the server"
instead. Left alone: "unforgeable" and "cannot be forged", which are the other
word entirely and mean what they say.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 19:39:04 -07:00

457 lines
25 KiB
Bash
Executable File

#!/usr/bin/env bash
# site — publish a built static export to the Hanzo Sites plane.
# One implementation, every static site.
#
# site <slug> <dir> # e.g. site 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>/deployments -> 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.
#
# THE ONE BODY THIS LANE STILL POSTS is the completion's `keys` manifest, and it
# grows with the object count — so the same edge limit still bounds it, and cloud
# caps nothing on that route (apps/projects/deploy.go:388 declares Keys with no
# length check). It is therefore built and MEASURED before the enqueue: past the
# limit this refuses with the number, having sent nothing. Measured at the point
# it is sent instead, the same refusal arrives as that opaque 400 after every
# object is already in the bucket and the deployment is already open.
#
# 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)
# GATEWAY_BODY_LIMIT (16777216) the edge's own name for its own limit
# SITE_JOBS (24) parallel uploads
# SITE_COMMIT / SITE_BRANCH recorded on the deployment
# SITE_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="${SITE_JOBS:-24}"
# `16 << 20`, quoted from cloud internal/edge/edge.go:38 so a reader can check it
# against the source rather than trust it. That package is the one definition;
# config.go:312 consumes it and cmd/cloud/main.go:124 binds it to the public
# door's fasthttp socket, so it is the outermost thing a request meets. The env
# var carries the SERVER'S OWN NAME: one value, one name, wherever it travels —
# a runner told a different edge 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:
# `[ "$body" -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 here so the two cannot disagree about
# the value of the same variable.
max_body="${GATEWAY_BODY_LIMIT-}"
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
# LC_ALL=C for the same reason the manifest sort below carries it: 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.
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] }'; }
# A private scratch dir, not fixed /tmp names: two sites deploying at once on a
# runner that is not a container would otherwise share sd-enq.json, and the
# second grant would send the first site's files into the other's prefix.
work=$(mktemp -d); trap 'rm -rf "$work"' EXIT
[ -d "$dir" ] || { echo "::error::$dir is not a directory — the build produced no export"; exit 1; }
# ---- content type -----------------------------------------------------------
# What the object STORES, which is what a reader of the S3 origin gets. The
# serving path names the type from the key and falls back to the stored one only
# for an extension it does not know, so this is the fallback rather than the
# authority — and an object stored as application/octet-stream is one a browser
# DOWNLOADS instead of rendering.
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 is the server's -------------------------------------------
# Nothing here computes one. The serving path composes CacheControlFor(key,
# site.CacheControl) on every request and deliberately does NOT read the stored
# object's header (cloud apps/sites/sites.go), so a Cache-Control stamped here
# reaches no reader.
#
# It was a copy of the server's policy, and it had drifted exactly as a second
# copy does: this script matched hashed BASENAMES only, while the server also
# treats the build-output PREFIXES (`_next/static/`, `assets/`) as immutable —
# so every unhashed-but-immutable asset in every Next export carried max-age=3600
# where the rule says a year. One rule, one place, and the place is what serves.
# ---- 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; }
printf '%s\n' "$manifest" | grep . > "$work/manifest"
# ---- the completion body, built BEFORE anything is sent ----------------------
# It is the last request of the three and the only one whose size grows with the
# release, so it is the only one the edge limit can refuse — and refusing it late
# is the expensive way to learn: the deployment is open, every object is already
# in the bucket, and the answer is an opaque 400.
#
# `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}')
# --rawfile, not --args: `jq --args ... $(cat manifest)` passes every key as an
# ARGV entry, and execve caps the argument block (~2 MiB on Linux). A wide export
# would die there first — "Argument list too long", from the tool that builds the
# body rather than from the size of it — which is the same unreadable failure one
# layer up, and it would fire before this check could name the real ceiling.
jq -n --arg c "${SITE_COMMIT:-}" --argjson f "$count" --argjson b "${bytes:-0}" \
--rawfile k "$work/manifest" \
'{status:"live", commit:$c, files:$f, bytes:$b,
keys:($k | rtrimstr("\n") | split("\n"))}' > "$work/sd-done.json"
body=$(wc -c < "$work/sd-done.json" | tr -d ' ') # BSD wc pads its count, GNU does not
[ "$body" -le "$max_body" ] || {
# Exact bytes beside the human number, because the band where a site 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.
echo "::error::publish aborted: the completion manifest is $(human "$body") ($body bytes) for $count files, over the $(human "$max_body") ($max_body bytes) edge limit (GATEWAY_BODY_LIMIT, cloud internal/edge/edge.go:38). The bytes stream per file and are not the problem — the manifest cloud reconciles the prefix against is one body, and fasthttp refuses an oversized POST before any handler runs, answering only 'Error when parsing request', which names neither size nor cause. Reduce the object count: a full prerender is what makes an export this wide."
exit 1; }
if [ -n "${SITE_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\n' "$k" "$(ctype "$k")"; done <<< "$manifest"
exit 0
fi
# One line, every publish, whether or not it is anywhere near the limit. The size
# of a release explains most of what this lane then does, and a build step that
# reports it only when it refuses leaves the number unknown on the run that
# introduced it — which is the run where a growing object count is cheapest to
# see.
echo "release: $(human "$bytes") / $count files; manifest $(human "$body") of the $(human "$max_body") edge limit"
# Spelled out rather than ${VAR:?...}. That form reports through the shell, and
# the EXIT trap above resets the status on its way out, so the refusal printed
# and the script still exited 0 — a deploy that never happened, reported as a
# success. The brace in the mint example also closed the expansion early, cutting
# the message off mid-sentence.
if [ -z "${HANZO_DEPLOY_TOKEN:-}" ]; then
echo "::error::HANZO_DEPLOY_TOKEN is unset. It is the ONE credential this needs; set it as an Actions secret on git.hanzo.ai, which is what reads .hanzo/workflows. Mint one: POST /v1/keys {\"type\":\"secret\"}" >&2
exit 1
fi
# ---- 0a. does the credential authenticate at all? ---------------------------
# Every org-scoped route answers a rejected credential and an ABSENT one with the
# same 403, `{"error":"X-Org-Id required"}`. That message names a HEADER, and the
# header is one CI cannot set — the gateway strips a client-supplied X-Org-Id and
# mints it from the validated bearer — so the true cause (the bearer resolved to
# no principal) is reported as the one thing the caller is forbidden to fix.
#
# It cost a full investigation on hanzo.ai: every gate green, then `ensure project
# hanzo-ai returned HTTP 403 X-Org-Id required` on the last step, reading as a
# server or header regression when the credential had simply stopped resolving.
# Verified against the live API — no auth, a bogus `sk-`, and a revoked key are
# byte-identical on /v1/projects.
#
# /v1/keys refuses in a DIFFERENT voice ("sign in to manage API keys"), so it
# still separates the two failures this script cannot otherwise tell apart: a
# bearer that does not authenticate, versus one that authenticates and has no
# org. Name which, and say what to do about it.
#
# ADVISORY, NOT FATAL — and that is the whole point of this block.
#
# It used to `exit 1` on any non-200, on the premise that /v1/keys "200s for a
# live key". That premise is not something this script can rely on: /v1/keys is
# the SESSION-scoped key-MANAGEMENT route, and a machine `sk-` key is not
# supposed to manage keys. Measured against the live API: an anonymous request
# and a bogus `sk-` are byte-identical there — both 403
# `{"code":"forbidden","error":"sign in to manage API keys"}`. A probe that
# answers the same for a valid and an invalid credential cannot decide anything,
# and this one was deciding whether the site ships.
#
# It froze the Sites plane. hanzo.ai and hips both died here in ~1s, every run,
# with the whole build and every gate already green above it — and the message
# said the credential was revoked, which is a claim this probe is not entitled
# to make.
#
# So the preflight keeps its diagnosis and loses its veto. The operations below
# are the authority: they are the ones that actually need the credential, they
# already report their own status precisely, and unlike this one they cannot be
# wrong about what they measured. A credential that is genuinely dead still
# fails the deploy — one step later, from the call that means something.
#
# `|| true`, not `|| echo 000`: curl ALREADY writes 000 to stdout when it cannot
# connect, so appending another made kcode `000000`, which matched no case and
# took the hard-fail branch — a preflight that turned an unreachable API into
# "your credential is revoked". Caught by the unreachable-host test below.
kcode=$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" "$api/v1/keys" || true)
case "$kcode" in
200) : ;;
000|"") echo "::warning::could not reach $api/v1/keys to preflight the credential; continuing" ;;
*)
echo "::warning::the credential did not read as a live key at $api/v1/keys (HTTP $kcode)."
echo "That route is session-scoped key MANAGEMENT, which a machine key is not"
echo "expected to hold, so this is a hint and not a verdict — continuing, and the"
echo "calls below will say for certain."
echo "If one of them refuses the credential: the token resolves to no principal —"
echo "absent from this repo's scope, expired, or revoked. It is NOT a missing"
echo "X-Org-Id header; CI cannot set that header, cloud derives it from this"
echo "bearer. Mint a replacement with POST $api/v1/keys {\"type\":\"secret\"} as a"
echo "member of the owning org, then set it as the ORG-level Actions secret"
echo "HANZO_DEPLOY_TOKEN on git.hanzo.ai — that is what reads"
echo ".hanzo/workflows, not GitHub." ;;
esac
# ---- POST, with 5xx asked again ---------------------------------------------
# 5xx is the plane saying "not now"; 4xx is it saying "no". Only the first is
# worth repeating, and repeating it is worth a lot here: every gate has already
# passed by this point, so a publish lost to one transient 503 throws away a
# whole green build. hanzo.ai spent an hour alternating — 16:21 failed, 16:24
# and 16:28 published, 16:41 failed twice, 16:54 through 17:04 published, 17:21
# failed — on `ensure project ... 503`, with sign and insights flapping beside
# it. Nothing was wrong with any of those builds.
#
# A curl that cannot speak at all reports 000, which retries for the same reason
# and then falls through to the caller's own error path.
#
# Both callers below are idempotent, which is what makes a retry safe rather
# than merely convenient: creating a project answers 409 the second time, and an
# enqueue that never returned its 202 created no deployment to duplicate.
post() {
local out=$1 url=$2 body=$3 code attempt
for attempt in 1 2 3 4 5; do
code=$(curl -sS -o "$out" -w '%{http_code}' -X POST "$url" \
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
-d "$body" || printf '000')
case "$code" in 5??|000) ;; *) break ;; esac
[ "$attempt" -lt 5 ] || break
sleep $((attempt * 4))
done
printf '%s' "$code"
}
# ---- 0b. 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 so the deploy's lifecycle events name a
# repo and reach the notify subscriptions keyed on it — the enqueue itself no
# longer requires a link; SITE_REPO overrides the origin for a
# checkout whose remote is not the canonical repo.
repo="${SITE_REPO:-$(git remote get-url origin 2>/dev/null || true)}"
pcode=$(post "$work"/sd-proj.json "$api/v1/projects" \
"$(jq -nc --arg s "$slug" --arg u "$repo" --arg b "${SITE_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 "$work"/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.
#
# POST on the DEPLOYMENTS COLLECTION, which is the address that opens one. The
# enqueue used to share the /deploy address with the archive upload, told apart
# by Content-Type; /deploy is now the archive alone and reads any body as bytes,
# so this JSON sent there is sniffed as a tar and refused —
# 400 "invalid artifact: tar: unexpected EOF", with the build and every gate
# already green above it. The collection is where a deployment is created; the
# verb is the method and the noun is the resource.
#
# `commit` is the whole body. `source` was the Content-Type discriminator the
# split removed, and `branch` was always discarded server-side — a deployment has
# no branch column, and the lifecycle event derives it from the project's own
# linked branch. jq builds it so a commit message can never break the JSON.
code=$(post "$work"/sd-enq.json "$api/v1/projects/$slug/deployments" \
"$(jq -nc --arg c "${SITE_COMMIT:-}" '{commit:$c}')")
if [ "$code" != "202" ]; then
echo "::error::enqueue $api/v1/projects/$slug/deployments returned HTTP $code"; head -c 600 "$work"/sd-enq.json; echo; exit 1
fi
dep=$(jq -r '.id' "$work"/sd-enq.json)
prefix=$(jq -r '.prefix' "$work"/sd-enq.json)
upload_url=$(jq -r '.upload.url // empty' "$work"/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}' "$work"/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)"' "$work"/sd-enq.json > "$work"/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 < "$work"/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 "file=@$DIR/$rel" \
|| { echo "::error::upload failed: $rel"; return 1; }
}
export -f put ctype
export UP_URL="$upload_url" PREFIX="$prefix" DIR="$dir" work
echo "uploading $count files to s3://$(jq -r .bucket "$work"/sd-enq.json)/$prefix ($jobs parallel)"
xargs -P "$jobs" -I{} bash -c 'put "$@"' _ {} < "$work/manifest"
# ---- 3. flip it live --------------------------------------------------------
# The body was built and measured at the top, before a single object moved, so
# there is nothing left to assemble here — only to send it.
code=$(curl -sS -o "$work"/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 @"$work"/sd-done.json)
trap - ERR
if [ "$code" != "200" ]; then
echo "::error::complete returned HTTP $code"; head -c 600 "$work"/sd-resp.json; echo; fail; exit 1
fi
jq -c '{status,liveUrl,version,files,bytes}' "$work"/sd-resp.json
# ---- 4. read back what we just published ------------------------------------
# A publish that uploads and flips live has still proved nothing about whether
# the bytes come back. On 2026-08-15 hanzo.ai's apex served `HTTP/2 200` with a
# correct `content-length: 96475`, a valid MD5 etag — and ZERO body bytes, for
# 35 minutes. StatObject knew a size GetObject could not deliver. Exactly two
# objects were affected, index.html and 404.html, both from one publish; every
# sibling path served fine, so nothing that walked routes would have caught it.
#
# A HEAD is blind to this by construction: it never asks for a body, so `curl -I`
# reports a healthy 200 on a page that cannot load. The check has to DOWNLOAD and
# compare what arrived against what was promised. curl exits 18 on the short read
# ("transfer closed with N bytes remaining"), which is the signal we want.
#
# Retried, because a flip-to-live is not instantaneous and a single miss here
# would fail honest deploys. Three attempts is enough to separate propagation
# from a genuinely unreadable object.
live=$(jq -r '.liveUrl // empty' "$work"/sd-resp.json)
if [ -z "$live" ]; then
echo "::warning::complete returned no liveUrl — published bytes were not read back"
else
readback() {
local code got len
code=$(curl -sS -L --max-time 30 \
-D "$work"/sd-vfy.hdr -o "$work"/sd-vfy.body \
-w '%{http_code}' "$live" 2>"$work"/sd-vfy.err) || {
echo " transfer failed: $(tr -d '\r' < "$work"/sd-vfy.err | tail -1)"; return 1; }
[ "$code" = "200" ] || { echo " HTTP $code"; return 1; }
got=$(wc -c < "$work"/sd-vfy.body | tr -d ' ')
len=$(awk 'BEGIN{IGNORECASE=1} /^content-length:/{gsub(/\r/,"");print $2}' \
"$work"/sd-vfy.hdr | tail -1)
[ "${got:-0}" -gt 0 ] || { echo " 200 with an EMPTY body"; return 1; }
if [ -n "$len" ] && [ "$got" -ne "$len" ]; then
echo " got $got bytes, content-length promised $len"; return 1
fi
echo " $got bytes"; return 0
}
ok=
for attempt in 1 2 3; do
printf 'read-back %s (attempt %d): ' "$live" "$attempt"
if readback; then ok=1; break; fi
[ "$attempt" -lt 3 ] && sleep 5
done
if [ -z "$ok" ]; then
echo "::error::$live is live and does not serve its own bytes. The upload and the" >&2
echo "::error::flip both succeeded, so this is not a retry — the stored object is" >&2
echo "::error::unreadable. Republish or roll back; do not leave it serving." >&2
exit 1
fi
fi