#!/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.
#
# `exit 1` on any non-200 would rest on /v1/keys "200s for a live key", and this
# script cannot rely on that: /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, least of all 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 makes kcode `000000`, which matches no case and
# falls to the hard-fail branch — a preflight that reports an unreachable API as
# "your credential is revoked". Pinned 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.
# /deploy is the ARCHIVE address and reads any body as bytes, so 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

# ASSETS BEFORE DOCUMENTS, because the bytes are live where they land. This
# publisher writes into the prefix the edge already serves: there is no release to
# flip, so every object is public the instant S3 accepts it. A single flat pass
# therefore has no ordering between a document and the hashed assets it names, and
# a document is only servable once all of them are in the bucket.
#
# Splitting on `.html` gives that ordering with no coordination and no new
# machinery. An asset name carries a content hash, so writing one never replaces
# an object a live document is still naming — phase one is purely additive, and
# whatever is published stays servable throughout it. The document is the switch,
# thrown once everything it can name is already there.
#
# It is also what lets a caller cancel a superseded run safely: the run dies in
# phase one having only added unreferenced objects, and what is live is unchanged.
grep -v '\.html$' "$work/manifest" > "$work/manifest.assets" || true
grep    '\.html$' "$work/manifest" > "$work/manifest.docs"   || true
n_assets=$(grep -c . "$work/manifest.assets" || true)
n_docs=$(grep -c . "$work/manifest.docs" || true)
echo "uploading $count files to s3://$(jq -r .bucket "$work"/sd-enq.json)/$prefix ($jobs parallel: $n_assets assets, then $n_docs documents)"
if [ "$n_assets" -gt 0 ]; then
  xargs -P "$jobs" -I{} bash -c 'put "$@"' _ {} < "$work/manifest.assets"
fi
if [ "$n_docs" -gt 0 ]; then
  xargs -P "$jobs" -I{} bash -c 'put "$@"' _ {} < "$work/manifest.docs"
fi

# ---- 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. An object store can answer `200` with a correct
# `content-length` and a valid MD5 etag and then deliver ZERO body bytes —
# StatObject knowing a size GetObject cannot serve. It strikes per object, so
# sibling paths stay healthy and nothing that merely walks routes sees 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
