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