From 2ce1fc073db5a72f7f0107ee3018060700747ed2 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 28 Oct 2026 16:30:36 -0700 Subject: [PATCH] Update host dir and release process --- configs/nginx-nixos.nix | 23 ++++++-- deploy.sh | 117 ++++++++++++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 21 deletions(-) diff --git a/configs/nginx-nixos.nix b/configs/nginx-nixos.nix index 77fcbc5..132c6cb 100644 --- a/configs/nginx-nixos.nix +++ b/configs/nginx-nixos.nix @@ -17,6 +17,13 @@ # /assets/, /sw.js and /manifest.json unresolvable once they needed their # own location blocks. # +# It also no longer points into the repo. deploy.sh publishes the built +# client to /var/www/ss/releases/ and atomically repoints the +# /var/www/ss/current symlink at it. Pointing nginx at that stable path +# means a deploy is one rename -- nginx resolves the symlink per request, +# so no reload is needed and no request sees a half-copied directory. +# Rollback is the same rename pointing at an older release. +# # 3. proxyWebsockets on /socket. recommendedProxySettings does NOT add the # Upgrade/Connection headers, so Socket.IO has been silently falling back # to long-polling -- that's the live note-sync path running degraded. @@ -45,7 +52,8 @@ services.nginx = { virtualHosts."192.168.1.7" = { # serverAliases = [ "notes.example.com" ]; # if the tunnel forwards the public host - root = "REPO_PATH/client/dist"; + # Published by deploy.sh; a symlink to the live release. Not in the repo. + root = "/var/www/ss/current"; locations."/" = { tryFiles = "$uri $uri/ /index.html"; @@ -84,7 +92,12 @@ services.nginx = { }; }; -# Note on file access: nginx runs as its own user, so REPO_PATH/client/dist must -# be readable by it -- every parent directory needs +x. If the repo lives under -# /home/mab this usually means loosening the home directory's permissions, or -# putting the clone somewhere like /srv instead. +# Note on file access: nginx runs as its own user and needs read access to the +# web root, with +x on every parent directory. Serving from /var/www/ss is what +# makes that a non-issue -- the one-time `chmod 755 /var/www /var/www/ss` in +# deploy.sh is the whole of it. This is the reason the root is not the repo +# clone: a clone under /home/mab would require loosening the permissions on the +# home directory itself. +# +# Uploads are NOT here. Express serves them from REPO_PATH/staticFiles at +# /api/static via the proxy below, so they are untouched by deploys. diff --git a/deploy.sh b/deploy.sh index ab3b756..6feda02 100755 --- a/deploy.sh +++ b/deploy.sh @@ -13,24 +13,72 @@ # environment.systemPackages or the user's profile -- NOT only inside a # nix-shell, or this will fail at the install step) # - pm2 is already managing NoteServer (pm2 resurrect on boot) -# - nginx serves client/dist with an SPA fallback to index.html +# - nginx serves $WEBROOT/current with an SPA fallback to index.html # # nginx is NOT touched here. On NixOS it is declarative -- see configs/. # +# --- the web root -------------------------------------------------------- +# +# The built client is published to /var/www/ss, NOT served out of the repo. +# nginx runs as its own user, so serving from a clone under /home/mab meant +# loosening the home directory's permissions for every parent dir. /var/www is +# the conventional place for this and sidesteps the whole problem. +# +# Layout, which is the standard symlink-swap pattern: +# +# /var/www/ss/ +# releases/2026-10-28_1147/ <- one directory per deploy +# releases/2026-10-28_0930/ +# current -> releases/2026-10-28_1147 +# +# nginx points at the stable path $WEBROOT/current. Publishing is a rename of +# that symlink, which is a single atomic syscall: no request ever sees a +# half-copied directory, and no window exists where the root is missing. +# Rolling back is the same rename pointing at the previous release. +# +# Only the *client* moves. The API server still runs from the repo under pm2, +# and user uploads are still served by Express from $REPO/staticFiles at +# /api/static -- deliberately, so deploys never touch user data. +# +# ONE-TIME SETUP on a new server (needs root): +# +# sudo mkdir -p /var/www/ss/releases +# sudo chown -R "$USER" /var/www/ss +# sudo chmod 755 /var/www /var/www/ss +# +# Override the location for a staging box with: WEBROOT=/var/www/ss-staging ./deploy.sh +# set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$REPO" +WEBROOT="${WEBROOT:-/var/www/ss}" +RELEASE="$WEBROOT/releases/$(date +%Y-%m-%d_%H%M%S)" +# Number of old releases to keep around for rollback. +KEEP_RELEASES=5 + step(){ printf '\n\033[32m::--:: %s\033[0m\n' "$1"; } # --- sanity checks before changing anything ------------------------------ -for tool in git pnpm pm2; do +for tool in git pnpm pm2 rsync; do command -v "$tool" >/dev/null || { echo "$tool is not on PATH" >&2; exit 1; } done +# Fail here rather than after a three-minute build. A missing web root is +# almost always a server that never had the one-time setup above run on it. +if [ ! -d "$WEBROOT/releases" ]; then + echo "$WEBROOT/releases does not exist -- run the one-time setup at the top of this script." >&2 + exit 1 +fi + +if [ ! -w "$WEBROOT/releases" ]; then + echo "$WEBROOT/releases is not writable by $(whoami) -- check the chown in the one-time setup." >&2 + exit 1 +fi + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then echo "Working tree has local modifications. Commit or stash them first:" >&2 git status --short >&2 @@ -54,23 +102,54 @@ pnpm install --frozen-lockfile --dir client # --- build --------------------------------------------------------------- -# dist is gitignored, so the server builds it. Built into a temp dir first so a -# failed build leaves the currently-served files untouched. +# dist is gitignored, so the server builds it. The build stays inside the repo +# and is only copied to $WEBROOT once it has been checked, so a failed build +# cannot touch what is currently being served. step "Building client..." -rm -rf client/dist.new -pnpm --dir client exec vite build --outDir dist.new --emptyOutDir +rm -rf client/dist +pnpm --dir client exec vite build -if [ ! -f client/dist.new/index.html ] || [ ! -f client/dist.new/sw.js ]; then - echo "Build output looks wrong (missing index.html or sw.js) -- keeping the previous build." >&2 - rm -rf client/dist.new - exit 1 -fi +# manifest.json is checked in under client/public and copied verbatim by Vite; +# if it is missing, the PWA installs without icons, scope or a share target. +for required in index.html sw.js manifest.json; do + if [ ! -f "client/dist/$required" ]; then + echo "Build output looks wrong (missing $required) -- $WEBROOT left untouched." >&2 + exit 1 + fi +done -step "Swapping in the new build..." -rm -rf client/dist.old -[ -d client/dist ] && mv client/dist client/dist.old -mv client/dist.new client/dist -rm -rf client/dist.old +# --- publish ------------------------------------------------------------- + +step "Publishing to $RELEASE..." +mkdir -p "$RELEASE" +# Trailing slash on the source: copy the *contents* of dist into the release +# dir. --delete is harmless on a fresh dir but keeps a re-run of the same +# timestamp honest. +rsync -a --delete client/dist/ "$RELEASE/" + +# nginx reads these as its own user, so they must be world-readable. +chmod -R a+rX "$RELEASE" + +step "Pointing $WEBROOT/current at the new release..." +# Two steps because `ln -sfn` over an existing symlink unlinks before relinking, +# which leaves a brief window with no web root at all. Creating a scratch +# symlink and renaming it over the old one is a single atomic rename(2). +ln -sfn "$RELEASE" "$WEBROOT/current.new" +mv -T "$WEBROOT/current.new" "$WEBROOT/current" + +# --- prune --------------------------------------------------------------- + +# Keep the most recent $KEEP_RELEASES for rollback, drop the rest. Never remove +# whatever `current` actually resolves to, however old it is. +live="$(readlink -f "$WEBROOT/current")" +ls -1d "$WEBROOT"/releases/*/ 2>/dev/null \ + | sort -r \ + | tail -n +$((KEEP_RELEASES + 1)) \ + | while read -r stale; do + [ "$(readlink -f "$stale")" = "$live" ] && continue + echo " pruning $(basename "$stale")" + rm -rf "$stale" + done # --- restart ------------------------------------------------------------- @@ -83,4 +162,10 @@ pm2 reload "$REPO/server/ecosystem.config.js" --env production --update-env pm2 save step "Done." +echo " serving $(readlink -f "$WEBROOT/current")" +echo +echo " To roll back, repoint the symlink at the previous release:" +echo " ls $WEBROOT/releases" +echo " ln -sfn $WEBROOT/releases/ $WEBROOT/current.new && mv -T $WEBROOT/current.new $WEBROOT/current" +echo pm2 list