guts/tools/deploy.sh
type-two bc891527ed [lane F] The deploy stops checking a URL nobody visits, and the frame rate gets a switch
Audit item 14.

THE DEPLOY VERIFIED THE ONE URL A PLAYER NEVER LOADS. Every check in deploy.sh appends
?v=$BUST — which is exactly what makes it bypass Cloudflare's edge — so all six could pass
green while every actual player was still being served the previous build from a POP. The
script's answer was a line of prose at the end asking a human to remember to purge. It now
fetches js/boot.js the way a browser does, with no query string, and byte-compares it to what
was just shipped; a stale edge fails the deploy and prints the purge steps. No credentials
needed, and it asks the question the prose was only gesturing at.

This is the same mistake as the `assets` bug and the unwinnable saliva tide: verifying the
mechanism rather than the path the player actually takes through it. Third time, so it is
written into the tool this time rather than into a comment.

QUALITY SWITCH. renderer.setPixelRatio was hardcoded to min(dpr, 2), so a retina laptop drew
four times the pixels of a 1x one with no way for its owner to decline — and this game is
fill-bound, not geometry-bound (fogged tube, additive glows, a full-screen damage layer), so
that is the lever that moves. `performance` caps it at 1. On the title beside difficulty,
saved, same reload path (the pixel ratio is set at boot, before any card exists).

save.js's two one-line setters collapsed into setOpt(key, value) now that there are two options
and a third is obvious.

Note on verification: the chip persists and boot reads it, confirmed live. The resolution
change itself is not observable in the harness — that browser pane runs at devicePixelRatio 1,
where min(1,1) and min(1,2) are the same number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:29:59 +10:00

