I shipped a working tree earlier and had to tell John "live matches no commit in
the repo" — which is exactly the sentence a deploy should make impossible.
deploy.sh now writes the HEAD short-sha to web root as VERSION, and verifies it
came back. `curl https://partly.party/gutsy/VERSION` answers "what is online right
now" without trusting anyone's memory of the last deploy.
The dirty check is scoped to `git status --porcelain -- web/`, not the whole
tree, because only web/ ships: editing docs or this script does not make the
payload unreproducible, and a stamp that cried wolf on every NOTES edit would get
ignored. A dirty payload ships as `<sha>-dirty` and says so, loudly, rather than
refusing — mid-round lanes need to push a look at something without committing
first, and a deploy tool that blocks that will just get bypassed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README said "target: partly.party, not wired yet". It's wired.
tools/deploy.sh follows the GAMES doctrine (deploy-map skill): QA gate -> rsync
web/ to VPS staging -> docker cp into forum-nginx -> verify. No build step, so
web/ ships as-is: vanilla ES modules + importmap with three r175 vendored, all
paths relative or resolved from import.meta.url, which is why it runs from a
subdirectory with no base rewrite. 6.6 MB. nginx.conf needed no edit — its
catch-all `root /usr/share/nginx/html` already routes any new directory, which is
how blobbo works. /gutsy (no slash) 301s to /gutsy/.
Excludes web/dev/ (ruling #5: never shipped). Verified live, not assumed: the
lane harnesses 404 through to the arcade index.
THE 200 THAT ISN'T. That same catch-all try_files means ANY missing file returns
the arcade's index.html with HTTP 200. A deploy that lost every module would
still curl 200 on all of them. So every check asserts on CONTENT, never on the
status code — this is the failure deploy-map records as ".bin data URLs serving
arcade HTML". The guard caught itself, too: it originally grepped for
`<title>MonsterRobot`, but the arcade's real title is
`~*~W3LC0M3 2 TH3 M0NST3R R0B0T P4RTY 4RC4D3~*~`, so it could never have fired.
Matched against the live box now.
DURABILITY, and it's John's call. This docker-cp's into the container's own
layer, matching blobbo/glytch: zero downtime, nothing else touched, survives
restarts and reboots (restart: unless-stopped) — but NOT `docker compose up
--force-recreate` or an nginx image bump, which wipe it. Re-running the 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 and brief downtime
across ALL of partly.party, so this script does not take that decision.
ship-check (README says run it before deploying — I ran it after; my miss, and it
came back clean):
1 auth N/A — static files, no endpoint/API/admin surface
2 secrets PASS — nothing secret-shaped in the payload or the diff
3 input/SSRF PASS — the only user string reaching a fetch is ?lvl=, and C
gates it on the CAMPAIGN allowlist BEFORE the load
(levels/index.js:151). Traversal tested live with
`curl --path-as-is` (plain curl normalises ../ away and proves
nothing): /etc/passwd never leaked. DEPOT_BASE in assets.js is
unused — the manifest has zero external URLs.
4 money N/A
5 deploy PASS — verified by content + booted in a real browser:
assets.misses() == [], 15 draws, 69k tris, zero console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>