From 00c2ad15a7dd6af1470786aebf8568b0ff24c68b Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 24 Sep 2026 13:57:31 -0400 Subject: [PATCH] Deploy the web app from a push to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now a server change reached the NAS only when someone ran ./release.sh --web from a machine on the church network. That made the deploy depend on which laptop was awake and where it was, which is the same fragility that let the nightly Drive backup go stale unnoticed. An act_runner now runs in Docker on the NAS itself and picks up this workflow, so a push to main rebuilds the container regardless of where the push came from. The workflow keeps the protections the old remote deploy script had, because they are the ones that matter: server/data and server/.env are excluded from the copy and then explicitly checked for afterwards, and the container is recreated with 'up -d --build' rather than 'down' first, so an interrupted deploy leaves the old container serving instead of tearing the stack down. rsync runs without --delete, matching the tarball deploy it replaces — a stale file is survivable, a wiped data/ is not. This covers the web app only. The iOS and Android apps bundle a copy of the UI at build time, so they still need ./release.sh and a store release; server-side changes reach them immediately, since they call this same server live. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 2 +- .gitea/workflows/deploy.yml | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy.yml diff --git a/.claude/launch.json b/.claude/launch.json index 77ff59a..1763e34 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,7 +4,7 @@ { "name": "checklist", "runtimeExecutable": "bash", - "runtimeArgs": ["-lc", "cd /Users/christian/Documents/checklist-transfer/recurring-checklist-app/server && ADMIN_PASSWORD=changeme REPORT_EMAIL='' PORT=4998 node server.js"], + "runtimeArgs": ["-lc", "cd /Users/christian/Documents/recurring-checklist-app/server && ADMIN_PASSWORD=changeme REPORT_EMAIL='' PORT=4998 node server.js"], "port": 4998 } ] diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..c1a24e4 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,87 @@ +# Push to main -> the NAS rebuilds itself. +# +# This replaces having to run ./release.sh --web by hand for a server change. It does the +# same two things that script's remote half did, in the same order and with the same +# protections: copy server/ over the live directory, then recreate the container in place. +# +# It runs on the "nas" runner (act_runner, in Docker on the NAS itself), so it does not +# care whether the laptop is awake, on the church network, or in the building. +# +# NOT covered here on purpose: the iOS and Android apps. They bundle a copy of the web UI +# at build time (see mobile/scripts/sync-web.js), so a UI change only reaches phones via a +# new store release. Server-side changes reach them immediately, because they call this +# same server live. + +name: Deploy web to NAS + +on: + push: + branches: [main] + paths: + - 'server/**' + - '.gitea/workflows/deploy.yml' + workflow_dispatch: + +jobs: + deploy: + runs-on: nas + container: + image: node:20-alpine + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Mounted at the SAME path it has on the host. docker-compose.yml binds ./data + # relative to the compose file, and the daemon resolves that as a HOST path — so if + # this were mounted anywhere else the app would come up pointed at a directory that + # does not exist, with an empty data volume. + - /volume2/docker/recurring-checklist:/volume2/docker/recurring-checklist + + steps: + - name: Install tools + run: apk add --no-cache docker-cli docker-cli-compose rsync + + - name: Checkout + uses: actions/checkout@v4 + + - name: Copy server files over the live directory + run: | + set -eu + DEST=/volume2/docker/recurring-checklist + test -f "$DEST/docker-compose.yml" || { echo "REFUSING: $DEST is not the deploy directory"; exit 1; } + + # data/ holds the live checklists, uploads and backups; .env holds the admin and + # Gmail credentials. Neither is in git, and neither may ever be written here. + # No --delete: this mirrors the old tarball deploy, which only ever added and + # overwrote. A stale file left behind is survivable; a wiped data/ is not. + rsync -a \ + --exclude 'data/' \ + --exclude 'data-smoke/' \ + --exclude '.env' \ + --exclude 'node_modules/' \ + server/ "$DEST/" + + - name: Verify the live data and credentials survived + run: | + set -eu + DEST=/volume2/docker/recurring-checklist + test -f "$DEST/.env" || { echo "ABORT: .env is gone"; exit 1; } + test -d "$DEST/data" || { echo "ABORT: data/ is gone"; exit 1; } + echo ".env and data/ intact" + + - name: Rebuild the container in place + # No 'down' first: that removes the container AND the network, so an interrupted + # deploy would leave the whole stack torn down. 'up -d --build' recreates only the + # app container, and the worst case on failure is the old one still serving. + run: docker compose -f /volume2/docker/recurring-checklist/docker-compose.yml up -d --build --remove-orphans + + - name: Healthcheck + run: | + set -eu + for i in $(seq 1 20); do + S=$(docker inspect --format '{{.State.Health.Status}}' recurring-checklist 2>/dev/null || echo starting) + [ "$S" = "healthy" ] && { echo "container healthy"; exit 0; } + sleep 3 + done + echo "container did not report healthy in time:" + docker ps --filter name=recurring-checklist --format '{{.Names}} {{.Status}}' + docker logs --tail 30 recurring-checklist + exit 1