Files
pig/scripts/deploy.sh
T
claude f0173440e4
CI / verify (push) Successful in 7m6s
CI / publish (push) Has been skipped
Put Piggy on Prime Agent, and let it write to the book
Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session —
Prime Intellect's own harness, embedded as a Node library — answering from
PIG's tools and, for the first time, able to put information into the CRM
rather than only read it out.

The harness is a coding agent, so the first job was taking the coding agent
away from it. `noTools: 'all'` plus an explicit allowlist leaves the model
with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That
holds under attack: a hostile extension, a skill and a settings file planted
in the agent's own directory, then `setActiveToolsByName` called with every
built-in, still leaves ten tools, all ours. Both lines are load-bearing —
`noTools` alone registers nothing, and the allowlist is what admits our own.

Writing is gated rather than assumed. A change is proposed, not made: the
tool returns a description, the transcript renders a diff card, and nothing
reaches the database until someone presses Apply. Contracts, commitments,
allocations and compliance always stop for a human whatever the mode. Every
write runs through `executeMutation` as the calling user, so their
capabilities and the audit trail apply exactly as they would to a human's.

Four things about the SDK are wrong in its own documentation and cost a
debugging cycle each: models.json does not resolve an env var name for
`apiKey`, it sends the literal string; there is no built-in prime-inference
provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you;
and the stock system prompt is a coding-assistant prompt that must be
replaced — but replacing it also silently removes the tool list, because the
harness only renders that section when it owns the prompt. AGENTS.md records
all four.

The expensive one was thinking level. The harness defaults to `medium`, and
nemotron spent an entire 4,096-token budget reasoning and returned an empty
answer. `low` was worse; `off` omits the parameter so the endpoint's default
wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn
from 6,195 output tokens to 149.

And a turn is now bounded. The harness loop is `while (true)` with no
iteration cap; a runaway on a frontier model would have eaten the credit it
is supposed to report on. Ceilings on model calls and tokens, enforced both
through the harness hook and independently from the event stream, plus a
per-user daily spend limit — and the ledger now records spend on turns that
fail, which it previously discarded.

Signing in lands on /piggy, which is a workspace: conversations down one
side, the agent in the middle, what it did and what it cost beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 05:26:28 -07:00

