Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
325 changes: 325 additions & 0 deletions .claude/skills/handoff-report/SKILL.md

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Root .dockerignore — applies to any build whose context is the repo root
# (currently: Dockerfile.frontend, context `.`). backendServer/ has its own
# .dockerignore for its own context (backendServer/); this file doesn't
# replace that, it covers the root-context builds.
#
# Keep in sync with .gitignore where the two overlap. The point of this file
# is as much about SECRETS as it is about build speed: the repo root holds a
# real private key (oraclevps.key) and broadcastWeb/ holds a committed cert
# (commons-broadcast.pem) — neither may ever enter a build context.
#
# IMPORTANT — verified empirically against this Docker Desktop/buildkit
# version: a pattern WITHOUT a leading `**/` only matches at the exact
# context-root path, even with no slash in the pattern at all (e.g. bare
# `*.pem` did NOT exclude a nested broadcastWeb/commons-broadcast.pem in a
# throwaway test build — only `**/*.pem` did). This is stricter than plain
# .gitignore semantics, where a no-slash pattern matches at any depth. So
# every pattern below that must also apply inside a subdirectory (which is
# nearly all of them, since theCommonsWeb/, broadcastWeb/, backendServer/
# are subdirectories of this context) carries an explicit `**/` form —
# don't "simplify" these back down to bare patterns.

# ── Secrets — must never enter a build context ────────────────────────────
oraclevps.key
*.key
**/*.key
*.pem
**/*.pem
.env
**/.env
.env.*
**/.env.*
!.env.example
!**/.env.example

# ── VCS ────────────────────────────────────────────────────────────────────
.git/
.gitignore

# Claude Code worktrees — full duplicate checkouts (own node_modules/.venv)
# used for parallel agent sessions, not application source. Never relevant
# to a build and, left in, they alone can add several hundred MB of stale
# installs to the context.
.claude/worktrees/

# ── Node ───────────────────────────────────────────────────────────────────
# node_modules is reinstalled fresh inside each stage via `pnpm install
# --frozen-lockfile` — a host-built node_modules (wrong OS/arch: dev is
# Apple Silicon, prod is Oracle ARM64; also pnpm's node_modules is a symlink
# farm into a content-addressed store that doesn't travel with the folder)
# must never be copied in.
node_modules/
**/node_modules/

# Build output — produced fresh inside the image, not copied from host.
.next/
**/.next/
dist/
**/dist/

# TypeScript incremental build cache (generated, host-specific, diverges on
# every build — see root .gitignore).
*.tsbuildinfo
**/*.tsbuildinfo

# ── Python / backend cruft (irrelevant to the frontend builds, but a root-
# context build still walks the whole tree unless excluded here) ──────────
backendServer/.venv/
backendServer/staticfiles/
**/.venv/
**/staticfiles/
__pycache__/
**/__pycache__/
*.pyc
**/*.pyc
.mypy_cache/
**/.mypy_cache/
.ruff_cache/
**/.ruff_cache/
.pytest_cache/
**/.pytest_cache/

# ── Runtime/generated data ──────────────────────────────────────────────────
dump.rdb
broadcast-extension.zip

# ── Editor/OS cruft ─────────────────────────────────────────────────────────
.DS_Store
**/.DS_Store
.idea/
**/.idea/
.vscode/
**/.vscode/

