Add a deploy script, after hand-deploys went wrong twice

Deploying by hand failed in two different ways. Once the files went to
/home/sema_gopa/pwa/dist, which nothing serves, so a fix looked live for a week
while the site ran an August build. Then pruning stale assets kept being undone:
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, including bundles still carrying the unrotated API token.

The script makes the served directory mirror a freshly built dist. It also
carries the things a plain copy keeps getting wrong here:

  - 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, and `.htaccess` once arrived
    as `._.htaccess`. COPYFILE_DISABLE=1 plus a sweep handles that.
  - `dist/*` silently skips `.htaccess`, which carries the rewrite rules, so
    everything is copied with `.` and the transfer is refused unless the staged
    file count matches the build and .htaccess survived.

It backs the docroot up before touching it, prunes assets/ to exactly the build,
and verifies over HTTP afterwards — including that the live service worker
version matches the one just built. Files outside assets/ are reported rather
than deleted, since someone may have put them there deliberately; --prune-all
removes them.

That caution paid for itself on the first run: it found a legacy icons/
directory, eleven files from May, left behind when the icons moved to
app-icons/. Apache had been 404ing them regardless, because Debian's
alias.conf maps /icons/ to /usr/share/apache2/icons/ for directory listings —
which is presumably why the directory was renamed. Removed with --prune-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-10-21 14:44:45 +02:00
parent 8c859197ad
commit 9e62344471

177
scripts/deploy.sh Executable file
View File

@ -0,0 +1,177 @@
#!/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
say "Transferring to $SSH_HOST:~/$STAGING"
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
find ~/$STAGING -name '._*' -delete
" || die "the transfer failed"
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."