Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m40s
CI / tests-unit (push) Successful in 1m51s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m10s
Effect renders need a little over 4s, which the 4s primary timeout cut off, so every avatar with the default effect fell through to an unreachable public fallback and rendered as a placeholder. Raise the primary budget above the observed render cost and shorten the fallback budget. Also stop the proxy from stamping no-store over the avatar and media responses, so browsers keep the long-lived Cache-Control the route already sends, and recreate the imaging cache directories with the container user on every deploy, since root ownership made those cache writes fail silently.
211 lines
8.3 KiB
Bash
211 lines
8.3 KiB
Bash
#!/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
|
|
[ "$deploy_dir/.env" -ef .env ] || cp "$deploy_dir/.env" .env
|
|
pnpm install --frozen-lockfile
|
|
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
|
|
# Read reports from the already-built image; do not start an extra application.
|
|
report_container=""
|
|
if report_container="$(docker create --entrypoint /bin/true "$image")" && [ -n "$report_container" ]; then
|
|
mkdir -p build-reports
|
|
if docker cp "$report_container:/app/build-reports/." build-reports && [ -s build-reports/report.md ]; then
|
|
cat build-reports/report.md
|
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
cat build-reports/report.md >> "$GITHUB_STEP_SUMMARY" || echo "Warning: could not append performance summary" >&2
|
|
fi
|
|
else
|
|
echo "Warning: build performance report unavailable" >&2
|
|
fi
|
|
docker rm "$report_container" >/dev/null
|
|
else
|
|
echo "Warning: could not extract build performance report" >&2
|
|
fi
|
|
# Exercise the candidate with disposable services before any live migration or cutover.
|
|
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
|
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
|
|
# The avatar/badge disk cache lives on the host bind and is written by uid 33
|
|
# inside the container. Root-owned directories make every cache write fail
|
|
# silently, which turns each avatar into a fresh live render.
|
|
for cache_dir in avatars badges; do
|
|
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
|
|
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
|
|
fi
|
|
done
|
|
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
|
|
# 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 HTTP and browser checks pass.
|
|
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
|
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
|
docker tag "$verified_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:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; 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)
|
|
# Reclaim build cache, unreferenced images and long-stopped containers. Never
|
|
# volumes; retention boundaries are enforced inside docker-prune.sh.
|
|
bash "$deploy_dir/scripts/docker-prune.sh" || true
|