diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..84d7e13 --- /dev/null +++ b/scripts/deploy.sh @@ -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" </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."