freezmo/scripts/deploy.sh
Martin Dvorak 9cea13496a Report question: skip a broken card and tell the server
A generated matching card arrived on a phone with three identical
definitions (the generation guards now drop those), but there was no way
to get past a bad card from inside a session. The feed chrome (deck
study, review, mistakes and exam) now has the red "!" control: one tap
records the question, skips to the next card and shows an Undo toast.

- lib/question-reports.ts: the local list (freezmo.reports.v1, zod on
  read, capped 200) is the skip state — reported cards leave every
  session type, count as "not attempted" in the results instead of
  wrong, and Undo takes a report back. A deck where everything is
  reported stays studiable so it can be regenerated.
- POST /api/reports appends one JSON line per report to
  REPORTS_DIR/reports.jsonl (shared/freezmo/reports in production, the
  dev data dir in dev). Deliberately write-only: the question text is
  readable on the server (that is the point — prompt tuning) but never
  over HTTP, since the app has no auth. Best effort: offline reports stay
  local.
- The button: a red outlined rounded square with "!", the chrome's
  control size, in both feed headers (hidden on the results slide).
- config: REPORTS_DIR + RATE_LIMIT_REPORTS (20); the deploy creates the
  shared reports dir; the dev unit and AGENTS.md note the new server
  data location.
- Suites: phase-1 (append + validation + no GET), phase-2 (button, toast,
  server POST, local copy, skip counter, hidden next session, Undo, exam
  pool). SW -> freezmo-v38.
2026-10-12 07:12:55 +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" "$PROD/shared/freezmo/reports"
# ------------------------------------------------------------------ #
# 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"