#!/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."