475 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Deploy PIG. Run on the host that serves it.
#
# ./scripts/deploy.sh
#
# Deliberately a script rather than automated push-to-deploy. Automating it
# would mean putting an SSH key with write access to the production host onto
# the CI runner, which is a meaningful escalation for a project this size. CI
# proves the commit is sound; a human decides when it ships.
#
# That constraint still holds under the tag-to-ship flow added later. Nothing
# on the CI runner can reach this host: CI publishes an image, and this host
# PULLS it (scripts/autodeploy.sh). The direction of the credential is the
# whole point — a read-only registry token here, no host credential there. And
# a human still decides when it ships, by choosing to create a `release-*` tag.
#
# PIG_IMAGE unset Build from this working tree, as it always has.
# PIG_IMAGE set Pull that published image instead of building, and leave
# the checkout exactly where the caller put it. The caller
# is responsible for having checked out the matching
# commit, because the compose file, the migrations and the
# image have to agree.
#
# Safe to re-run. Migrations are additive and tracked.
set -euo pipefail
# Resolved BEFORE the re-exec below and carried across it. After the re-exec
# `$0` is a copy in /tmp, so `dirname "$0"` would point at the wrong tree.
PIG_REPO_ROOT="${PIG_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PIG_REPO_ROOT
cd "$PIG_REPO_ROOT"
# Run from a copy, because this script rewrites itself.
#
# `git reset --hard origin/main` below replaces this very file while bash is
# still reading it. bash does not slurp a script: it reads incrementally and
# remembers a byte OFFSET, so after the reset it resumes at that offset into
# different content — which silently skips or splices steps, and at worst
# executes a fragment of a line. The 13dec6b deploy hit exactly this: the new
# public-origin gate was on disk and never ran, because bash was still
# executing the buffered previous version.
#
# It failed harmlessly that time. It is not guaranteed to. scripts/autodeploy.sh
# has always had this guard; deploy.sh needed it for the same reason.
if [ "${PIG_DEPLOY_REEXEC:-}" != '1' ]; then
_copy=$(mktemp -t pig-deploy.XXXXXX)
cat "$0" > "$_copy"
PIG_DEPLOY_REEXEC=1 exec bash "$_copy" "$@"
fi
trap 'rm -f "$0"' EXIT
# What compose will run. Must match the `image:` default in docker-compose.yml,
# because that is the tag a rollback re-points at the previous image.
IMAGE_REF="${PIG_IMAGE:-pig:local}"
# Which compose profiles are active. Set below, once .env has been read; empty
# means none, which is what compose does by default anyway.
COMPOSE_PROFILES=''
# Every compose invocation goes through this. sudo's default `env_reset` drops
# PIG_IMAGE, so a plain `sudo docker compose` interpolates the `pig:local`
# fallback in docker-compose.yml instead of the release tag: the pull then fails
# with "pull access denied for pig", and — worse, if the pull is ever made
# non-fatal — the migrate, the `up` and the rollback all silently run whatever
# `pig:local` happens to be while the log reports the release. Pass it on the
# command line via env(1): `sudo -E` and bare `sudo VAR=val` are both refused by
# the default sudoers policy, `sudo env VAR=val …` is not.
#
# COMPOSE_PROFILES rides along for the same reason and needs it just as badly:
# the piggy service is profile-gated, and compose ignores a profile-gated
# service SILENTLY — `pull`, `build` and `up` behave as though it were not in
# the file at all, with no warning and a zero exit. Carrying the profile here
# rather than at each call site is what stops one forgotten flag leaving the
# agent on the previous release.
dc() {
sudo env PIG_IMAGE="$IMAGE_REF" COMPOSE_PROFILES="$COMPOSE_PROFILES" docker compose -p pig "$@"
}
# Gate failures come in two kinds and the caller must be able to tell them
# apart: scripts/autodeploy.sh reports to the journal what is serving right now.
# 1 a gate failed and the previous image was restored — the old release is up
# 3 a gate failed and the release under test is STILL LIVE
EXIT_STILL_LIVE=3
# Read one key out of .env WITHOUT sourcing it. Sourcing an environment file
# executes it, and this one holds every secret the deployment has.
env_value() {
[ -f .env ] || return 0
sed -n "s/^[[:space:]]*$1=//p" .env | tail -n1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"
}
# The same words the API accepts, from `envBoolean` in apps/api/src/lib/config.ts.
# If this and that ever disagree, the CRM offers a chat surface backed by a
# container this script never started.
is_true() {
# Compose drops a whitespace-preceded inline comment before the container ever
# sees the value, so this has to as well: `PIGGY_ENABLED=true # on` would
# otherwise read as false here and true there — the agent offered by the CRM
# and never deployed, which is the exact fault this whole path exists to end.
local value=${1%%[[:space:]]#*}
case "$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in
1 | true | yes | on) return 0 ;;
*) return 1 ;;
esac
}
# The database this deployment actually has, with the same defaults
# docker-compose.yml interpolates. Hardcoding `pig` here is survivable only
# while nobody sets POSTGRES_USER: on a host that does, the pre-migration dump
# would fail and take the deploy with it — fail-safe, but it fails the deploy
# instead of backing up the database, which is not the intent.
PG_USER="$(env_value POSTGRES_USER)"
PG_USER="${PG_USER:-pig}"
PG_DB="$(env_value POSTGRES_DB)"
PG_DB="${PG_DB:-pig}"
# Piggy is part of the release or it is not; there is no half-deployed state
# worth having. Left out of the pull, the build, the `up` and the rollback, it
# runs the PREVIOUS image against the schema this deploy just migrated — the
# hazard docker-compose.yml's own comment warns about — or does not run at all
# while the deploy reports success.
DEPLOY_SERVICES=(app)
PIGGY_IN_RELEASE=0
if is_true "$(env_value PIGGY_ENABLED)"; then
COMPOSE_PROFILES='piggy'
DEPLOY_SERVICES+=(piggy)
PIGGY_IN_RELEASE=1
echo "==> Piggy is enabled; it ships with this release"
fi
if [ -n "${PIG_IMAGE:-}" ]; then
echo "==> Deploying published image $PIG_IMAGE"
# No git sync. The caller has already detached this checkout at the tag the
# image was built from; resetting to origin/main here would silently deploy a
# compose file and a migration set from a different commit than the image.
AFTER=$(git rev-parse --short HEAD)
echo " tree at $AFTER"
else
echo "==> Fetching"
git fetch -q origin
BEFORE=$(git rev-parse --short HEAD)
git reset --hard -q origin/main
AFTER=$(git rev-parse --short HEAD)
if [ "$BEFORE" = "$AFTER" ]; then
echo " Already at $AFTER"
else
echo " $BEFORE -> $AFTER"
git --no-pager log --oneline "$BEFORE..$AFTER" | sed 's/^/ /'
fi
fi
echo "==> Starting the database"
# Before the dump, not after it. The backup step below `exec`s into this
# container, so on a host where the stack is down — a reboot, a `compose down`,
# a first-ever deploy — the backup was the step that failed, and it took the
# whole release with it. Fail-safe, but it aborted the deploy instead of
# protecting the data, which is the opposite of the intent.
dc up -d db
for _ in $(seq 1 60); do
if dc exec -T db pg_isready -U "$PG_USER" -d "$PG_DB" > /dev/null; then break; fi
sleep 1
done
if ! dc exec -T db pg_isready -U "$PG_USER" -d "$PG_DB" > /dev/null; then
echo "ERROR: database did not become ready within 60 seconds" >&2
exit 1
fi
echo "==> Backing up the database first"
# This used to be cheap insurance against a bad migration. It is now the thing
# standing between an agent bug and the book: Piggy can WRITE to the CRM —
# accounts, deals, activity — as the signed-in user, so a release that ships a
# broken write tool can corrupt data no migration ever touched. Restoring from
# here is the only undo that exists.
#
# Taken BEFORE the migration and before the new image starts, which is the only
# ordering that gives a dump of the schema the old code wrote.
#
# How many tables there are, so an empty dump can be told apart from a failed
# one. A first deploy legitimately dumps almost nothing; every later deploy
# dumping almost nothing is a fault.
PUBLIC_TABLES=$(dc exec -T db psql -U "$PG_USER" -d "$PG_DB" -tAc \
"select count(*) from information_schema.tables where table_schema = 'public'" | tr -cd '0-9')
PUBLIC_TABLES="${PUBLIC_TABLES:-0}"
mkdir -p backups
BACKUP="backups/pig-$(date +%Y%m%d-%H%M%S).sql.gz"
dc exec -T db pg_dump -U "$PG_USER" "$PG_DB" | gzip > "$BACKUP"
# pipefail catches a pg_dump that FAILS. It does not catch a pg_dump that
# succeeds and says nothing — a wrong database name, an empty scratch volume
# adopted by mistake — and gzip turns that silence into a plausible-looking
# 20-byte file. A dump nobody can restore from is worse than a missing one,
# because the deploy log claims a backup was taken. Decompressing the whole
# thing verifies the gzip stream as a side effect, and `wc -c` reads to EOF so
# nothing here dies on SIGPIPE.
BACKUP_BYTES=$(gzip -dc "$BACKUP" | wc -c)
if [ "$PUBLIC_TABLES" -gt 0 ] && [ "$BACKUP_BYTES" -lt 4096 ]; then
echo "ERROR: $PG_DB has $PUBLIC_TABLES tables but $BACKUP decompresses to only" >&2
echo " $BACKUP_BYTES bytes. That is not a usable dump. Refusing to migrate" >&2
echo " without one, because this dump is the only rollback the data has." >&2
exit 1
fi
echo " $BACKUP ($(du -h "$BACKUP" | cut -f1) compressed, $BACKUP_BYTES bytes of SQL, $PUBLIC_TABLES tables)"
if [ "$PUBLIC_TABLES" -eq 0 ]; then
echo " (no tables yet — this looks like a first deploy, so a thin dump is expected)"
fi
# Kept next to the checkout on purpose. backups/ is in .gitignore, so
# scripts/autodeploy.sh's `git checkout --detach --force` leaves it alone — a
# dump that the next deploy deletes is not a backup. Nothing here prunes them
# either; they are small, they are the only copy, and a disk-space problem is a
# better problem than a missing dump. Copy them off this host if the data
# matters as much as the uptime does.
if [ -n "${PIG_IMAGE:-}" ]; then
echo "==> Pulling"
dc pull "${DEPLOY_SERVICES[@]}"
else
echo "==> Building"
dc build "${DEPLOY_SERVICES[@]}"
fi
echo "==> Migrating before the schema-dependent app starts"
# A release may query a newly introduced table during startup. Running the
# migration from a one-off container prevents that app from crash-looping
# before an `exec`-based migration can reach it.
#
# This ordering matters more with every migration that a NEW surface depends
# on. 0014 adds piggy_conversations and piggy_messages, which the API writes on
# every agent turn — and the agent turn is paid for BEFORE the insert, so a
# release that starts ahead of its migration takes a user's question, calls a
# model, bills for it and then fails to persist the answer. `dc up` starts app
# and piggy together below, so both are behind this line.
#
# The image runs it, not the host: the migration set that runs is the one baked
# into $IMAGE_REF, so a published image and its migrations can never disagree.
# `--no-deps` because the database is already up, and letting compose start
# dependencies here would start the OLD app container against an unmigrated
# schema, which is the exact race this step exists to avoid.
dc run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts
# The image the current container is running, captured before it is replaced.
# Without this a failed release stays live: both gates below used to exit 1 and
# leave the broken version serving, which is tolerable when a human is reading
# the terminal and an outage when the poller ran this at 04:00.
PREVIOUS_IMAGE=""
PREVIOUS_CID=$(dc ps -q app 2>/dev/null | head -n1 || true)
if [ -n "$PREVIOUS_CID" ]; then
PREVIOUS_IMAGE=$(sudo docker inspect -f '{{.Image}}' "$PREVIOUS_CID" 2>/dev/null || true)
fi
# Restore the previous image and exit non-zero. Deliberately NOT a database
# rollback: migrations are additive, so the previous code runs against the new
# schema, and the dump taken above is the escape hatch for the case where it
# does not. The re-tag makes $IMAGE_REF point back at the old image locally; the
# next successful pull moves it forward again.
#
# Leaving the schema ahead of the image is the deliberate half of that, and it
# is safe for exactly the reason above. 0014 is the current case: it CREATES
# piggy_conversations and piggy_messages and alters nothing, so an image that
# predates it never names those tables and cannot notice they exist. The rule
# this relies on is a rule about migrations, not about this script — a
# migration that drops a column, renames one, or tightens a constraint breaks
# the previous image and makes this rollback a partial outage. Write additive
# migrations, or plan the rollback with the dump in hand.
roll_back() {
echo " !! $1"
dc logs app --tail 40 || true
if [ -z "$PREVIOUS_IMAGE" ]; then
echo " NO PREVIOUS IMAGE TO ROLL BACK TO — the broken release is live" >&2
exit "$EXIT_STILL_LIVE"
fi
echo "==> Rolling back to $PREVIOUS_IMAGE"
sudo docker tag "$PREVIOUS_IMAGE" "$IMAGE_REF"
# Piggy included: app and piggy are one image running two commands, and a
# rollback that restores only the app leaves the two halves of the same
# release on different code.
dc up -d --no-build "${DEPLOY_SERVICES[@]}"
for _ in $(seq 1 60); do
if curl -sf http://127.0.0.1:8920/api/health > /dev/null; then break; fi
sleep 1
done
if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
echo " rolled back, previous release is healthy"
exit 1
fi
# The restore ran but did not come up. Reporting "rolled back" here would tell
# the on-call the previous release is serving when nothing is.
echo " ROLLBACK ALSO UNHEALTHY — this host needs a human" >&2
exit "$EXIT_STILL_LIVE"
}
echo "==> Starting the app"
dc up -d "${DEPLOY_SERVICES[@]}"
echo "==> Waiting for health"
for _ in $(seq 1 60); do
if curl -sf http://127.0.0.1:8920/api/health > /dev/null; then break; fi
sleep 1
done
echo "==> Verifying"
if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
echo " health ok"
else
echo " HEALTH CHECK FAILED"
roll_back "health check failed"
fi
# Authentication must be enforced. A deploy that accidentally serves the CRM
# unauthenticated is the one failure worth blocking on.
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8920/api/dashboard)
if [ "$CODE" != "401" ]; then
echo " UNAUTHENTICATED REQUEST RETURNED $CODE, EXPECTED 401"
roll_back "unauthenticated request returned $CODE, expected 401"
fi
echo " auth enforced"
if [ "$PIGGY_IN_RELEASE" = '1' ]; then
echo "==> Verifying Piggy"
PIGGY_CID=$(dc ps -q piggy 2>/dev/null | head -n1 || true)
if [ -z "$PIGGY_CID" ]; then
echo " PIGGY IS ENABLED IN .env BUT NO PIGGY CONTAINER IS RUNNING"
roll_back "piggy is enabled but no piggy container is running"
fi
# Image IDs, not tags. The failure this catches is a Piggy someone started by
# hand once and never touched again: it answers to the same tag while running
# whatever that tag meant on the day, so a tag comparison sees nothing wrong
# and the old agent goes on writing to a schema three migrations newer.
WANTED_IMAGE_ID=$(sudo docker image inspect -f '{{.Id}}' "$IMAGE_REF" 2>/dev/null || true)
PIGGY_IMAGE_ID=$(sudo docker inspect -f '{{.Image}}' "$PIGGY_CID" 2>/dev/null || true)
if [ -z "$WANTED_IMAGE_ID" ] || [ "$PIGGY_IMAGE_ID" != "$WANTED_IMAGE_ID" ]; then
echo " PIGGY IS RUNNING ${PIGGY_IMAGE_ID:-<unknown>}, EXPECTED ${WANTED_IMAGE_ID:-<unknown>}"
roll_back "piggy is not running $IMAGE_REF"
fi
echo " piggy on $IMAGE_REF"
# Where the harness is allowed to look at the filesystem, read back from the
# container that is actually running rather than from the file that was meant
# to configure it.
#
# The harness discovers extensions, skills and context files from its cwd, and
# Piggy hands it PIGGY_AGENT_DIR as cwd. Inside /app that is the application
# checkout, so a stray bind mount or a hand-edited compose file would put
# source code within reach of a CRM agent's prompt — a leak that every other
# gate here passes cheerfully, because the container is perfectly healthy.
# Empty is fine: an older compose file simply does not set it, and the
# in-code default is ~/.pig/piggy-agent.
PIGGY_AGENT_DIR_LIVE=$(sudo docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' \
"$PIGGY_CID" 2>/dev/null | sed -n 's/^PIGGY_AGENT_DIR=//p' | tail -n1)
case "${PIGGY_AGENT_DIR_LIVE:-}" in
/app | /app/*)
echo " PIGGY_AGENT_DIR IS $PIGGY_AGENT_DIR_LIVE, INSIDE THE CHECKOUT" >&2
echo " The agent harness treats that directory as its cwd and reads" >&2
echo " context files from it, so this puts repository contents inside a" >&2
echo " CRM agent's prompt. Point it at /var/lib/piggy-agent, which the" >&2
echo " image creates for exactly this, and redeploy. Not rolled back:" >&2
echo " the previous image reads the same compose file." >&2
exit "$EXIT_STILL_LIVE"
;;
esac
# `starting` until the first probe answers, so this is a wait, not a poll of
# something already decided. start_period is 20s and the interval 30s, hence
# the longer budget than the app's.
PIGGY_HEALTH=''
for _ in $(seq 1 90); do
PIGGY_HEALTH=$(sudo docker inspect \
-f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
"$PIGGY_CID" 2>/dev/null || true)
[ "$PIGGY_HEALTH" = 'starting' ] || break
sleep 1
done
if [ "$PIGGY_HEALTH" = 'healthy' ]; then
echo " piggy healthy"
elif [ "$PIGGY_HEALTH" = 'none' ]; then
# Only reachable from a container created by an older compose file, since
# this one defines a healthcheck for the service. Worth saying rather than
# passing quietly, because "no result" is not "well".
echo " piggy has no healthcheck to consult; recreate it to get one" >&2
else
echo " PIGGY IS ${PIGGY_HEALTH:-UNKNOWN}" >&2
dc logs piggy --tail 40 || true
# Deliberately no rollback, for the same reason the empty-body case below
# does not: the previous image reads this same .env and fails identically,
# so restoring it churns the CRM without fixing the agent. The CRM is live
# and well; the agent the operator asked for is not.
echo " The CRM is serving. Piggy is not, and a rollback would not help:" >&2
echo " the previous image reads the same .env. Usual causes, in order:" >&2
echo " - PRIME_API_KEY missing or rejected (PIGGY_INFERENCE_API_KEY is" >&2
echo " still accepted as its alias); Piggy exits at boot and" >&2
echo " restart: unless-stopped turns that into a crash loop" >&2
echo " - PIGGY_INTERNAL_TOKEN shorter than 32 characters" >&2
echo " - PIGGY_AGENT_MODEL naming a model that is not in the catalogue" >&2
echo " at apps/piggy/src/agent/models.json" >&2
echo " The log above says which; the config error names the key." >&2
exit "$EXIT_STILL_LIVE"
fi
fi
# Everything above proves the container is well. It proves nothing about what
# the public actually gets, and there is a failure on this host that every
# check so far passes: a Caddy site block missing `bind 10.0.0.2` lands in a
# separate server on :443, wins for traffic arriving on that address, and
# answers with a valid certificate, HTTP 200 and an EMPTY body. The app is
# fine; the request never reached it. So ask the origin, from outside the
# compose network, and insist on seeing something the app actually renders.
PUBLIC_URL="${PIG_DEPLOY_PUBLIC_URL:-$(env_value PIG_PUBLIC_URL)}"
PUBLIC_URL="${PUBLIC_URL:-https://primeintellectgrowth.com}"
PUBLIC_MARKER="${PIG_DEPLOY_PUBLIC_MARKER:-<div id=\"root\">}"
echo "==> Verifying the public origin ($PUBLIC_URL)"
# Three outcomes, and conflating any two of them has already cost a deploy.
# `|| true` used to fold "curl never got an answer" into the empty string, so a
# host where the public name does not hairpin, or where egress to 443 is
# filtered, failed a completely healthy release — and autodeploy.sh then
# blacklists that digest for good. Keep the transport result separate from the
# body.
PUBLIC_ERR=$(mktemp)
trap 'rm -f "$PUBLIC_ERR"' EXIT
if PUBLIC_BODY=$(curl -sS --max-time 20 --location "${PUBLIC_URL%/}/" 2>"$PUBLIC_ERR"); then
CURL_STATUS=0
else
CURL_STATUS=$?
fi
PUBLIC_BYTES=${#PUBLIC_BODY}
if [ "$CURL_STATUS" -ne 0 ]; then
# Could not connect at all: DNS, TLS, no hairpin, filtered egress. This says
# nothing about the release, and nothing about the proxy either — from this
# host the two are indistinguishable. Do not fail a deploy that passed every
# check that can actually be trusted; set PIG_DEPLOY_REQUIRE_PUBLIC=1 on a
# host where the public name IS reachable from here and this must block.
echo " could not reach the public origin (curl exit $CURL_STATUS)" >&2
sed 's/^/ /' "$PUBLIC_ERR" >&2
if [ "${PIG_DEPLOY_REQUIRE_PUBLIC:-0}" = '1' ]; then
echo " PIG_DEPLOY_REQUIRE_PUBLIC=1, so this is fatal." >&2
exit "$EXIT_STILL_LIVE"
fi
echo " Not treated as a failure: the container passed every local gate," >&2
echo " and this host may simply not be able to route to its own name." >&2
echo " Verify $PUBLIC_URL from somewhere else." >&2
elif [ "$PUBLIC_BYTES" -eq 0 ]; then
echo " PUBLIC ORIGIN ANSWERED WITH AN EMPTY BODY" >&2
echo " Something terminated TLS and answered, so this is the proxy, not" >&2
echo " the release. Check that every Caddy site block on this host has" >&2
echo " 'bind 10.0.0.2'." >&2
# Deliberately no rollback: the previous image would fail this same check.
# Rolling back would churn the service and not fix a proxy fault. The release
# under test therefore stays live, which is what EXIT_STILL_LIVE tells the
# poller to say.
exit "$EXIT_STILL_LIVE"
elif ! printf '%s' "$PUBLIC_BODY" | grep -qF "$PUBLIC_MARKER"; then
echo " PUBLIC ORIGIN ANSWERED $PUBLIC_BYTES BYTES WITHOUT '$PUBLIC_MARKER'" >&2
# This one DOES roll back, unlike the empty-body case above. A proxy fault
# cannot serve a wrong-but-non-empty page for this hostname; a bad release
# can — a changed Vite `base`, a Dockerfile that stopped copying
# apps/web/dist. Every earlier gate passes in that state (the API is well,
# /api/dashboard still 401s) and only this one fires, so if it is the release
# then refusing to roll back leaves the broken release serving the public.
# Restoring the previous image is right when it is, and harmless when it is
# not: the marker check is the last gate, so the rollback costs one restart.
roll_back "public origin answered without '$PUBLIC_MARKER'"
else
echo " public origin serving PIG ($PUBLIC_BYTES bytes)"
fi
echo "==> Deployed $AFTER"