# Local docker-compose bind-mount targets
**/.local-dev/
199 changes: 150 additions & 49 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,80 +208,171 @@ jobs:
username: ${{ secrets.ORACLE_USER }}
key: ${{ secrets.ORACLE_SSH_KEY }}
fingerprint: ${{ steps.fp.outputs.fingerprint }}
# Mirrors DEPLOY.md §Deploying Updates (validated dry-run in 16-12-vm-prep).
# uv is at /snap/bin (not on the non-interactive PATH); pnpm is a global
# npm bin already on PATH. set -e aborts on the first failure.
# Mirrors DEPLOY.md §Deploying Updates. Containers via
# docker-compose.yml (T6/T7 of the Dockerization suite) replace the
# old uv-sync + pnpm-build + systemctl-restart flow. Requires the VM
# to have Docker Engine + the `docker compose` v2 plugin, and the
# deploy user in the `docker` group — one-time setup, documented in
# a separate ticket. No more `sudo`: group membership is enough.
# `-f docker-compose.yml` is REQUIRED on every invocation below — a
# bare `docker compose` also auto-loads docker-compose.override.yml
# (local-dev-only: plain HTTP, no cert, repo-relative bind mounts),
# which would deploy the dev config to prod. set -e aborts on the
# first failure.
script: |
set -e
# CI=true lets pnpm purge/rebuild node_modules non-interactively (no TTY
# over SSH); without it pnpm aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
export CI=true
cd /home/ubuntu/thecommons
# pnpm build regenerates tsconfig.tsbuildinfo locally each run, which used
# to be tracked in git and would then block the fast-forward pull below.
# It's a generated TS incremental-build cache, now gitignored — discard any
# local copy so the pull can proceed even before that lands on this VM.
# The real frontend build now happens inside `docker compose
# build` (Dockerfile.frontend), not via a host-side pnpm, but a
# stray tracked/leftover tsconfig.tsbuildinfo from a pre-Docker
# deploy could still block the fast-forward below — cheap
# insurance to discard it before pulling.
git checkout -- theCommonsWeb/tsconfig.tsbuildinfo broadcastWeb/tsconfig.tsbuildinfo 2>/dev/null || true
git pull origin main

cd backendServer
/snap/bin/uv sync
# Build every image on the VM — arm64-native (the VM is Oracle
# Ubuntu 24.04 ARM64) and needs no registry. nextjs and
# broadcast-spa-build bake NEXT_PUBLIC_*/VITE_* values in as
# build args with safe placeholder defaults (see
# docker-compose.yml's header) — a bare `build` here would
# silently ship those placeholders (verified failure mode: a
# broadcast bundle with no thecommons.town API origin baked in,
# misrouting every call at runtime). Source the real env files
# and re-export under the *_BUILD_* names docker-compose.yml's
# build args actually read. Scoped to this subshell so these
# values (DATABASE_URL, BETTER_AUTH_SECRET, ...) don't linger in
# the wider script env.
(
set -a
. theCommonsWeb/.env.local
. broadcastWeb/.env
set +a
export NEXTJS_BUILD_DATABASE_URL="$DATABASE_URL"
export NEXTJS_BUILD_BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET"
export NEXTJS_BUILD_BETTER_AUTH_URL="$BETTER_AUTH_URL"
export NEXTJS_BUILD_NEXT_PUBLIC_BETTER_AUTH_URL="$NEXT_PUBLIC_BETTER_AUTH_URL"
export NEXTJS_BUILD_NEXT_PUBLIC_API_BASE_URL="$NEXT_PUBLIC_API_BASE_URL"
export NEXTJS_BUILD_NEXT_PUBLIC_THE_COMMONS_API_KEY="$NEXT_PUBLIC_THE_COMMONS_API_KEY"
export BROADCAST_BUILD_VITE_BROADCAST_API_BASE_URL="$VITE_BROADCAST_API_BASE_URL"
export BROADCAST_BUILD_VITE_BETTER_AUTH_URL="$VITE_BETTER_AUTH_URL"
export BROADCAST_BUILD_VITE_BROADCAST_EXTENSION_ID="$VITE_BROADCAST_EXTENSION_ID"

# Guard: `. env_file` is shell sourcing, NOT a dotenv parser, so a
# BARE value containing a shell metacharacter is mis-parsed rather
# than rejected. The real case this caught: theCommonsWeb/.env.local
# had an unquoted DATABASE_URL whose Neon query string contains `&`
# (`...?sslmode=require&channel_binding=require`). The shell reads
# `VAR=x & y=z` as "assign x in a BACKGROUND subshell, then assign
# y", so DATABASE_URL never reached this shell at all. Without this
# guard the export below sets it to the empty string, compose's
# `${NEXTJS_BUILD_DATABASE_URL:-<placeholder>}` treats empty as
# unset, and the build "succeeds" with a placeholder DB URL baked
# into the image — green pipeline, wrong artifact.
#
# Fix the env file (quote the value); this only makes the failure
# loud. Every var here is required, so empty is always a bug.
missing=""
for v in NEXTJS_BUILD_DATABASE_URL NEXTJS_BUILD_BETTER_AUTH_SECRET \
NEXTJS_BUILD_BETTER_AUTH_URL NEXTJS_BUILD_NEXT_PUBLIC_BETTER_AUTH_URL \
NEXTJS_BUILD_NEXT_PUBLIC_API_BASE_URL NEXTJS_BUILD_NEXT_PUBLIC_THE_COMMONS_API_KEY \
BROADCAST_BUILD_VITE_BROADCAST_API_BASE_URL BROADCAST_BUILD_VITE_BETTER_AUTH_URL; do
eval "val=\${$v:-}"
[ -n "$val" ] || missing="$missing $v"
done
if [ -n "$missing" ]; then
echo "::error::empty build arg(s):$missing — a value in theCommonsWeb/.env.local or broadcastWeb/.env failed to survive shell sourcing (usually a bare value containing & or a space; quote it)"
exit 1
fi

docker compose -f docker-compose.yml build
)

