#!/usr/bin/env bash # One lock covers build, migrations, cutover, health checks and smoke tests. set -Eeuo pipefail deploy_dir="${CMS_DEPLOY_DIR:-/var/www/atom-nexst}" branch="${DEPLOY_BRANCH:-main}" case "$branch" in main|master) ;; *) echo "Unsupported deployment branch" >&2; exit 1 ;; esac exec 9>"$deploy_dir/.deploy.lock" flock -w 1800 9 sha="$(git rev-parse HEAD)" [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit" >&2; exit 1; } image="epicnext-cms:$sha" previous_name="" previous_image="" secondary_name="" secondary_backup="epicnext-cms-rollback-secondary" secondary_backup_created=0 backup_name="epicnext-cms-rollback" cutover_started=0 candidate_attempted=0 backup_created=0 is_current() { local head head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2 head="${head%%[[:space:]]*}" if [ "$head" != "$sha" ]; then echo "Skipping superseded commit $sha (branch now at $head)" return 1 fi } check_current() { local status=0 is_current || status=$? case "$status" in 0) ;; 1) exit 0 ;; *) echo "Cannot verify remote branch" >&2; exit 1 ;; esac } healthy() { local attempt for attempt in $(seq 1 30); do if curl -sf --max-time 5 http://127.0.0.1:3002/api/health | grep -q '"database":true'; then return 0; fi sleep 3 done return 1 } finish() { local status=$? trap - EXIT if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then echo "Deployment failed; restoring previous container" >&2 docker logs epicnext-cms-app --tail 50 >&2 || true if command -v ss >/dev/null 2>&1; then ss -ltnp 'sport = :3002' >&2 || true; fi if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi if [ "$secondary_backup_created" -eq 1 ]; then docker rename "$secondary_backup" "$secondary_name" || true fi if [ -n "$previous_name" ]; then if docker start "$previous_name" && healthy; then echo "Rollback verified: $previous_image" else echo "ERROR: previous container could not be restored to healthy state" >&2 fi else echo "No previous container exists; rollback is unavailable" >&2 fi if [ "$secondary_backup_created" -eq 1 ]; then docker start "$secondary_name" || true; fi fi exit "$status" } trap finish EXIT trap 'exit 130' INT trap 'exit 143' TERM check_current # Read-only ownership evidence before any build or container cutover. if command -v ss >/dev/null 2>&1; then listeners="$(ss -ltnp 'sport = :3002' 2>/dev/null || true)" printf '%s\n' "$listeners" while read -r listener_pid; do [ -n "$listener_pid" ] || continue printf 'Port owner PID=%s cwd=' "$listener_pid" readlink "/proc/$listener_pid/cwd" || true cat "/proc/$listener_pid/cgroup" 2>/dev/null || true ps -o pid=,ppid=,user=,comm= -p "$listener_pid" || true done < <(printf '%s' "$listeners" | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u) fi for managed_name in epicnext-cms epicnext-cms-app; do docker inspect --format '{{.Name}} running={{.State.Running}} pid={{.State.Pid}} image={{.Image}}' "$managed_name" 2>/dev/null || true done cp "$deploy_dir/.env" .env pnpm install --frozen-lockfile # Prepare browser before cutover so installation failures cannot interrupt the site. pnpm exec playwright install chromium echo "Building $image" DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \ --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" . check_current pnpm db:migrate check_current # Prefer the active CI container, or the active legacy compose container. for name in epicnext-cms epicnext-cms-app; do if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then if [ -z "$previous_name" ]; then previous_name="$name"; else secondary_name="$name"; fi fi done if [ -z "$previous_name" ]; then for name in epicnext-cms-app epicnext-cms; do if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi done fi if docker inspect "$backup_name" >/dev/null 2>&1; then echo "Unresolved rollback container exists; refusing to overwrite it" >&2 exit 1 fi if [ -n "$previous_name" ]; then previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")" docker tag "$previous_image" epicnext-cms:previous fi # Remove a stopped leftover CI container when the compose container is active. if [ "$previous_name" != epicnext-cms-app ] && [ "$secondary_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then docker rm epicnext-cms-app fi cutover_started=1 # Both legacy Compose and CI containers can exist after earlier failed updates. # Preserve each before releasing the shared host port; never kill an arbitrary PID. if [ -n "$secondary_name" ]; then docker stop "$secondary_name" docker rename "$secondary_name" "$secondary_backup" secondary_backup_created=1 fi if [ -n "$previous_name" ]; then docker stop "$previous_name" docker rename "$previous_name" "$backup_name" backup_created=1 fi candidate_attempted=1 ( set -a # shellcheck disable=SC1091 . "$deploy_dir/.env" set +a ENV_ARGS=() while IFS='=' read -r key _; do case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac ENV_ARGS+=(-e "$key") done < "$deploy_dir/.env" docker run -d --name epicnext-cms-app --restart always --net=host \ "${ENV_ARGS[@]}" -e PORT=3002 -e HOSTNAME=0.0.0.0 \ -v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \ -v "$deploy_dir/public/swf:/app/public/swf" \ -v "$deploy_dir/storage:/app/storage" \ -v /var/www/Gamedata:/var/www/Gamedata \ "$image" ) healthy node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha" PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e # Publish the latest alias only after health and browser checks pass. docker tag "$image" epicnext-cms:latest cutover_started=0 if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi echo "Deployment verified: $sha" # Retain the current and previous releases; do not remove arbitrary named tags. while IFS= read -r tag; do if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)" if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi fi done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms) docker builder prune -af --filter "until=72h" --max-used-space=4g || true docker image prune -f --filter "until=168h" || true