"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.
227 lines
8.4 KiB
Bash
Executable file
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"
|