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>
178 lines
8.2 KiB
Bash
Executable File
178 lines
8.2 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
|
|
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."
|