Files
EpicNext-Cms/scripts/ci-deploy.sh
T
openhands 80d7ae14ba
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 34s
CI / tests-unit (push) Successful in 1m42s
CI / tests-integration (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m35s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m37s
fix(ci): fail fast when the deploy dir has no DATABASE_URL
pnpm db:migrate runs on the host and reads DATABASE_URL from the deploy
directory's .env. When that variable was missing the deploy had already
built an image and run the browser gate before pnpm db:migrate aborted on
an empty value, so a release was paid for in full and then thrown away.

Check for the variable right after the .env is copied, before the build,
and say plainly that the live release was not touched. The deploy test
fixture gains a DATABASE_URL so it mirrors a working deploy directory
instead of the broken one.
2026-09-27 18:57:10 +02:00

223 lines
8.9 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
# De migraties draaien op de host tegen DATABASE_URL, niet in de container. Die
# waarde staat alleen in $deploy_dir/.env, dus controleer hem hier: anders
# bouwen we eerst een image en doorlopen we de browsergate voordat `db:migrate`
# op een lege DATABASE_URL stukloopt. Dat is een halve release voor niets.
if ! grep -qs '^DATABASE_URL=' .env; then
echo "Error: DATABASE_URL ontbreekt in $deploy_dir/.env" >&2
echo " Zet daar een DATABASE_URL (mysql://user:pass@host:3306/db) en draai opnieuw." >&2
echo " De live release is niet aangeraakt; deze release is niet uitgerold." >&2
exit 1
fi
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