Infra: split dev and production (releases, services, deploy script)
- Production now runs from ~/freezmo-prod/current (release symlink) as a standalone server on :3207; releases are exported by scripts/deploy.sh, built and e2e-verified on a temp port, switched atomically, health- checked via the new /api/healthz (commit + build time) and rolled back automatically on failure. Last 5 releases kept; scripts/rollback.sh for manual rollback. Shared env + data live in ~/freezmo-prod/shared/ and are symlinked into every release (existing sync/push data migrated and verified identical). - Dev runs from ~/freezmo via freezmo-dev.service: next dev on :3217 (LAN only), its own build folder (.next-dev via NEXT_DIST_DIR), its own data (~/freezmo/.freezmo-dev), DEV badge, dev favicon/theme colour, "Freezmo Dev" PWA identity and never the production service worker. - /api/healthz + the Settings Details version row (short commit + build time). output: standalone in next.config (next start still works). - AGENTS.md documents the workflow rules: only dev updates during a task, temporary dirs/ports for test builds, and deploy-at-the-end as the only production path. README gains an Environments and deploy section. - Fix found by the rehearsal: lib/dev-mode.ts must not be a client module (a server component importing a constant from a "use client" module gets a truthy proxy, which baked dev metadata into production builds). - SW -> freezmo-v34.
This commit is contained in:
parent
5ca0de920c
commit
3f52ab47c2
22 changed files with 665 additions and 35 deletions
216
scripts/deploy.sh
Executable file
216
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deploy Freezmo to production.
|
||||
#
|
||||
# scripts/deploy.sh # deploy the latest origin/main
|
||||
# scripts/deploy.sh <commit|tag> # deploy a specific pushed commit
|
||||
# scripts/deploy.sh --skip-tests # skip the pre-switch e2e run (fast path)
|
||||
#
|
||||
# What it does, in order:
|
||||
# 1. refuses to run unless the working tree is clean and the target commit is
|
||||
# already pushed to origin/main (production only runs pushed, verified code)
|
||||
# 2. exports the target commit into ~/freezmo-prod/releases/<timestamp>-<sha>
|
||||
# 3. npm ci, lint, production build (standalone output) inside that release
|
||||
# 4. (default) starts the new release on a temporary port and runs the full
|
||||
# browser suite against it — the running production is never touched
|
||||
# 5. links shared env + data, writes release.json, flips `current` atomically,
|
||||
# restarts the service and polls /api/healthz for the new commit
|
||||
# 6. on any failure after the switch it rolls back to the previous release
|
||||
# 7. keeps the last 5 releases
|
||||
#
|
||||
# Overridable: FREEZMO_REPO, FREEZMO_PROD, FREEZMO_SERVICE, FREEZMO_PORT,
|
||||
# FREEZMO_KEEP, FREEZMO_TEST_PORT, FREEZMO_HEALTH_TRIES (0 forces a rollback —
|
||||
# handy for testing the failure path).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${FREEZMO_REPO:-$HOME/freezmo}"
|
||||
PROD="${FREEZMO_PROD:-$HOME/freezmo-prod}"
|
||||
SERVICE="${FREEZMO_SERVICE:-freezmo}"
|
||||
PORT="${FREEZMO_PORT:-3207}"
|
||||
KEEP="${FREEZMO_KEEP:-5}"
|
||||
TEST_PORT="${FREEZMO_TEST_PORT:-3299}"
|
||||
HEALTH_TRIES="${FREEZMO_HEALTH_TRIES:-45}"
|
||||
RUN_TESTS=1
|
||||
TARGET_ARG=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-tests) RUN_TESTS=0 ;;
|
||||
--with-tests) RUN_TESTS=1 ;;
|
||||
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
|
||||
*) TARGET_ARG="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[1m[deploy]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 1. Preconditions #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
[[ -d "$REPO/.git" ]] || fail "no git repository at $REPO"
|
||||
cd "$REPO"
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
fail "working tree is not clean — commit or stash first (production only runs committed code)"
|
||||
fi
|
||||
|
||||
say "fetching origin/main…"
|
||||
git fetch origin main --quiet
|
||||
|
||||
TARGET_REF="${TARGET_ARG:-main}"
|
||||
TARGET="$(git rev-parse --verify "$TARGET_REF^{commit}")" || fail "unknown commit: $TARGET_REF"
|
||||
|
||||
if ! git merge-base --is-ancestor "$TARGET" origin/main; then
|
||||
fail "commit $TARGET is not on origin/main — push it first"
|
||||
fi
|
||||
|
||||
SHORT="$(git rev-parse --short "$TARGET")"
|
||||
RELEASE="$(date +%Y%m%d-%H%M%S)-$SHORT"
|
||||
DIR="$PROD/releases/$RELEASE"
|
||||
|
||||
say "deploying $SHORT as $RELEASE"
|
||||
mkdir -p "$PROD/releases" "$PROD/shared/freezmo/sync" "$PROD/shared/freezmo/push"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 2. Export + build the release #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
mkdir -p "$DIR/src"
|
||||
git archive "$TARGET" | tar -x -C "$DIR/src"
|
||||
say "release exported to $DIR/src"
|
||||
|
||||
cd "$DIR/src"
|
||||
say "npm ci…"
|
||||
npm ci --no-audit --no-fund >/dev/null
|
||||
|
||||
say "lint…"
|
||||
npm run lint
|
||||
|
||||
say "production build…"
|
||||
npm run build
|
||||
|
||||
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# Assemble the standalone runtime: server.js + pruned node_modules, with
|
||||
# public/ and .next/static copied next to it (Next does not copy those).
|
||||
say "assembling standalone runtime…"
|
||||
mkdir -p "$DIR/server/.next"
|
||||
cp -r "$DIR/src/.next/standalone/." "$DIR/server/"
|
||||
cp -r "$DIR/src/.next/static" "$DIR/server/.next/static"
|
||||
cp -r "$DIR/src/public" "$DIR/server/public"
|
||||
|
||||
cat > "$DIR/server/release.json" <<JSON
|
||||
{
|
||||
"commit": "$TARGET",
|
||||
"short": "$SHORT",
|
||||
"builtAt": "$BUILT_AT",
|
||||
"release": "$RELEASE"
|
||||
}
|
||||
JSON
|
||||
|
||||
ln -sfn "$PROD/shared/env.local" "$DIR/server/.env.local"
|
||||
ln -sfn "$PROD/shared/freezmo" "$DIR/server/.freezmo"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3. Verify the new release on a temporary port #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if [[ "$RUN_TESTS" == "1" ]]; then
|
||||
say "starting the new release on :$TEST_PORT for the e2e run…"
|
||||
# The standalone server does not read .env files itself, so hand it the
|
||||
# shared production env exactly like the systemd unit does.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$PROD/shared/env.local"
|
||||
set +a
|
||||
( cd "$DIR/server" && PORT="$TEST_PORT" HOSTNAME=127.0.0.1 node server.js >"$DIR/test-server.log" 2>&1 ) &
|
||||
TEST_PID=$!
|
||||
cleanup_test() { kill "$TEST_PID" >/dev/null 2>&1 || true; wait "$TEST_PID" 2>/dev/null || true; }
|
||||
trap cleanup_test EXIT
|
||||
|
||||
ready=0
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS --max-time 2 "http://127.0.0.1:$TEST_PORT/api/healthz" >/dev/null 2>&1; then ready=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$ready" == "1" ]] || { cat "$DIR/test-server.log" >&2; fail "the new release did not start on :$TEST_PORT"; }
|
||||
|
||||
say "running the browser suites against the new release…"
|
||||
# Spawned test instances run from the release checkout (hermetic), while the
|
||||
# suites themselves talk to the release over HTTP.
|
||||
( cd "$REPO" && FREEZMO_URL="http://127.0.0.1:$TEST_PORT" FREEZMO_SPAWN_CWD="$DIR/src" npm run test:e2e )
|
||||
|
||||
cleanup_test
|
||||
trap - EXIT
|
||||
say "suites passed"
|
||||
else
|
||||
say "skipping the e2e run (--skip-tests)"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 4. Switch, restart, health-check (roll back on failure) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
PREVIOUS="$(readlink -f "$PROD/current" 2>/dev/null || true)"
|
||||
|
||||
health_ok() {
|
||||
local expected="$1" body commit
|
||||
for ((i = 0; i < HEALTH_TRIES; i++)); do
|
||||
body="$(curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/healthz" 2>/dev/null || true)"
|
||||
if [[ -n "$body" ]]; then
|
||||
commit="$(printf '%s' "$body" | grep -o '"commit":"[^"]*"' | cut -d'"' -f4 || true)"
|
||||
# Compare short prefixes: the target may be a full sha while the release
|
||||
# directory (and rollbacks) only carry the short one.
|
||||
if [[ -n "$commit" && "${commit:0:7}" == "${expected:0:7}" ]]; then return 0; fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
rollback() {
|
||||
if [[ -n "$PREVIOUS" && -d "$PREVIOUS" ]]; then
|
||||
say "rolling back to $(basename "$PREVIOUS")…"
|
||||
ln -sfn "$PREVIOUS" "$PROD/current.tmp" && mv -T "$PROD/current.tmp" "$PROD/current"
|
||||
systemctl --user restart "$SERVICE" || true
|
||||
if health_ok "$(basename "$PREVIOUS" | sed 's/.*-//')"; then
|
||||
say "rollback healthy — production is back on $(basename "$PREVIOUS")"
|
||||
else
|
||||
say "rollback restarted, but the health check is still failing — check journalctl --user -u $SERVICE"
|
||||
fi
|
||||
else
|
||||
say "no previous release to roll back to"
|
||||
fi
|
||||
fail "deploy failed — $RELEASE is not live"
|
||||
}
|
||||
|
||||
say "switching current -> $RELEASE…"
|
||||
ln -sfn "$DIR" "$PROD/current.tmp"
|
||||
mv -T "$PROD/current.tmp" "$PROD/current"
|
||||
|
||||
say "restarting $SERVICE…"
|
||||
systemctl --user restart "$SERVICE"
|
||||
|
||||
if ! health_ok "$TARGET"; then
|
||||
rollback
|
||||
fi
|
||||
say "health check passed: $SHORT is live on :$PORT"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 5. Prune old releases (keep the last $KEEP, never the live one) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
LIVE="$(readlink -f "$PROD/current")"
|
||||
mapfile -t OLD < <(ls -1dt "$PROD"/releases/*/ 2>/dev/null | tail -n +$((KEEP + 1)))
|
||||
for old_dir in "${OLD[@]:-}"; do
|
||||
[[ -n "$old_dir" ]] || continue
|
||||
[[ "$(readlink -f "$old_dir")" == "$LIVE" ]] && continue
|
||||
say "pruning $(basename "$old_dir")"
|
||||
rm -rf "$old_dir"
|
||||
done
|
||||
|
||||
say "done — $RELEASE is live (commit $SHORT, built $BUILT_AT)"
|
||||
say "production: http://127.0.0.1:$PORT · logs: journalctl --user -u $SERVICE -f"
|
||||
Loading…
Add table
Add a link
Reference in a new issue