# Bug-class check, now run against the built image instead of a
# host dist/ dir: a malformed VITE_BROADCAST_API_BASE_URL (e.g.
# missing "//") builds "successfully" but silently misroutes
# every API call. Fail here, before the broken image goes live,
# rather than discovering it later via a confusing 404/405 in
# production.
if ! docker compose -f docker-compose.yml run --rm -T --no-deps --entrypoint sh broadcast-spa-build \
-c 'grep -rq "https\?://[a-zA-Z0-9.-]*thecommons\.town" /app/dist/assets/*.js'; then
echo "::error::broadcastWeb build does not reference a thecommons.town API origin — check broadcastWeb/.env"
exit 1
fi

# Guarded migrate: `migrate --check` exits non-zero when unapplied
# migrations exist and applies nothing (Django 6: "Exits with a
# non-zero status if unapplied migrations exist and does not
# actually apply migrations"). So prod only writes schema when
# there is real work — and never without a fresh pre-migrate dump.
if /snap/bin/uv run python manage.py migrate --check; then
# there is real work — and never without a fresh pre-migrate
# dump. Django commands run via one-shot containers off the
# image just built above; --no-deps skips starting redis/etc.
# for a plain management command.
if docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py migrate --check; then
echo "No unapplied migrations — skipping migrate."
else
echo "Unapplied migrations detected — plan tail:"
/snap/bin/uv run python manage.py showmigrations --plan | tail -20
# A silent skip here would defeat the guard: no pg_dump = no
# backup = hard failure, never a warning.
if ! command -v pg_dump >/dev/null 2>&1; then
echo "::error::pg_dump not found on the VM — run: sudo apt install postgresql-client. Refusing to migrate without a pre-migrate backup."
exit 1
fi
docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py showmigrations --plan | tail -20

mkdir -p /home/ubuntu/backups
# Subshell so backendServer/.env (DATABASE_URL etc.) never leaks
# into the rest of this script's env or the log (set -x stays
# off). The app's DATABASE_URL already carries Neon's sslmode,
# so pg_dump consumes it as-is. pipefail: a failed pg_dump must
# not leave a truncated-but-"green" gzip behind. This dump is
# pg_dump now runs in a postgres:18-alpine container: the VM no
# longer ships postgresql-client, and the backend image (slim
# Python) never did. Pinned to 18 deliberately — pg_dump can
# dump servers older than itself but refuses newer ones, so the
# newest client stays unconditionally safe against whatever
# version Neon runs. Subshell so backendServer/.env
# (DATABASE_URL etc.) never leaks into the rest of this
# script's env or the log (set -x stays off); `-e
# DATABASE_URL` (no `=value`) passes it into the container by
# reference so the value never appears in argv either.
# `/home/ubuntu/backups` is bind-mounted so the dump lands on
# the host and survives the container being removed (--rm).
# set -o pipefail (verified supported by postgres:18-alpine's
# /bin/sh): a failed pg_dump must not leave a
# truncated-but-"green" gzip behind. A silent skip here would
# defeat the guard: no dump = no backup = hard failure, never a
# warning — set -e (outer) + pipefail (inner) already enforce
# that without a separate tool-existence check. This dump is
# belt-and-suspenders — Neon PITR/branching is the real restore
# mechanism (see DEPLOY.md).
(
set -o pipefail
set -a; . ./.env; set +a
pg_dump "$DATABASE_URL" | gzip > "/home/ubuntu/backups/pre-migrate-$(date +%Y%m%d-%H%M%S).sql.gz"
set -a; . backendServer/.env; set +a
docker run --rm \
-e DATABASE_URL \
-v /home/ubuntu/backups:/backups \
postgres:18-alpine \
sh -c 'set -o pipefail; pg_dump "$DATABASE_URL" | gzip > "/backups/pre-migrate-$(date +%Y%m%d-%H%M%S).sql.gz"'
)
# Keep only the 5 newest dumps.
ls -1t /home/ubuntu/backups/pre-migrate-*.sql.gz | tail -n +6 | xargs -r rm -f --
/snap/bin/uv run python manage.py migrate --noinput
docker compose -f docker-compose.yml run --rm -T --no-deps migrate python manage.py migrate --noinput
fi

