#!/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 # pm2 lives in /usr/local/bin. The systemd unit sets this explicitly, but a plain # `ssh host 'scripts/update-app.sh'` (no login shell, .bashrc never sourced) does not — # it leaves PATH at /usr/bin:/bin and the restart step below dies with "pm2: not found", # after the build has already succeeded and switched over. Hit in production 2026-09-12. export PATH="/usr/local/bin:$PATH" 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"; kill "${SMOKE_PID:-0}" 2>/dev/null || true; 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) # Gebaut wird, wenn der vorhandene Build nicht zum aktuellen Stand passt. # # Frueher wurde HEAD mit origin/main verglichen. Das ging schief, sobald jemand vor dem # Skript von Hand `git pull` ausfuehrte: Dann waren beide gleich, das Skript sah keinen # Grund zu bauen -- und lieferte weiter den alten Build aus, obwohl neuer Code dalag. # Massgeblich ist deshalb, aus welchem Commit der vorhandene Build stammt. local BUILT_COMMIT="" [ -f "$APP_DIR/.next/BUILD_COMMIT" ] && BUILT_COMMIT=$(cat "$APP_DIR/.next/BUILD_COMMIT") local NEEDS_BUILD=0 [ "$LOCAL" != "$REMOTE" ] && NEEDS_BUILD=1 [ ! -f "$APP_DIR/.next/standalone/server.js" ] && NEEDS_BUILD=1 [ "$BUILT_COMMIT" != "$REMOTE" ] && NEEDS_BUILD=1 [ "$FORCE" = "--force" ] && NEEDS_BUILD=1 if [ "$NEEDS_BUILD" -eq 0 ]; then log "Vorhandener Build stammt aus ${BUILT_COMMIT:0:7} und passt zum aktuellen Stand." 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 # Steht noch ein Server aus einem abgebrochenen Lauf auf dem Port, wuerde der # Smoke-Test dessen Antwort messen statt der des neuen Builds. if (ss -ltn 2>/dev/null || netstat -ltn 2>/dev/null) | grep -q ":$SMOKE_PORT "; then log "!!! Port $SMOKE_PORT ist belegt -- vermutlich ein Rest aus einem abgebrochenen Lauf." log " Bitte pruefen: ss -ltnp | grep $SMOKE_PORT" rm -rf "$BUILD_DIR" return 1 fi # exec ist hier wesentlich: Ohne das ersetzt node die Subshell NICHT, $! zeigt auf die # Subshell, und der node-Prozess ueberlebt das spaetere kill als Waise -- Port 3999 # bliebe belegt und jeder weitere Deploy scheiterte daran. ( cd "$BUILD_DIR/standalone" && exec env 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 # Nachfassen, falls der Prozess das TERM ignoriert hat. if kill -0 "$SMOKE_PID" 2>/dev/null; then sleep 2 kill -9 "$SMOKE_PID" 2>/dev/null || true fi 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 # Damit der naechste Lauf erkennt, ob der Build noch zum Code passt. git rev-parse HEAD > .next/BUILD_COMMIT # 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 "$@"