pwaLUPMIS2/scripts/deploy.sh
ekke e3e30ad709 Deploy script: rsync the build across instead of re-sending it whole
Transferring with tar meant pushing the full 16 MB on every deploy, most of it
unchanged source maps, over the link to Accra. rsync sends only what differs —
on a no-op deploy that is now zero bytes.

--delete matters as much as the speed. A staging directory without it quietly
accumulates every asset ever built, which is precisely how the stale chunks kept
reappearing: the existing staging area at /home/sema_gopa/pwa/dist holds 226
files against a build of 47, so copying from it restored everything that had
been pruned. The trailing slash on the source copies contents including
dotfiles, so .htaccess comes along; dist/* would skip it. tar remains as a
fallback where rsync is not installed, and the AppleDouble sweep stays for both.

The final hop still has to be docker cp. rsync is not installed in the
container, the live src directory is owned by root so sema_gopa cannot create or
delete entries in it, and there is no passwordless sudo — docker exec is the
only route that has root where the bind mount lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 16:29:41 +02:00

195 lines
9.1 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Deploy the built PWA to the LUPMIS2 server.
#
# WHY THIS EXISTS
# Deploying by hand went wrong twice. Once the files went to
# /home/sema_gopa/pwa/dist, which nothing serves, so a fix appeared to be live
# for a week when it was not. And that directory is not a git checkout — it has
# simply accumulated every asset ever copied into it (226 files against a build
# of 47), so deploying from it restores every stale chunk and quietly undoes any
# pruning. This script removes both traps: it builds locally, transfers only
# that build, and makes the served directory mirror it exactly.
#
# WHAT IT KNOWS THAT A PLAIN rsync DOES NOT
# - The docroot is root-owned, so files go in through `docker cp` (the
# container runs as root and /var/www/html is a bind mount to the host).
# - macOS tar writes AppleDouble `._*` companions when extended attributes are
# present; `.htaccess` arrived once as `._.htaccess` too. COPYFILE_DISABLE=1
# plus a sweep handles that.
# - A glob like `dist/*` silently skips `.htaccess`, which is what carries the
# rewrite rules. Everything here is copied with `.` so dotfiles come along.
#
# USAGE
# scripts/deploy.sh build, deploy, prune assets, verify
# scripts/deploy.sh --skip-build deploy whatever is already in dist/
# scripts/deploy.sh --dry-run show what would change, touch nothing
# scripts/deploy.sh --prune-all also delete non-asset files the build
# does not contain (default: report only)
#
set -euo pipefail
SSH_HOST=${SSH_HOST:-ssh_l4l}
CONTAINER=${CONTAINER:-pwaproject}
DOCROOT=${DOCROOT:-/var/www/html}
STAGING=${STAGING:-pwa-deploy}
SITE=${SITE:-https://pwa.lupmis4luspa.org}
DIST=${DIST:-dist}
DRY_RUN=0; SKIP_BUILD=0; PRUNE_ALL=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--skip-build) SKIP_BUILD=1 ;;
--prune-all) PRUNE_ALL=1 ;;
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
*) echo "unknown option: $arg (try --help)" >&2; exit 2 ;;
esac
done
say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
item() { printf ' %s\n' "$*"; }
die() { printf '\n\033[31mFAILED:\033[0m %s\n' "$*" >&2; exit 1; }
cd "$(dirname "$0")/.."
# ---------------------------------------------------------------- 1. build
if [ "$SKIP_BUILD" -eq 0 ]; then
say "Building"
npm run build >/dev/null || die "the build failed"
item "built"
else
say "Skipping the build (--skip-build)"
fi
[ -d "$DIST" ] || die "$DIST does not exist — run without --skip-build"
# ------------------------------------------------- 2. sanity-check the build
say "Checking the build"
ENTRY=$(grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' "$DIST/index.html" | head -1) \
|| die "index.html names no entry chunk"
[ -f "$DIST/$ENTRY" ] || die "index.html references $ENTRY, which is not in the build"
[ -f "$DIST/.htaccess" ] || die ".htaccess is missing from the build"
[ -f "$DIST/index.php" ] || die "index.php is missing from the build"
SW_VERSION=$(grep -oE "CACHE_VERSION = '[^']+'" "$DIST/sw.js" | head -1 | cut -d"'" -f2)
LOCAL_FILES=$(find "$DIST" -type f | wc -l | tr -d ' ')
LOCAL_ASSETS=$(find "$DIST/assets" -maxdepth 1 -type f | wc -l | tr -d ' ')
item "entry chunk : $ENTRY"
item "service worker : $SW_VERSION"
item "files : $LOCAL_FILES ($LOCAL_ASSETS assets)"
if [ "$DRY_RUN" -eq 1 ]; then
say "Dry run — comparing against the server, changing nothing"
ssh -o BatchMode=yes "$SSH_HOST" \
"docker exec $CONTAINER sh -c 'ls $DOCROOT/assets'" 2>/dev/null | sort > /tmp/dep_have.txt || true
find "$DIST/assets" -maxdepth 1 -type f -exec basename {} \; | sort > /tmp/dep_keep.txt
item "would add : $(comm -23 /tmp/dep_keep.txt /tmp/dep_have.txt | wc -l | tr -d ' ')"
item "would prune : $(comm -13 /tmp/dep_keep.txt /tmp/dep_have.txt | wc -l | tr -d ' ')"
comm -13 /tmp/dep_keep.txt /tmp/dep_have.txt | head -5 | sed 's/^/ /'
exit 0
fi
# ------------------------------------------------------------- 3. transfer
# rsync, not tar: most of a build is unchanged source maps, and over the link to
# Accra re-sending 16 MB every time is wasteful. --delete keeps the staging
# directory honest — without it a staging area quietly accumulates every asset
# ever built, which is exactly how stale chunks kept coming back. A trailing
# slash on the source copies the contents, dotfiles included, so .htaccess comes
# along; `dist/*` would skip it.
say "Transferring to $SSH_HOST:~/$STAGING"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete --no-perms --no-owner --no-group --info=stats2 \
-e "ssh -o BatchMode=yes" \
"$DIST"/ "$SSH_HOST:~/$STAGING/" 2>/dev/null \
| grep -E "Number of .*(transferred|deleted)|Total transferred" \
| sed 's/^/ /' || die "rsync failed"
else
item "rsync not found locally — falling back to tar"
COPYFILE_DISABLE=1 tar -C "$DIST" -cf - . 2>/dev/null \
| ssh -o BatchMode=yes "$SSH_HOST" "
rm -rf ~/$STAGING && mkdir -p ~/$STAGING &&
tar -C ~/$STAGING -xf - 2>/dev/null
" || die "the transfer failed"
fi
# macOS tar writes AppleDouble companions when extended attributes are present;
# .htaccess arrived as ._.htaccess once. rsync does not, but sweep either way.
ssh -o BatchMode=yes "$SSH_HOST" "find ~/$STAGING -name '._*' -delete" || true
REMOTE_FILES=$(ssh -o BatchMode=yes "$SSH_HOST" "find ~/$STAGING -type f | wc -l" | tr -d ' ')
item "staged $REMOTE_FILES files"
[ "$REMOTE_FILES" = "$LOCAL_FILES" ] \
|| die "staged $REMOTE_FILES files but the build has $LOCAL_FILES — refusing to deploy a partial copy"
ssh -o BatchMode=yes "$SSH_HOST" "test -f ~/$STAGING/.htaccess" \
|| die ".htaccess did not survive the transfer"
item ".htaccess present"
# ------------------------------------------------- 4. back up, copy, prune
say "Deploying"
ssh -o BatchMode=yes "$SSH_HOST" "bash -s" <<REMOTE || die "the deploy step failed"
set -euo pipefail
STAMP=\$(date +%Y%m%d-%H%M%S)
# A copy of what is being replaced, so a bad deploy is one command to undo.
docker exec $CONTAINER sh -c 'cd $DOCROOT && tar -czf /tmp/pre-deploy.tgz .' >/dev/null 2>&1 || true
docker cp $CONTAINER:/tmp/pre-deploy.tgz ~/pre-deploy-\$STAMP.tgz >/dev/null 2>&1 || true
echo " backup: ~/pre-deploy-\$STAMP.tgz"
docker cp ~/$STAGING/. $CONTAINER:$DOCROOT/
echo " copied"
# Make assets/ mirror the build exactly. These are content-hashed build output
# and nothing else belongs there, so anything absent from the build is stale.
ls ~/$STAGING/assets | sort > /tmp/dep_keep.txt
docker exec $CONTAINER sh -c 'ls $DOCROOT/assets' | sort > /tmp/dep_have.txt
comm -13 /tmp/dep_keep.txt /tmp/dep_have.txt > /tmp/dep_stale.txt
STALE=\$(wc -l < /tmp/dep_stale.txt | tr -d ' ')
if [ "\$STALE" -gt 0 ]; then
docker cp /tmp/dep_stale.txt $CONTAINER:/tmp/dep_stale.txt >/dev/null
docker exec $CONTAINER sh -c 'cd $DOCROOT/assets && while IFS= read -r f; do [ -n "\$f" ] && rm -f -- "\$f"; done < /tmp/dep_stale.txt'
fi
echo " pruned \$STALE stale assets; \$(docker exec $CONTAINER sh -c 'ls $DOCROOT/assets | wc -l') remain"
# Files outside assets/ are reported, not deleted: someone may have put
# something there deliberately, and this script should not be the thing that
# discovers that the hard way.
( cd ~/$STAGING && find . -type f | sed 's|^\./||' | sort ) > /tmp/dep_keep_all.txt
docker exec $CONTAINER sh -c 'cd $DOCROOT && find . -type f | sed "s|^\./||"' | sort > /tmp/dep_have_all.txt
comm -13 /tmp/dep_keep_all.txt /tmp/dep_have_all.txt | grep -v '^assets/' > /tmp/dep_extra.txt || true
EXTRA=\$(wc -l < /tmp/dep_extra.txt | tr -d ' ')
if [ "\$EXTRA" -gt 0 ]; then
if [ "$PRUNE_ALL" -eq 1 ]; then
docker cp /tmp/dep_extra.txt $CONTAINER:/tmp/dep_extra.txt >/dev/null
docker exec $CONTAINER sh -c 'cd $DOCROOT && while IFS= read -r f; do [ -n "\$f" ] && rm -f -- "\$f"; done < /tmp/dep_extra.txt'
echo " removed \$EXTRA file(s) outside assets/ (--prune-all)"
else
echo " NOTE: \$EXTRA file(s) outside assets/ are not in this build:"
sed 's/^/ /' /tmp/dep_extra.txt | head -10
echo " left in place — re-run with --prune-all to remove them"
fi
fi
REMOTE
# -------------------------------------------------------------- 5. verify
say "Verifying $SITE"
check () { # path, expected, label
local code
code=$(curl -s -o /dev/null -m 25 -w '%{http_code}' "$SITE$1" || echo 000)
if [ "$code" = "$2" ]; then item "ok $3 ($code)"
else item "WARN $3 — expected $2, got $code"; FAILED=1; fi
}
FAILED=0
check "/index.html" 302 "/index.html routes through index.php"
check "/$ENTRY" 200 "entry chunk is served"
check "/assets/" 403 "directory listing is closed"
check "/sw.js" 200 "service worker is served"
check "/manifest.json" 200 "manifest is served"
DEPLOYED_SW=$(curl -s -m 25 "$SITE/sw.js" | grep -oE "CACHE_VERSION = '[^']+'" | head -1 | cut -d"'" -f2)
if [ "$DEPLOYED_SW" = "$SW_VERSION" ]; then item "ok service worker live at $DEPLOYED_SW"
else item "WARN service worker is $DEPLOYED_SW, expected $SW_VERSION"; FAILED=1; fi
[ "$FAILED" -eq 0 ] || die "deployed, but some checks did not pass — see above"
say "Done"
item "An installed app keeps the previous worker until $SW_VERSION activates."
item "Downloaded map tiles survive: TILE_CACHE_VERSION is pinned separately."