119 lines
6.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# =====================================================================
# GUTS — deploy to https://partly.party/gutsy/
# =====================================================================
# Usage: bash tools/deploy.sh
#
# 1. QA gate (never ship red)
# 2. rsync web/ to VPS staging — MINUS web/dev/ (ruling #5: never shipped)
# 3. docker cp into forum-nginx web root
# 4. verify index AND data endpoints (see "the 200 that isn't" below)
#
# No build step: GUTS is vanilla ES modules + an importmap with three r175 vendored, so
# web/ ships as-is. All paths are relative (index.html) or resolved from import.meta.url
# (core/assets.js), which is why it runs from a subdirectory with no base rewrite.
#
# THE 200 THAT ISN'T. nginx.conf serves `root /usr/share/nginx/html` with a catch-all
# `try_files $uri $uri/ /index.html`. That's what routes /gutsy/ with no location block of
# its own — but it also means ANY missing file returns the forum's index.html with HTTP 200.
# A deploy that lost every module would still curl 200 on all of them. So each check below
# asserts on CONTENT, never on the status code alone. (This is the failure the deploy-map
# skill records as ".bin data URLs serving arcade HTML".)
#
# DURABILITY. This docker-cp's into the container's own layer, matching blobbo/glytch. It
# survives restarts and reboots (restart: unless-stopped) but NOT `docker compose up
# --force-recreate` or an nginx image bump — those wipe it, and re-running this script is
# the fix. The durable alternative is a bind mount in forum/docker-compose.yml like
# cratewars/roguelike have, which costs a forum-nginx recreate (brief partly.party downtime)
# and so is John's call to schedule, not this script's to take.
#
# Requirements: ssh humanjing@100.71.119.27 (tailscale), container forum-nginx.
# =====================================================================
set -euo pipefail
VPS="humanjing@100.71.119.27"
CONTAINER="forum-nginx"
REMOTE_WEB_ROOT="/usr/share/nginx/html/gutsy"
STAGING="/tmp/gutsy_deploy"
LIVE="https://partly.party/gutsy"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
echo "[1/4] QA gate ..."
bash "$ROOT/tools/qa.sh" > /tmp/gutsy_qa.log 2>&1 || { echo " ✗ QA RED — not shipping. See /tmp/gutsy_qa.log"; exit 1; }
echo " ✓ QA green"
echo "[2/4] Syncing web/ to VPS staging (excluding dev harnesses) ..."
COMMIT="$(git -C "$ROOT" rev-parse --short HEAD)"
if [ -n "$(git -C "$ROOT" status --porcelain -- web/)" ]; then
STAMP="$COMMIT-dirty"
echo " ⚠ web/ has uncommitted changes — what ships will match NO commit in the repo"
else
STAMP="$COMMIT"
fi
ssh "$VPS" "rm -rf '$STAGING' && mkdir -p '$STAGING'"
rsync -az --delete --exclude='.DS_Store' --exclude='dev/' "$ROOT/web/" "$VPS:$STAGING/"
# Provenance, curl-able: `curl https://partly.party/gutsy/VERSION` answers "what is actually
# live right now" without trusting anyone's memory of the last deploy.
ssh "$VPS" "printf '%s\n' '$STAMP' > $STAGING/VERSION"
echo " $(ssh "$VPS" "du -sh $STAGING | cut -f1") staged @ $STAMP"
echo "[3/4] Replacing container content ..."
ssh "$VPS" "docker exec $CONTAINER sh -c 'rm -rf ${REMOTE_WEB_ROOT:?} && mkdir -p ${REMOTE_WEB_ROOT:?}' && docker cp $STAGING/. $CONTAINER:$REMOTE_WEB_ROOT"
echo "[4/4] Verifying live endpoints (content, not status) ..."
BUST="$(date +%s)"
fail=0
check() { # check <label> <url-suffix> <grep-pattern>
local label="$1" url="$LIVE$2?v=$BUST" pat="$3" body
body="$(curl -fsS --max-time 20 "$url" 2>/dev/null || true)"
if [ -z "$body" ]; then
echo "$label — empty/failed response"; fail=1; return
fi
# The arcade index is what try_files falls through TO. Match its real title — checked
# against the live box, not guessed; the guard is worthless if the marker is wrong.
if grep -q 'M0NST3R R0B0T P4RTY 4RC4D3' <<<"$body" 2>/dev/null; then
echo "$label — served the ARCADE index (file is missing; try_files fell through)"; fail=1; return
fi
if ! grep -q "$pat" <<<"$body" 2>/dev/null; then
echo "$label — 200 but content unrecognised"; fail=1; return
fi
echo "$label"
}
check "VERSION stamp" "/VERSION" "$STAMP"
check "index.html" "/" 'id="game"'
check "js/boot.js" "/js/boot.js" 'Lane F'
check "ui/hud.js" "/js/ui/hud.js" 'createHUD'
check "vendor/three r175" "/vendor/three.module.js" 'REVISION'
check "assets/manifest" "/assets/manifest.json" 'textures'
# the no-trailing-slash form John will actually type
REDIR="$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$LIVE?v=$BUST")"
echo " · $LIVE (no slash) -> HTTP $REDIR (301/302 to /gutsy/ expected)"
[ "$fail" = "0" ] || { echo " ✗ DEPLOY UNVERIFIED"; exit 1; }
echo " ✓ deployed: $LIVE/"
# ---------------------------------------------------------------------
# THE CHECK ABOVE VERIFIES A URL NOBODY VISITS.
# Every check() appends ?v=$BUST, which is precisely what makes it bypass Cloudflare's edge
# cache — so all six can pass while every actual player is still being served the PREVIOUS
# build from a POP. The script's answer to that was a line of prose at the end telling a human
# to remember. This asks the question properly: fetch a module the way a browser does, with no
# query string, and compare it to the bytes we just shipped.
# ---------------------------------------------------------------------
echo "[5/5] Checking what a PLAYER gets (no cache-buster) ..."
EDGE="$(curl -fsS --max-time 20 "$LIVE/js/boot.js" 2>/dev/null || true)"
if [ -z "$EDGE" ]; then
echo " ⚠ could not fetch $LIVE/js/boot.js un-busted — cannot tell whether the edge is stale"
elif [ "$EDGE" = "$(cat "$ROOT/web/js/boot.js")" ]; then
echo " ✓ edge is serving this build"
else
echo " ✗ STALE EDGE — players are still being served the previous build."
echo " Cloudflare has partly.party/gutsy/js/*.js cached. Purge it:"
echo " Cloudflare dashboard -> partly.party -> Caching -> Configuration -> Purge Everything"
echo " (or Custom Purge on $LIVE/js/). Then re-run this script to confirm."
exit 1
fi