#!/usr/bin/env bash set -Eeuo pipefail if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi RELEASE_SHA="${1:?Release SHA is required}" [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid release SHA'; exit 1; } IMAGE="waterfall-wiki:$RELEASE_SHA" APP_ROOT=/opt/prompt RELEASE_DIR="$APP_ROOT/releases/$RELEASE_SHA" PORT=18131 command -v docker >/dev/null docker compose version >/dev/null docker image inspect "$IMAGE" >/dev/null mkdir -p "$APP_ROOT/releases" exec 9>"$APP_ROOT/.deploy.lock" flock -w 300 9 || { echo 'Another PROMPT deployment is running'; exit 1; } test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$RELEASE_SHA" LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1) if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then git -c safe.directory="$PWD" fetch origin main if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki packages/brand Dockerfile .dockerignore compose.yml package.json pnpm-workspace.yaml scripts .gitea/workflows/app.yml; then echo 'Skipping deployment: Wiki has a newer commit'; exit 0 fi fi PREVIOUS="" PREVIOUS_WEB=web PREVIOUS_WORKER=worker if [ -L "$APP_ROOT/current" ] && [ -d "$APP_ROOT/current" ]; then PREVIOUS=$(readlink -f "$APP_ROOT/current"); fi if [ -n "$PREVIOUS" ] && grep -q '^ waterfall-wiki:' "$PREVIOUS/compose.yml"; then PREVIOUS_WEB=waterfall-wiki; PREVIOUS_WORKER=waterfall-wiki-worker fi bash scripts/infra.sh "$IMAGE" mkdir -p "$RELEASE_DIR" install -m 0644 compose.yml "$RELEASE_DIR/compose.yml" printf 'WATERFALL_WIKI_IMAGE=%s\nRELEASE_SHA=%s\nWEB_PORT=%s\nAUTH_CONFIG_FILE=/auth/config.json\n' "$IMAGE" "$RELEASE_SHA" "$PORT" > "$RELEASE_DIR/release.env" compose_at(){ local release="$1"; shift; env -u PROMPT_IMAGE -u PROMPT_TEST_IMAGE -u AUTH_CONFIG_FILE -u AUTH_COOKIE_SECURE -u WATERFALL_WIKI_IMAGE -u WATERFALL_WIKI_TEST_IMAGE -u RELEASE_SHA -u WEB_PORT -u WEB_BIND_ADDRESS docker compose -p prompt --env-file /etc/prompt.env --env-file "$release/release.env" -f "$release/compose.yml" "$@"; } compose(){ compose_at "$RELEASE_DIR" "$@"; } LEGACY_WEB=0 LEGACY_WORKER=0 LEGACY_WEB_ENABLED=0 LEGACY_WORKER_ENABLED=0 if command -v systemctl >/dev/null; then if systemctl is-active --quiet prompt.service; then LEGACY_WEB=1; fi if systemctl is-active --quiet prompt-worker.service; then LEGACY_WORKER=1; fi if systemctl is-enabled --quiet prompt.service 2>/dev/null; then LEGACY_WEB_ENABLED=1; fi if systemctl is-enabled --quiet prompt-worker.service 2>/dev/null; then LEGACY_WORKER_ENABLED=1; fi fi CUTOVER=0 MIGRATION_STARTED=0 BACKUP_FILE="" rollback(){ local code=$? trap - ERR set +e if [ "$CUTOVER" -eq 1 ]; then compose stop waterfall-wiki waterfall-wiki-worker if [ "$MIGRATION_STARTED" -eq 1 ] && [ -s "$BACKUP_FILE" ]; then if ! compose exec -T postgres pg_restore -U prompt -d prompt --clean --if-exists --exit-on-error < "$BACKUP_FILE"; then echo 'Database rollback failed; application remains stopped. Backup retained.' exit "$code" fi echo 'Restored the database backup compatible with the previous application' fi if [ -n "$PREVIOUS" ] && [ -f "$PREVIOUS/compose.yml" ]; then compose_at "$PREVIOUS" up -d --no-build --no-deps "$PREVIOUS_WEB" "$PREVIOUS_WORKER" echo 'Restored the previous Docker application image' else compose stop waterfall-wiki waterfall-wiki-worker compose rm -f waterfall-wiki waterfall-wiki-worker if [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then systemctl enable prompt-worker.service; fi if [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl enable prompt.service; fi if [ "$LEGACY_WEB" -eq 1 ]; then systemctl start prompt.service; fi if [ "$LEGACY_WORKER" -eq 1 ]; then systemctl start prompt-worker.service; fi echo 'Restored legacy application services if previously running' fi if [ -n "$PREVIOUS" ]; then ln -sfn "$PREVIOUS" "$APP_ROOT/.current.rollback" mv -Tf "$APP_ROOT/.current.rollback" "$APP_ROOT/current" fi fi echo "Docker deployment failed (exit $code); persistent data volumes were retained" exit "$code" } trap rollback ERR # Keep the same prompt_postgres_data and prompt_objects volumes. No production down -v. # Infrastructure already exists; never recreate it during an app release. compose exec -T postgres pg_isready -U prompt -d prompt install -d -o root -g root -m 0700 /var/lib/prompt/backups CUTOVER=1 compose stop waterfall-wiki waterfall-wiki-worker if [ -n "$PREVIOUS" ]; then compose_at "$PREVIOUS" stop "$PREVIOUS_WEB" "$PREVIOUS_WORKER"; fi BACKUP_FILE="/var/lib/prompt/backups/$(date -u +%Y%m%dT%H%M%S)-$RELEASE_SHA.dump" compose exec -T postgres pg_dump -U prompt -d prompt -Fc > "$BACKUP_FILE" find /var/lib/prompt/backups -name '*.dump' -printf '%T@ %p\n' | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm -- MIGRATION_STARTED=1 compose run --rm --no-deps auth-admin compose run --rm --no-deps migrate compose run --rm --no-deps publish CUTOVER=1 # One-time migration: finish a current host worker job before switching the web port. if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then systemctl disable --now prompt-worker.service; fi if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl disable --now prompt.service; fi compose up -d --no-build --no-deps --wait --wait-timeout 180 waterfall-wiki waterfall-wiki-worker compose exec -T waterfall-wiki node container-health.mjs compose exec -T waterfall-wiki node auth-cli.cjs verify compose exec -T waterfall-wiki node auth-cli.cjs run scripts/import-character-packs.mjs compose exec -T waterfall-wiki-worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null' # Editorial posts may be older than the first 24 entries. Verify the complete paged feed. compose exec -T waterfall-wiki sh -c 'cat > /tmp/prompt-release-check.mjs && node auth-cli.cjs run /tmp/prompt-release-check.mjs' <<'NODE' (async()=>{ const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english']; const missing=new Set(slugs),seen=new Set();let cursor=''; while(missing.size){ const query=new URLSearchParams({section:'articles',...(cursor?{cursor}:{})}); const response=await fetch('http://127.0.0.1:3000/api/feed?'+query); if(!response.ok)throw Error('Article feed unavailable: '+response.status); const feed=await response.json();for(const post of feed.posts)missing.delete(post.slug); if(!missing.size||!feed.nextCursor)break; if(seen.has(feed.nextCursor))throw Error('Article pagination did not advance'); seen.add(feed.nextCursor);cursor=feed.nextCursor; } if(missing.size)throw Error('Published articles missing: '+[...missing].join(', ')); for(const slug of slugs){const res=await fetch('http://127.0.0.1:3000/p/'+slug);if(!res.ok)throw Error('Article unavailable: '+slug);} console.log('Verified three published article pages across the paged feed'); })().catch(error=>{console.error(error.message);process.exit(1)}); NODE bash scripts/verify-studio-production.sh ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next" mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current" install -m 0755 scripts/auth-admin.sh "$APP_ROOT/auth" compose ps for service in waterfall-wiki waterfall-wiki-worker postgres objects; do id=$(compose ps -q "$service") docker inspect --format '{{.Name}} running={{.State.Running}} restart={{.HostConfig.RestartPolicy.Name}}' "$id" done if command -v systemctl >/dev/null; then if systemctl is-active --quiet prompt.service || systemctl is-active --quiet prompt-worker.service; then echo 'Legacy host service is unexpectedly running'; false; fi fi if docker inspect prompt-studio-studio-1 >/dev/null 2>&1; then if [ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' prompt-studio-studio-1)" = prompt-studio ]; then docker stop prompt-studio-studio-1 fi fi echo "Full Docker deployment verified: web+worker+postgres+objects port=$PORT release=$RELEASE_SHA" # Register only after every deployment check succeeded; ledger failure retains old images. if ! python3 scripts/wiki-image-lifecycle.py record-success --sha "$RELEASE_SHA"; then echo "Warning: successful-release ledger update failed; runtime cleanup stays conservative" >&2 fi # Read-only network diagnostics: a private address alone is not a public entrypoint. echo "Host: $(hostname)" hostname -I || true if command -v curl >/dev/null; then for field in eipv4 public-ipv4; do address=$(curl --noproxy '*' -fsS --connect-timeout 2 --max-time 4 "http://100.100.100.200/latest/meta-data/$field" 2>/dev/null || true) if [[ "$address" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Instance $field: $address"; fi done fi