My-Weekly-ToDo-List/scripts/update-app.sh
mARTin-B78 162c56d0d3
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run
feat: fehlgeschlagener Build reisst die laufende App nicht mehr mit
next build lief direkt auf .next -- also auf dem Verzeichnis, aus dem die
laufende Instanz bedient wird. Brach der Build ab, fand PM2 server.js nicht
mehr (MODULE_NOT_FOUND) und geriet in eine Neustartschleife mit ueber 790
Versuchen. Am 07.09. war die Seite dadurch rund eine Stunde unbrauchbar.

Neuer Ablauf:
1. Build nach .next-build, .next bleibt unangetastet
2. statische Dateien und .env in den neuen Baum
3. Smoke-Test: der neue Build wird auf Port 3999 gestartet und muss auf
   /auth/login antworten -- ein Build kann durchlaufen und trotzdem nicht
   starten
4. erst danach umschalten, alter Stand bleibt als .next-previous liegen
5. PM2 neu starten

Scheitert etwas davon, raeumt der ERR-Trap .next-build weg und die laufende
App bleibt unveraendert. Die Meldung "die laufende App bleibt unberuehrt"
stimmt damit endlich -- vorher war sie schlicht falsch.

Geprueft wurde vorher, dass der umbenannte Build ueberhaupt startet: der
standalone-Ausgabebaum traegt den distDir-Namen intern, server.js findet
seine Dateien aber relativ zu sich selbst. Testweise gestartet, Login-Seite
und JS-Dateien lieferten 200.

Beide Faelle durchgespielt: Abbruch liess .next samt Markierung unberuehrt
und raeumte .next-build weg; Erfolg schaltete um, legte .next-previous an
und bestand den Smoke-Test.

v1.120.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:04 +02:00

196 lines
8.6 KiB
Bash
Executable File

