Phase 4 slice 2: two-service release deploy (backend + frontend) on the v0.3 line #33

Merged
jonathan merged 4 commits from po/phase4-deploy-two-service into milestone/v0.3-hosted 2026-10-31 08:28:27 +00:00
+82 -19
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# ResearchX release deploy (ciagent release system)
#
# Builds the frontend at a given version tag on the Docker host and
# runs it as a container. Idempotent: re-deploying a version replaces
# the running container, keeping the previous one as rollback.
# Builds the frontend and the backend at a given version tag on the
# Docker host and runs them as two containers. Idempotent: re-deploying
# a version replaces the running containers, keeping the previous ones
# as rollback.
#
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
#
@@ -21,8 +22,14 @@ IMAGE_NAME="researchx"
CONTAINER_NAME="researchx"
CONTAINER_PREV="researchx-prev"
HOST_PORT="3000"
IMAGE_BACKEND="researchx-backend"
CONTAINER_BACKEND="researchx-backend"
CONTAINER_BACKEND_PREV="researchx-backend-prev"
HOST_PORT_BACKEND="8000"
BACKEND_DATA_DIR="/var/lib/researchx"
REMOTE_BASE="/opt/researchx"
HEALTH_PATH="/ask"
BACKEND_HEALTH_PATH="/healthz"
HEALTH_RETRIES=15
HEALTH_INTERVAL=2
# -----------------------------------------------------------------------
@@ -38,6 +45,7 @@ VERSION="$1"
# ---- dependency checks --------------------------------------------------
command -v ssh >/dev/null || die "ssh not found" 2
command -v uv >/dev/null || die "uv not found (backend gates need it: pip install uv)" 2
# ---- secrets (env file ONLY) -------------------------------------------
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
@@ -156,37 +164,72 @@ if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
WORKTREE=$(mktemp -d)
git -C "$REPO_ROOT" worktree add --detach "$WORKTREE" "$TAG_COMMIT" >/dev/null
trap 'git -C "$REPO_ROOT" worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
BUILD_DIR="$WORKTREE/frontend"
BUILD_ROOT="$WORKTREE"
else
BUILD_DIR="$REPO_ROOT/frontend"
BUILD_ROOT="$REPO_ROOT"
fi
BUILD_DIR="$BUILD_ROOT/frontend"
BACKEND_DIR="$BUILD_ROOT/backend"
(cd "$BUILD_DIR" && pnpm install --frozen-lockfile >/dev/null 2>&1)
(cd "$BUILD_DIR" && pnpm typecheck >/dev/null) || die "typecheck failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm lint >/dev/null) || die "lint failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm test >/dev/null 2>&1) || die "tests failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm build >/dev/null 2>&1) || die "build failed at $VERSION" 3
log "gates passed: typecheck, lint, test, build"
log "frontend gates passed: typecheck, lint, test, build"
cd "$REPO_ROOT"
# Backend gates: the repo's own commands, same as the CI backend job
# (.gitea/workflows/ci.yml), run from the committed lockfile.
(cd "$BACKEND_DIR" && uv sync --frozen >/dev/null 2>&1) || die "backend dependency sync failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run ruff check src tests >/dev/null) || die "backend ruff failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run mypy src >/dev/null) || die "backend mypy failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run pytest >/dev/null) || die "backend pytest failed at $VERSION" 3
log "backend gates passed: ruff, mypy, pytest"
# ---- remote host prep ----------------------------------------------------
log "connecting to $DEPLOY_USER@${DEPLOY_HOST%%:*} and preparing $REMOTE_BASE"
SSH "mkdir -p '$REMOTE_BASE/releases/$BASE_VERSION'" || die "cannot reach host or create remote dir" 4
SSH "command -v docker >/dev/null" || die "docker not found on host" 4
# ---- sync source ---------------------------------------------------------
log "syncing source to host ($REMOTE_BASE/releases/$BASE_VERSION) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$REMOTE_BASE/releases/$BASE_VERSION" \
|| die "source sync failed" 4
RELEASE_DIR="$REMOTE_BASE/releases/$BASE_VERSION"
log "syncing source to host ($RELEASE_DIR) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$RELEASE_DIR/frontend" || die "frontend source sync failed" 4
sync_source "$BACKEND_DIR" "$RELEASE_DIR/backend" || die "backend source sync failed" 4
# ---- remote build --------------------------------------------------------
log "building docker image $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$REMOTE_BASE/releases/$BASE_VERSION' && \
log "building docker images $IMAGE_BACKEND:$VERSION and $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$RELEASE_DIR/backend' && \
docker build -t '$IMAGE_BACKEND:$VERSION' -t '$IMAGE_BACKEND:latest' ." \
|| die "backend docker build failed on host" 4
SSH "cd '$RELEASE_DIR/frontend' && \
docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|| die "docker build failed on host" 4
|| die "frontend docker build failed on host" 4
# ---- swap container ------------------------------------------------------
log "swapping container (previous kept as $CONTAINER_PREV for rollback)"
log "swapping backend container (previous kept as $CONTAINER_BACKEND_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_BACKEND_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_BACKEND'; then
docker rename '$CONTAINER_BACKEND' '$CONTAINER_BACKEND_PREV'
docker stop '$CONTAINER_BACKEND_PREV' >/dev/null 2>&1 || true
fi
mkdir -p '$BACKEND_DATA_DIR'
# The container runs as the image's non-root user and a bind mount
# SHADOWS the image's own (correctly owned) /var/lib/researchx, so a
# root-owned host dir makes every write to the data root (users.json,
# sessions.json, keys/) fail with EACCES while /healthz still answers
# 200. Take the owner from the image itself.
chown -R \$(docker run --rm --entrypoint stat '$IMAGE_BACKEND:$VERSION' -c '%u:%g' /var/lib/researchx) '$BACKEND_DATA_DIR'
docker run -d --name '$CONTAINER_BACKEND' --restart unless-stopped \
-p '$HOST_PORT_BACKEND:8000' \
-v '$BACKEND_DATA_DIR:$BACKEND_DATA_DIR' \
-e RESEARCHX_DATA_DIR='$BACKEND_DATA_DIR' \
'$IMAGE_BACKEND:$VERSION'
" || die "backend docker run failed" 5
log "swapping frontend container (previous kept as $CONTAINER_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_NAME'; then
@@ -195,19 +238,39 @@ SSH "
fi
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
" || die "docker run failed" 5
" || die "frontend docker run failed" 5
# ---- health check ---------------------------------------------------------
log "health-checking http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
log "health-checking backend http://$DEPLOY_HOST:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH'"; then
log "backend healthy after $((i + 1)) check(s)"
break
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
[ "$i" -lt "$HEALTH_RETRIES" ] || die "backend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
# The health check above is a GET: it proves the app answers, not that it
# can persist. Probe the write path the auth/key stores use, so a
# mis-owned data root fails the deploy instead of the first signup.
log "checking the backend can write its data root"
SSH "docker exec '$CONTAINER_BACKEND' sh -c 'touch \"\$RESEARCHX_DATA_DIR/.write-probe\" && rm -f \"\$RESEARCHX_DATA_DIR/.write-probe\"'" \
|| die "backend data root is not writable by the container user" 5
log "health-checking frontend http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT$HEALTH_PATH'"; then
log "healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (container $CONTAINER_NAME)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker start $CONTAINER_NAME'"
log "frontend healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (containers $CONTAINER_NAME, $CONTAINER_BACKEND)"
log "backend: http://${DEPLOY_HOST%%:*}:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (container $CONTAINER_BACKEND)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME $CONTAINER_BACKEND; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker rename $CONTAINER_BACKEND_PREV $CONTAINER_BACKEND; docker start $CONTAINER_NAME $CONTAINER_BACKEND'"
exit 0
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
die "frontend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5