freezmo/scripts/deploy.sh
Martin Dvorak 37a84df462 AI disclaimer: one shared component, shown where model output appears
"Freezmo uses AI and can make mistakes. Double-check important info."
(plus a natural informal Czech translation), rendered by a shared
AiDisclaimer so the copy, style and translations live in one place.

Placements — only where the model's words reach the screen:
- Chat: under the composer, below the context/model chips — always (chat
  is pure model output, so it also shows in the empty state).
- Make my homework: under the start CTA (desktop) and the mode cards'
  helper line (phone), plus once under the generated result for all three
  modes (make / check / guide).
- New deck wizard: under the summary helper (phone step 3) and the desktop
  CTA, next to Freeze my deck.
- AI grading (short answers): under the graded feedback; study, review and
  exam all share it.

Stored, already-generated content (deck page, study cards, explanations)
deliberately carries no note: the disclaimer belongs to the moment of
generation, not to browsing. Style: --text-subtle (documented at 5.6:1
light / 8.6:1 dark), 11.5px, centred, wraps to two lines at 360px, not
clickable, no icon.

Suites: chat (composer, phone + desktop), wizard (deck step 3 across
360/390 x en/cs x dark/light, homework start cards), phase3 (homework
trigger + result, phone + desktop), phase2 (stubbed grading feedback).
SW -> freezmo-v37. README: decision 50 + chat composer note.

Also fixes the deploy script's test instance: it now execs the server so
the cleanup kill reaps the real PID (the wrapper subshell left orphans
squatting on the test port — three from earlier sessions were found while
verifying this task), and the readiness poll checks the port serves the
NEW commit rather than just answering, so a leftover instance can never
be tested silently.
2026-10-11 22:51:16 +00:00

227 lines
8.4 KiB
Bash
Executable file

#!/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
# exec so $! is the server itself, not a wrapper subshell — otherwise the
# cleanup kill only reaps the subshell and the server survives as an orphan
# squatting on the test port.
( cd "$DIR/server" && exec env 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 means the port answers with THIS release's commit: a leftover
# instance from an earlier run would otherwise be tested silently.
ready=0
for _ in $(seq 1 60); do
body="$(curl -fsS --max-time 2 "http://127.0.0.1:$TEST_PORT/api/healthz" 2>/dev/null || true)"
if [[ -n "$body" ]]; then
commit="$(printf '%s' "$body" | grep -o '"commit":"[^"]*"' | cut -d'"' -f4 || true)"
if [[ -n "$commit" && "${commit:0:7}" == "${TARGET:0:7}" ]]; then ready=1; break; fi
cat "$DIR/test-server.log" >&2 || true
fail "port $TEST_PORT answers with ${commit:0:7}, not $SHORT — is another instance still running?"
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"