#!/usr/bin/env bash # # Deploy Freezmo to production. # # scripts/deploy.sh # deploy the latest origin/main # scripts/deploy.sh # 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/- # 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" <"$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"