#!/bin/bash
#
# Deploy script for the production host (WebApp-Testing-live).
#
# The app runs there as a PM2 process ("my-weekly-todo") from /root/My-Weekly-ToDo-List.
# It is NOT run under Docker — deploy.sh and docker-compose.yml in this repo describe an
# older Portainer setup that is not what serves todo.martin-bierschenk.de.
#
# This is the single update path. It is used both ways:
# - by hand: /root/My-Weekly-ToDo-List/scripts/update-app.sh [--force]
# - at boot: my-weekly-todo-update.service, before pm2-root.service starts the app
#
# There used to be a second, near-identical boot-update.sh. The two drifted (the npm ci fix
# from 1.114.1 only ever landed in this one), so boot-update.sh is now a shim that calls
# this script. Keep it that way: one script, one set of fixes.
#
# The whole body is wrapped in a function so bash parses it in one go: this script lives
# inside the very checkout it updates, and `git pull` rewriting it mid-run would otherwise
# make bash resume at a stale file offset.
main() {
# pipefail matters because everything below is written through a `tee` redirect: without
# it the shell sees tee's exit status (always 0) and a failed build looks like success.
set -euo pipefail
local APP_DIR="/root/My-Weekly-ToDo-List"
local PM2_APP="my-weekly-todo"
local LOG_FILE="/var/log/my-weekly-todo-update.log"
local LOCK_FILE="/var/lock/my-weekly-todo-update.lock"
local FORCE="${1:-}"
# One redirect for the whole run: goes to the log and, when run by hand, to the console.
exec > >(tee -a "$LOG_FILE") 2>&1
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
# Bei einem Abbruch den halbfertigen Build wegraeumen. Der Hinweis "die laufende App
# bleibt unberuehrt" stimmt seit v1.120.0 tatsaechlich -- vorher wurde direkt auf .next
# gebaut, ein Fehlschlag riss die laufende Instanz mit.
trap 'log "!!! Update FAILED at line $LINENO (exit $?) — die laufende App bleibt unberuehrt"; rm -rf "${APP_DIR:-.}/.next-build"' ERR
# Fail fast instead of hanging on a credential prompt when run without a terminal
# (systemd has no tty). The host authenticates via a read-only SSH deploy key.
export GIT_TERMINAL_PROMPT=0
# Never let a boot run and a manual run install into the same tree at once: a half-written
# node_modules is what leaves npm unable to clean up afterwards (ENOTEMPTY on rmdir).
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another update is already running — nothing to do."
return 0
fi
cd "$APP_DIR"
log "=== Update started ==="
log "--- Ensuring main branch ---"
git checkout main
log "--- Fetching from git ---"
git fetch origin main
local LOCAL REMOTE
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
# Rebuild when the code moved, when there is no build to serve, or when asked to.
local NEEDS_BUILD=0
[ "$LOCAL" != "$REMOTE" ] && NEEDS_BUILD=1
[ ! -f "$APP_DIR/.next/standalone/server.js" ] && NEEDS_BUILD=1
[ "$FORCE" = "--force" ] && NEEDS_BUILD=1
if [ "$NEEDS_BUILD" -eq 0 ]; then
log "Already up to date and a build is in place. Nothing to do."
log "=== Update finished (no changes) ==="
return 0
fi
if [ "$LOCAL" != "$REMOTE" ]; then
log "--- Pulling latest changes ($LOCAL -> $REMOTE) ---"
git pull origin main
# npm ci, not npm install: it installs exactly what package-lock.json specifies and
# never rewrites it. npm install rewrites the lockfile, which leaves the working tree
# dirty and makes the *next* git pull abort. npm ci clears node_modules itself; if
# that ever fails with ENOTEMPTY, remove the directory by hand and rerun.
log "--- Installing dependencies ---"
npm ci
fi
log "--- Updating database schema (safe migrate) ---"
npx prisma migrate deploy || {
log "migrate deploy failed, falling back to db push..."
npx prisma db push --accept-data-loss
}
log "--- Regenerating Prisma client ---"
npx prisma generate
# ── Build in ein Nebenverzeichnis ────────────────────────────────────────────────
#
# Frueher lief `next build` direkt auf .next -- also auf dem Verzeichnis, aus dem die
# laufende App bedient wird. Scheiterte der Build, war nicht nur das Update weg, sondern
# die laufende Instanz gleich mit: PM2 fand server.js nicht mehr und geriet in eine
# Neustartschleife. Genau das ist am 07.09. passiert, die Seite war rund eine Stunde
# unbrauchbar.
#
# Jetzt entsteht der neue Build unter .next-build. Das laufende .next wird dabei nicht
# angefasst. Erst wenn der neue Build gebaut UND probeweise gestartet werden konnte,
# wird umgeschaltet.
#
# Nodes Standard-Heap lag hier bei rund 1,5 GB und reichte nicht; ein Versuch mit 2048
# scheiterte punktgenau bei 2045 MB. Gemessener Bedarf sind rund 2,6 GB.
local BUILD_DIR=".next-build"
rm -rf "$BUILD_DIR"
log "--- Building application (nach $BUILD_DIR) ---"
NEXT_DIST_DIR="$BUILD_DIR" NODE_OPTIONS="--max-old-space-size=4096" npm run build
# next.config.js setzt output: 'standalone', und Next.js kopiert public/ und static/
# NICHT selbst dorthin -- ein dokumentierter manueller Schritt. Wird er ausgelassen,
# verweist das neue HTML auf Chunks, die im standalone-Baum fehlen: sie liefern 404,
# React hydriert nie, und jede Seite haengt auf "Loading session...", waehrend die API
# weiter 200 antwortet.
#
# Der innere Ordner traegt den Namen des distDir -- deshalb .next-build/static und nicht
# .next/static. Beim Umschalten wandert er unveraendert mit; server.js findet ihn
# relativ zu sich selbst (geprueft).
log "--- Copying static assets into standalone output ---"
cp -r public "$BUILD_DIR/standalone/"
mkdir -p "$BUILD_DIR/standalone/$BUILD_DIR/static"
cp -r "$BUILD_DIR/static/." "$BUILD_DIR/standalone/$BUILD_DIR/static/"
cp .env "$BUILD_DIR/standalone/" 2>/dev/null || true
# ── Smoke-Test auf einem freien Port ─────────────────────────────────────────────
#
# Ein Build kann durchlaufen und trotzdem nicht starten. Bevor der neue Stand live
# geht, muss er einmal wirklich geantwortet haben.
log "--- Smoke-Test des neuen Builds ---"
local SMOKE_PORT=3999
( cd "$BUILD_DIR/standalone" && PORT=$SMOKE_PORT node server.js > /tmp/smoke-test.log 2>&1 ) &
local SMOKE_PID=$!
local SMOKE_OK=0
for _ in $(seq 1 20); do
sleep 1
if curl -sf -o /dev/null --max-time 3 "http://127.0.0.1:$SMOKE_PORT/auth/login"; then
SMOKE_OK=1
break
fi
done
kill "$SMOKE_PID" 2>/dev/null || true
wait "$SMOKE_PID" 2>/dev/null || true
if [ "$SMOKE_OK" -ne 1 ]; then
log "!!! Smoke-Test fehlgeschlagen -- der neue Build antwortet nicht."
log " Die laufende App bleibt unveraendert. Ausgabe:"
tail -20 /tmp/smoke-test.log | sed 's/^/ /'
rm -rf "$BUILD_DIR"
return 1
fi
log " Smoke-Test bestanden."
# ── Umschalten ───────────────────────────────────────────────────────────────────
#
# Ab hier ist der neue Build erwiesenermassen lauffaehig. Der alte bleibt als
# .next-previous liegen; zum Zurueckrollen genuegt:
# pm2 stop my-weekly-todo
# rm -rf .next && mv .next-previous .next
# pm2 restart my-weekly-todo
log "--- Umschalten auf den neuen Build ---"
rm -rf .next-previous
if [ -d .next ]; then
mv .next .next-previous
fi
mv "$BUILD_DIR" .next
# At boot this script runs before pm2-root.service, so the PM2 daemon is not up yet and
# there is nothing to restart — pm2-root.service resurrects the app right after. Probing
# with `pm2 describe` would be wrong here: any pm2 command spawns a daemon, and a stray
# one started from this unit would race the real service.
local PM2_PID_FILE="${HOME:-/root}/.pm2/pm2.pid"
if [ -f "$PM2_PID_FILE" ] && kill -0 "$(cat "$PM2_PID_FILE")" 2>/dev/null; then
log "--- Restarting PM2 process ---"
pm2 restart "$PM2_APP" --update-env
pm2 save
else
log "PM2 daemon is not running — leaving the start to pm2-root.service."
fi
log "=== Update Complete ==="
log "Deployed version: $(node -p "require('$APP_DIR/package.json').version")"
}
main "$@"