From 6f4fe219dc4788fdcfbe045f693437b6ad0732b0 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Thu, 3 Sep 2026 13:46:18 +0200 Subject: [PATCH] fix: make update-app.sh the single update path and run it at boot There were two near-identical update scripts and they drifted: the npm ci fix from 1.114.1 only ever landed in update-app.sh, so the boot path kept running npm install. my-weekly-todo-update.service now runs update-app.sh, and boot-update.sh is a shim kept only so a not-yet-recopied unit still works. To be usable at boot the script needed three things it did not have: - Skip the rebuild when HEAD already matches origin/main and a standalone build exists, so a reboot is not a full rebuild every time (--force overrides). - Do not restart PM2 when its daemon is not up yet. At boot this unit runs before pm2-root.service, and probing with `pm2 describe` would spawn a stray daemon that races the real service, so it checks the pid file. - A flock single-instance lock, so a boot run and a manual run cannot install into the same tree at once. It also picks up prisma migrate deploy / generate and the .env copy that only the boot script had. The log moved to /var/log/my-weekly-todo-update.log. v1.115.2 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 28 +++++++ package-lock.json | 4 +- package.json | 2 +- scripts/boot-update.sh | 94 ++-------------------- scripts/my-weekly-todo-update.service | 4 +- scripts/update-app.sh | 111 +++++++++++++++++++++----- 6 files changed, 131 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 788a48c..fdafee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to this project are documented in this file. +## [1.115.2] - 2026-09-03 + +### Changed +- `scripts/update-app.sh` is now the single update path, used both for a manual deploy and + for the boot run; `my-weekly-todo-update.service` points at it. There were two + near-identical scripts and they drifted — the `npm ci` fix from 1.114.1 only ever landed + in update-app.sh, so the boot path kept running `npm install`. `scripts/boot-update.sh` + is now a shim that calls it, kept only so a not-yet-recopied unit file still works. +- The update log moved from `/var/log/app-boot-update.log` to + `/var/log/my-weekly-todo-update.log`. + +### Added +- `update-app.sh` skips the rebuild when `HEAD` already matches `origin/main` and + `.next/standalone/server.js` exists, so a reboot no longer costs a full rebuild. Pass + `--force` to rebuild anyway. +- `update-app.sh` takes a `flock` single-instance lock, so a boot run and a manual run can + no longer install into the same tree at once — which is how node_modules ended up in a + state npm could not clean up. +- `update-app.sh` runs `prisma migrate deploy` and `prisma generate` (previously only the + boot script did), and copies `.env` into the standalone output. + +### Fixed +- `update-app.sh` no longer fails at boot trying to restart a PM2 process that does not + exist yet. It restarts only when the PM2 daemon is actually running, and otherwise leaves + the start to `pm2-root.service`, which is ordered after this unit. It deliberately does + not probe with `pm2 describe`, since any pm2 command spawns a daemon that would race the + real service. + ## [1.115.1] - 2026-09-03 ### Fixed diff --git a/package-lock.json b/package-lock.json index 22964bc..6ccc354 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.115.1", + "version": "1.115.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.115.1", + "version": "1.115.2", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index 18a42bc..74f8aa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.115.1", + "version": "1.115.2", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/scripts/boot-update.sh b/scripts/boot-update.sh index 91a54cc..29b7aaf 100755 --- a/scripts/boot-update.sh +++ b/scripts/boot-update.sh @@ -1,92 +1,8 @@ #!/bin/bash # -# Boot Update Script: pull latest main, install, build, and prepare for PM2. -# Runs on LXC container boot BEFORE PM2 starts (my-weekly-todo-update.service). -# -# 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. +# Deprecated: kept only so a stale /etc/systemd/system/my-weekly-todo-update.service that +# still points here keeps working. update-app.sh is the single update path — it handles both +# the manual deploy and the boot run. Once the unit has been recopied from +# scripts/my-weekly-todo-update.service, this file can be deleted. -main() { - # pipefail matters here: every command used to be piped into `tee`, so the shell saw - # tee's exit status (always 0). A failed npm install or build did not stop the script - # and did not fail the unit — it just logged "finished" and left a half-built tree. - set -euo pipefail - - local APP_DIR="/root/My-Weekly-ToDo-List" - local LOG_FILE="/var/log/app-boot-update.log" - - # Send everything to the log once, instead of piping each command through tee. - exec > >(tee -a "$LOG_FILE") 2>&1 - - log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; } - - trap 'log "!!! Boot update FAILED at line $LINENO (exit $?) — not restarting the app"' 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 - - log "=== Boot update started ===" - - cd "$APP_DIR" - - log "Ensuring main branch..." - git checkout main - - log "Fetching from origin..." - git fetch origin main - - local LOCAL REMOTE NEXT_DIR - LOCAL=$(git rev-parse HEAD) - REMOTE=$(git rev-parse origin/main) - NEXT_DIR="$APP_DIR/.next" - - if [ "$LOCAL" = "$REMOTE" ] && [ -d "$NEXT_DIR" ]; then - log "Already up-to-date and .next exists. No rebuild needed." - log "=== Boot update finished (no changes) ===" - return 0 - fi - - if [ "$LOCAL" != "$REMOTE" ]; then - log "Changes detected. Updating from $LOCAL to $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 also clears node_modules - # itself; if that ever fails with ENOTEMPTY, delete the directory by hand and rerun. - log "Installing dependencies..." - npm ci - fi - - if [ ! -d "$NEXT_DIR" ] || [ "$LOCAL" != "$REMOTE" ]; then - 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 - - log "Building application..." - npm run build - - # next.config.js sets output: 'standalone', and Next.js does NOT copy public/ or - # .next/static/ into .next/standalone — that is a documented manual step. Skipping - # it ships new server code whose HTML references new chunk hashes while the old - # chunks sit in the standalone tree: they 404, React never hydrates, and every page - # hangs on "Loading session..." while the API still answers 200. - log "Preparing standalone directory (static assets and env)..." - cp -r public .next/standalone/ - mkdir -p .next/standalone/.next/static - cp -r .next/static .next/standalone/.next/ - cp .env .next/standalone/ 2>/dev/null || true - fi - - log "Deployed version: $(node -p "require('$APP_DIR/package.json').version")" - log "=== Boot update finished (verified/rebuilt) ===" -} - -main "$@" +exec /bin/bash /root/My-Weekly-ToDo-List/scripts/update-app.sh "$@" diff --git a/scripts/my-weekly-todo-update.service b/scripts/my-weekly-todo-update.service index 19dfcb0..c7ed00a 100644 --- a/scripts/my-weekly-todo-update.service +++ b/scripts/my-weekly-todo-update.service @@ -1,12 +1,14 @@ [Unit] Description=My Weekly ToDo List - Boot Update (git pull + build) +# Runs the same script as a manual deploy; it skips the rebuild when nothing changed and +# leaves starting the app to pm2-root.service, which is ordered after this unit. After=network-online.target Wants=network-online.target Before=pm2-root.service [Service] Type=oneshot -ExecStart=/bin/bash /root/My-Weekly-ToDo-List/scripts/boot-update.sh +ExecStart=/bin/bash /root/My-Weekly-ToDo-List/scripts/update-app.sh WorkingDirectory=/root/My-Weekly-ToDo-List Environment=HOME=/root Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/scripts/update-app.sh b/scripts/update-app.sh index 82cc4bd..6ce614f 100644 --- a/scripts/update-app.sh +++ b/scripts/update-app.sh @@ -6,34 +6,96 @@ # 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. # -# Usage on the host: /root/My-Weekly-ToDo-List/scripts/update-app.sh +# 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 - # Fail fast instead of hanging on a credential prompt when run without a terminal - # (e.g. from systemd). The host authenticates via a read-only SSH deploy key. - export GIT_TERMINAL_PROMPT=0 - 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"; } + + trap 'log "!!! Update FAILED at line $LINENO (exit $?) — the running build is untouched"' 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" - echo "--- Pulling latest changes from git ---" - git pull origin main + log "=== Update started ===" - # 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. - echo "--- Installing dependencies ---" - npm ci + log "--- Ensuring main branch ---" + git checkout main - echo "--- Building application ---" + 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 + + log "--- Building application ---" npm run build # next.config.js sets output: 'standalone', and Next.js does NOT copy public/ or @@ -41,16 +103,27 @@ main() { # ships new server code whose HTML references new chunk hashes while the old chunks sit # in the standalone tree: they 404, React never hydrates, and every page hangs on its # server-rendered shell ("Loading session...") while the API still answers 200. - echo "--- Copying static assets into standalone output ---" + log "--- Copying static assets into standalone output ---" cp -r public .next/standalone/ + mkdir -p .next/standalone/.next/static cp -r .next/static .next/standalone/.next/ + cp .env .next/standalone/ 2>/dev/null || true - echo "--- Restarting PM2 process ---" - pm2 restart "$PM2_APP" --update-env - pm2 save + # 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 - echo "--- Update Complete ---" - echo "Deployed version: $(node -p "require('$APP_DIR/package.json').version")" + log "=== Update Complete ===" + log "Deployed version: $(node -p "require('$APP_DIR/package.json').version")" } main "$@"