#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Validate the production control-plane image before a remote VPS run. This checks the production Compose file, builds the app image, and verifies that the runtime image contains: - the bundled Linux devpush-agent binary used by the server installer - the standalone migration and database-backup runners, plus SQL migrations - the built TanStack Start server entrypoint - no development dependency tree or TypeScript runtime Optional environment: DEVPUSH_PRODUCTION_IMAGE Image tag to build, default: devpush-app:local DEVPUSH_PRODUCTION_ENV Env file for Compose config, default: deploy/.env.production.example Example: pnpm validate:production EOF } if [[ "${1:-}" == "--" ]]; then shift fi if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi image="${DEVPUSH_PRODUCTION_IMAGE:-devpush-app:local}" env_file="${DEVPUSH_PRODUCTION_ENV:-deploy/.env.production.example}" step() { printf '\n==> %s\n' "$1" } compose() { if docker compose version >/dev/null 2>&1; then docker compose "$@" return fi if command -v docker-compose >/dev/null 2>&1; then docker-compose "$@" return fi echo "Docker Compose is required." >&2 exit 1 } # The tracked example deliberately contains blank secrets; use dummy probe values. if [[ "$env_file" == "deploy/.env.production.example" ]]; then export BETTER_AUTH_SECRET=production-image-validation-auth-secret export DEVPUSH_SECRET_ENCRYPTION_KEY=production-image-validation-encryption-secret export DEVPUSH_CRON_SECRET=production-image-validation-cron-secret fi step "Checking production Compose config" compose --env-file "$env_file" -f deploy/compose.production.yml config >/dev/null step "Building production app image" export DEVPUSH_PRODUCTION_IMAGE="$image" compose --env-file "$env_file" -f deploy/compose.production.yml build app step "Checking production runtime image" docker run --rm "$image" sh -lc ' set -euo pipefail test "$(id -u)" -ne 0 test -w /app/storage test -w /repo/app/public/uploads test -x /repo/bin/devpush-agent-linux-amd64 test -x /repo/bin/devpush-agent-linux-arm64 case "$(uname -m)" in x86_64) /repo/bin/devpush-agent-linux-amd64 --help >/dev/null ;; aarch64) /repo/bin/devpush-agent-linux-arm64 --help >/dev/null ;; *) echo "Unsupported validation architecture: $(uname -m)" >&2; exit 1 ;; esac test -f /repo/app/.output/server/index.mjs test -f /repo/app/.maintenance/migrate.mjs test -f /repo/app/.maintenance/migrate.mjs.map test -f /repo/app/src/server/db/migrations-sqlite/meta/_journal.json test -f /repo/app/src/server/db/migrations-logs/meta/_journal.json test -f /repo/app/.maintenance/backup-sqlite.mjs test ! -e /repo/node_modules test -L /repo/app/node_modules test ! -e /repo/app/scripts/migrate.ts ! command -v npm >/dev/null ! command -v yarn >/dev/null ! command -v corepack >/dev/null ! command -v pnpm >/dev/null ! command -v tsx >/dev/null node --check /repo/app/.maintenance/migrate.mjs ' missing_database_output="$(docker run --rm "$image" node .maintenance/migrate.mjs 2>&1 || true)" if ! grep -q 'DEVPUSH_DATABASE_PATH is required in production' <<<"$missing_database_output"; then echo "Production migration runner did not reject a missing DEVPUSH_DATABASE_PATH." >&2 exit 1 fi step "Exercising production migrations and health check" probe_id="devpush-production-probe-${RANDOM:-0}-$$" probe_network="${probe_id}-net" probe_volume="${probe_id}-data" probe_app="${probe_id}-app" probe_cron_secret="production-image-validation-cron-secret" probe_dir="$(mktemp -d)" # Keep real operator configuration out of the disposable Compose probe. probe_compose() ( for name in "${!DEVPUSH_@}" "${!BETTER_AUTH_@}" "${!GITHUB_APP_@}" "${!GOOGLE_@}" EMAIL_FROM RESEND_API_KEY; do unset "$name" done export DEVPUSH_PRODUCTION_IMAGE="$image" compose -p "$probe_id" --env-file "$probe_dir/compose.env" -f deploy/compose.production.yml "$@" ) cat > "$probe_dir/compose.env" </dev/null 2>&1 || true rm -rf "$probe_dir" docker rm -f "$probe_app" >/dev/null 2>&1 || true docker volume rm "$probe_volume" >/dev/null 2>&1 || true docker network rm "$probe_network" >/dev/null 2>&1 || true } trap cleanup_probe EXIT INT TERM docker network create "$probe_network" >/dev/null docker volume create "$probe_volume" >/dev/null docker run --rm \ --network "$probe_network" \ -v "$probe_volume:/app/storage" \ -e DEVPUSH_DATABASE_PATH=/app/storage/devpush.sqlite \ -e DEVPUSH_LOG_DATABASE_PATH=/app/storage/logs.sqlite \ "$image" \ node --enable-source-maps /repo/app/.maintenance/migrate.mjs docker run -d \ --name "$probe_app" \ --network "$probe_network" \ -v "$probe_volume:/app/storage" \ -e DEVPUSH_DATABASE_PATH=/app/storage/devpush.sqlite \ -e DEVPUSH_LOG_DATABASE_PATH=/app/storage/logs.sqlite \ -e BETTER_AUTH_SECRET=production-image-validation-secret \ -e DEVPUSH_CRON_SECRET=production-image-validation-cron-secret \ -e DEVPUSH_BASE_URL=http://localhost:3000 \ "$image" >/dev/null app_ready=false for _ in $(seq 1 60); do if docker exec "$probe_app" wget -qO- http://127.0.0.1:3000/api/health 2>/dev/null | grep -q '"status":"ok"'; then app_ready=true break fi if [[ "$(docker inspect "$probe_app" --format '{{.State.Running}}')" != "true" ]]; then break fi sleep 1 done if [[ "$app_ready" != "true" ]]; then docker logs "$probe_app" >&2 echo "Production image health check failed." >&2 exit 1 fi step "Checking login and scheduled maintenance" docker exec "$probe_app" curl --fail --silent http://127.0.0.1:3000/auth/login >/dev/null docker exec "$probe_app" curl --fail --silent --request POST \ --header "Authorization: Bearer $probe_cron_secret" \ http://127.0.0.1:3000/api/cron/maintenance >/dev/null step "Checking online backups and restored snapshots" docker run --rm \ -v "$probe_volume:/app/storage" \ -e DEVPUSH_DATABASE_PATH=/app/storage/devpush.sqlite \ -e DEVPUSH_LOG_DATABASE_PATH=/app/storage/logs.sqlite \ -e DEVPUSH_DB_BACKUP_DIR=/app/storage/backups \ "$image" node .maintenance/backup-sqlite.mjs docker run --rm -v "$probe_volume:/app/storage" "$image" node --input-type=module -e ' import { createRequire } from "node:module"; import { readdirSync } from "node:fs"; const require = createRequire("/repo/app/.output/server/index.mjs"); const Sqlite = require("better-sqlite3"); const files = readdirSync("/app/storage/backups"); if (files.length !== 2) throw new Error("Expected two snapshots"); for (const file of files) { const db = new Sqlite("/app/storage/backups/" + file, { readonly: true }); if (db.pragma("integrity_check", { simple: true }) !== "ok") throw new Error("Snapshot integrity failure"); if (!db.prepare("select count(*) as n from sqlite_master where type = ?").get("table").n) throw new Error("Missing migrated tables"); db.close(); } ' step "Checking production Compose startup" probe_compose up -d --no-build --wait --wait-timeout 90 compose_app="$(probe_compose ps -q app)" docker exec "$compose_app" sh -ec ' curl --fail --silent --request POST --header "Authorization: Bearer $DEVPUSH_CRON_SECRET" http://127.0.0.1:3000/api/cron/maintenance >/dev/null ' cleanup_probe trap - EXIT INT TERM image_size="$(docker image inspect "$image" --format '{{.Size}}')" cat <