/snap/bin/uv run python manage.py collectstatic --noinput

cd ../theCommonsWeb
pnpm install --frozen-lockfile
pnpm run build

cd ../broadcastWeb
pnpm install --frozen-lockfile
pnpm run build
# collectstatic is no longer a deploy step — it now runs at image
# build time (backendServer/Dockerfile bakes it into
# /app/staticfiles_build/static, and deploy/nginx/Dockerfile
# COPY --from='s it into the nginx image above).
#
# pnpm install/build are no longer deploy steps either —
# Dockerfile.frontend does both frontend builds as part of the
# `docker compose build` above; there is no host-side
# node_modules or build step left to run.

# Bug-class check: a malformed VITE_BROADCAST_API_BASE_URL (e.g. missing
# "//") builds "successfully" but silently misroutes every API call.
# Fail here, before the broken build goes live, rather than discovering
# it later via a confusing 404/405 in production.
if ! grep -rq 'https\?://[a-zA-Z0-9.-]*thecommons\.town' dist/assets/*.js; then
echo "::error::broadcastWeb build does not reference a thecommons.town API origin — check broadcastWeb/.env"
exit 1
fi
# Recreate every service from the images just built. No more
# `sudo systemctl restart` — the deploy user is in the `docker`
# group, so this runs unprivileged.
docker compose -f docker-compose.yml up -d

sudo -n systemctl restart gunicorn nextjs celery celerybeat broadcast-worker scrape-worker
systemctl is-active gunicorn nextjs celery celerybeat broadcast-worker scrape-worker
# Health assertion replacing `systemctl is-active`. migrate and
# broadcast-spa-build are one-shot (`restart: "no"`) and exit 0
# by design, so they're excluded from the "still running" check.
docker compose -f docker-compose.yml ps
running_services=$(docker compose -f docker-compose.yml ps --status running --services)
for svc in redis backend celery celerybeat broadcast-worker scrape-worker nextjs nginx; do
if ! printf '%s\n' "$running_services" | grep -qx "$svc"; then
echo "::error::service '$svc' is not running after deploy — see docker compose ps output above"
exit 1
fi
done
- name: Post-deploy smoke test
uses: appleboy/ssh-action@v1
with:
Expand Down Expand Up @@ -312,6 +403,16 @@ jobs:
check "https://thecommons.town/" 200
check "https://broadcast.thecommons.town/" 200
check "https://api.thecommons.town/events/" 200
# Fourth origin, easy to forget because nothing user-facing points
# at it directly: auth.thecommons.town is what makes the
# .thecommons.town cookie domain work across subdomains (suite 37),
# and backendServer/.env's BETTER_AUTH_JWKS_URL points here, so every
# broadcast JWT verification fetches this exact URL. Probing the JWKS
# endpoint rather than `/` checks the thing the backend depends on.
# Dropping this server block from deploy/nginx/thecommons.conf makes
# the hostname fall through to the www->apex redirect, which the
# garbage-JWT probe below would only report indirectly as a 500.
check "https://auth.thecommons.town/api/auth/jwks" 200

# Regression check for the Unix-socket REMOTE_ADDR gap: nginx proxies
# to gunicorn over a Unix socket, which used to leave REMOTE_ADDR
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ dump.rdb

# Chrome Web Store package (generated by extensionzipper.sh)
broadcast-extension.zip

# Local docker-compose bind-mount targets (docker-compose.override.yml)
.local-dev/
Loading
Loading