#!/bin/sh set -eu repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" compose="$repo/deploy/compose.yml" project="${LUDARIUM_CANDIDATE_PROJECT:-ludarium-candidate}" candidate_port="${LUDARIUM_CANDIDATE_PORT:-1232}" data_root="${LUDARIUM_CANDIDATE_DATA_ROOT:?set an isolated candidate data root}" release_identity="$(tr -d '\r\n' < "$repo/VERSION")" image="${LUDARIUM_IMAGE:-ludarium/ludarium:$release_identity}" release_version="${LUDARIUM_RELEASE_VERSION:-${image##*:}}" playwright_image="mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e" browser_gate_mode="${LUDARIUM_BROWSER_GATE_MODE:-docker}" dotnet_image="mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0" gate_cpus="${LUDARIUM_GATE_CPUS:-4}" gate_memory="${LUDARIUM_GATE_MEMORY:-8g}" browser_gate_cpus="${LUDARIUM_BROWSER_GATE_CPUS:-4}" browser_gate_memory="${LUDARIUM_BROWSER_GATE_MEMORY:-4g}" build_nodes="${LUDARIUM_GATE_BUILD_NODES:-2}" export COMPOSE_PARALLEL_LIMIT="${LUDARIUM_COMPOSE_PARALLEL_LIMIT:-1}" case "$browser_gate_mode" in docker|external) ;; *) echo "browser gate mode must be docker or external" >&2; exit 2;; esac case "$gate_cpus:$browser_gate_cpus:$build_nodes:$COMPOSE_PARALLEL_LIMIT" in *[!0-9.:]*) echo "candidate resource limits must be positive numeric values" >&2; exit 2 ;; esac for value in "$gate_cpus" "$browser_gate_cpus" "$build_nodes" "$COMPOSE_PARALLEL_LIMIT"; do [ "$value" != "0" ] || { echo "candidate resource limits must be greater than zero" >&2; exit 2; } done case "$gate_memory:$browser_gate_memory" in *[!0-9kKmMgGtT:]*|:) echo "candidate memory limits must use Docker byte values such as 8g" >&2; exit 2 ;; esac case "$data_root" in /mnt/user/appdata/ludarium-candidate|/mnt/user/appdata/ludarium-candidate/*|/tmp/ludarium-candidate|/tmp/ludarium-candidate/*) ;; *) echo "candidate data root must remain below /mnt/user/appdata/ludarium-candidate or /tmp/ludarium-candidate" >&2; exit 2 ;; esac : "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" : "${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN}" : "${GAMES_LIBRARY:?set GAMES_LIBRARY}" : "${PS4_LIBRARY:?set PS4_LIBRARY}" : "${PS5_LIBRARY:?set PS5_LIBRARY}" : "${PUID:?set the numeric read-capable application UID}" : "${PGID:?set the numeric read-capable application GID}" for root in "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY"; do [ -d "$root" ] || { echo "library root unavailable: $root" >&2; exit 3; } done command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; } ensure_image() { target_image="$1" docker image inspect "$target_image" >/dev/null 2>&1 && return attempt=1 while [ "$attempt" -le 3 ]; do if command -v timeout >/dev/null; then timeout 420 docker pull "$target_image" && return else docker pull "$target_image" && return fi attempt=$((attempt + 1)) sleep $((attempt * 5)) done echo "bounded image download failed after 3 attempts: $target_image" >&2 exit 3 } [ "$browser_gate_mode" = "external" ] || ensure_image "$playwright_image" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT INT TERM before="$work/source-before.txt" after="$work/source-after.txt" source_archive="$work/source.tar" git -C "$repo" archive --format=tar --output="$source_archive" HEAD sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$before" cat "$before" if command -v dotnet >/dev/null; then dotnet restore "$repo/Ludarium.slnx" --locked-mode dotnet format "$repo/Ludarium.slnx" --no-restore --verify-no-changes dotnet build "$repo/Ludarium.slnx" -c Release --no-restore -m:"$build_nodes" -p:UseSharedCompilation=false LUDARIUM_RUN_CONTAINER_TESTS=1 dotnet test "$repo/Ludarium.slnx" -c Release --no-build --no-restore -m:"$build_nodes" else docker run --rm --network host --cpus "$gate_cpus" --memory "$gate_memory" \ --memory-swap "$gate_memory" --pids-limit 1024 --tmpfs /work:rw,nosuid,nodev,size=2g \ -v /var/run/docker.sock:/var/run/docker.sock -v "$source_archive:/source.tar:ro" -w /work \ -e LUDARIUM_RUN_CONTAINER_TESTS=1 -e LUDARIUM_GATE_BUILD_NODES="$build_nodes" \ "$dotnet_image" sh -lc \ 'tar -xf /source.tar -C /work && dotnet restore Ludarium.slnx --locked-mode && dotnet format Ludarium.slnx --no-restore --verify-no-changes && dotnet build Ludarium.slnx -c Release --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES" -p:UseSharedCompilation=false && dotnet test Ludarium.slnx -c Release --no-build --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES"' fi # The emulator sidecar controllers are the last defence before a process launch inside an # isolated player. They are stdlib-only, so this gate needs no dependency installation. if command -v python3 >/dev/null; then ( cd "$repo" && python3 -m unittest discover -s tests/controllers -t tests/controllers ) else docker run --rm --network none --cpus "$gate_cpus" --memory 512m \ -v "$repo:/repo:ro" -w /repo python:3.13-alpine@sha256:540c7d91f98ff6880174c40e99067bf5941eb54d818a7a5e094d188b196a934d \ python -m unittest discover -s tests/controllers -t tests/controllers fi mkdir -p "$data_root/app" "$data_root/cache" "$data_root/exports" "$data_root/postgres" "$data_root/backups" chown -R "$PUID:$PGID" "$data_root/app" "$data_root/cache" "$data_root/exports" export LUDARIUM_DATA_ROOT="$data_root" LUDARIUM_HTTP_PORT="$candidate_port" LUDARIUM_IMAGE="$image" export LUDARIUM_CONTAINER_NAME="${LUDARIUM_CANDIDATE_CONTAINER_NAME:-${project}-Ludarium}" docker compose -p "$project" -f "$compose" build --pull image_digest="$(docker image inspect "$image" --format '{{.Id}}')" docker compose -p "$project" -f "$compose" up -d --wait app_id="$(docker compose -p "$project" -f "$compose" ps -q ludarium)" [ -n "$app_id" ] || { echo "candidate app container was not created" >&2; exit 4; } initial_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')" [ "$initial_restart_count" = "0" ] || { echo "candidate started with an unexpected restart count: $initial_restart_count" >&2; exit 4; } docker inspect "$app_id" --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -Fq 'GSS Encryption Mode=Disable' || { echo "candidate database connection must disable unused local GSS negotiation" >&2 exit 4 } for destination in /library/games /library/ps4 /library/ps5; do docker inspect "$app_id" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' | grep -F "$destination false" >/dev/null || { echo "read-only mount proof failed for $destination" >&2; exit 5; } done wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select max(version) from schema_versions')" expected_schema="$(sed -n 's/.*CurrentSchemaVersion = \([0-9][0-9]*\).*/\1/p' "$repo/src/Ludarium.Infrastructure/PostgresStore.cs")" case "$expected_schema" in ''|*[!0-9]*) echo "could not read the declared schema version" >&2; exit 6;; esac [ "$schema" = "$expected_schema" ] || { echo "expected schema $expected_schema, found $schema" >&2; exit 6; } server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')" [ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding, found $server_encoding" >&2; exit 6; } docker compose -p "$project" -f "$compose" exec -T ludarium locale -a | grep -Fx 'en_US.utf8' >/dev/null || { echo "candidate runtime must provide the legacy production locale en_US.utf8" >&2 exit 6 } for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do docker compose -p "$project" -f "$compose" exec -T ludarium \ psql -v ON_ERROR_STOP=1 -U ludarium -d ludarium \ -c "ALTER SYSTEM SET $locale_setting TO 'en_US.utf8'" >/dev/null done reload_result="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select pg_reload_conf()')" [ "$reload_result" = "t" ] || { echo "failed to load the production-shaped locale profile" >&2; exit 6; } collation_fixture="ludarium_collation_fixture" docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium --if-exists "$collation_fixture" docker compose -p "$project" -f "$compose" exec -T ludarium \ createdb -U ludarium --template=template0 --locale=en_US.utf8 "$collation_fixture" docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d "$collation_fixture" -c " create table platform_definitions(id text,custom boolean not null,enabled boolean not null,version bigint not null,data jsonb not null,updated_at timestamptz not null); insert into platform_definitions values ('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-10T00:00:00Z\"}',timestamptz '2026-08-10T00:00:00Z'), ('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-21T00:00:00Z\"}',timestamptz '2026-08-21T00:00:00Z'); create table indexed_fixture(id text primary key); insert into indexed_fixture values('alpha'),('beta');" >/dev/null fixture_index_before="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ "select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")" docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d postgres -c \ "update pg_database set datcollversion=null where datname='$collation_fixture'" >/dev/null docker compose -p "$project" -f "$compose" exec -T -e POSTGRES_DB="$collation_fixture" ludarium \ /usr/local/bin/repair-postgres-collation.sh fixture_rows="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ "select count(*) from platform_definitions where id='game-gear'")" fixture_index_after="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ "select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")" fixture_version_current="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \ "select datcollversion=pg_database_collation_actual_version(oid) from pg_database where datname=current_database()")" docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium "$collation_fixture" [ "$fixture_rows" = "1" ] || { echo "legacy collation fixture did not safely deduplicate built-in rows" >&2; exit 6; } [ "$fixture_index_before" != "$fixture_index_after" ] || { echo "legacy collation fixture index was not rebuilt" >&2; exit 6; } [ "$fixture_version_current" = "t" ] || { echo "legacy collation fixture version was not refreshed" >&2; exit 6; } api="http://127.0.0.1:$candidate_port/api/v1" auth="Authorization: Bearer $LUDARIUM_ADMIN_TOKEN" libraries="$(curl -fsS -H "$auth" "$api/libraries")" if [ "$(printf '%s' "$libraries" | jq 'length')" = "0" ]; then create_library() { curl -fsS -H "$auth" -H 'Content-Type: application/json' -d "$2" "$api/libraries" | jq -r '.id' } games_id="$(create_library games '{"name":"Games","path":"/library/games","kind":"Mixed","recursive":true,"hashPolicy":"OnDemand","inspectArchives":true,"maxConcurrency":1}')" ps4_id="$(create_library ps4 '{"name":"PS4 Games","path":"/library/ps4","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')" ps5_id="$(create_library ps5 '{"name":"PS5 Games","path":"/library/ps5","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')" libraries="$(curl -fsS -H "$auth" "$api/libraries")" fi scan_ids="" for library_id in $(printf '%s' "$libraries" | jq -r '.[].id'); do curl -fsS -X POST -H "$auth" "$api/libraries/$library_id/verify" >/dev/null scan_id="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d '{"mode":"Quick"}' "$api/libraries/$library_id/scans" | jq -r '.id')" scan_ids="$scan_ids $scan_id" done deadline=$(( $(date +%s) + 1800 )) while :; do all_done=true for scan_id in $scan_ids; do state="$(curl -fsS -H "$auth" "$api/scans/$scan_id" | jq -r '.state')" [ "$state" != "Failed" ] || { echo "candidate bootstrap scan failed: $scan_id" >&2; exit 6; } [ "$state" = "Completed" ] || [ "$state" = "Cancelled" ] || all_done=false done [ "$all_done" = true ] && break [ "$(date +%s)" -lt "$deadline" ] || { echo "candidate bootstrap scans timed out" >&2; exit 6; } sleep 5 done if [ "$browser_gate_mode" = "docker" ]; then docker run --rm --network host --cpus "$browser_gate_cpus" --memory "$browser_gate_memory" \ --memory-swap "$browser_gate_memory" --pids-limit 1024 \ --tmpfs /work:rw,exec,nosuid,nodev,size=768m \ -e PLAYWRIGHT_BASE_URL="http://127.0.0.1:$candidate_port" \ -e LUDARIUM_ADMIN_TOKEN \ -v "$source_archive:/source.tar:ro" -w /work "$playwright_image" \ sh -lc 'tar -xf /source.tar -C /work --strip-components=2 src/Ludarium.Web && (npm ci --ignore-scripts || (sleep 5 && npm ci --ignore-scripts) || (sleep 15 && npm ci --ignore-scripts)) && node node_modules/vitest/vitest.mjs run --maxWorkers=4 && node node_modules/typescript/bin/tsc --noEmit && node node_modules/vite/bin/vite.js build && npm audit --audit-level=high && node e2e/workflows.mjs && node e2e/game-data-vault.mjs && node e2e/accessibility.mjs' else printf 'browser gate delegated: run the checked-in Playwright workflows against http://HOST:%s before promotion\n' "$candidate_port" fi post_browser_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')" [ "$post_browser_restart_count" = "$initial_restart_count" ] || { echo "candidate restarted unexpectedly during browser gates: $initial_restart_count -> $post_browser_restart_count" >&2 exit 6 } docker compose -p "$project" -f "$compose" restart ludarium docker compose -p "$project" -f "$compose" up -d --wait wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')" [ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding after restart, found $server_encoding" >&2; exit 6; } for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do configured_locale="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc "show $locale_setting")" [ "$configured_locale" = "en_US.utf8" ] || { echo "production-shaped locale $locale_setting did not survive restart: $configured_locale" >&2 exit 6 } done post_controlled_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')" [ "$post_controlled_restart_count" = "$initial_restart_count" ] || { echo "candidate restart count changed after controlled restart: $initial_restart_count -> $post_controlled_restart_count" >&2 exit 6 } backup="$data_root/backups/candidate-schema${expected_schema}.dump" docker compose -p "$project" -f "$compose" exec -T ludarium pg_dump -U ludarium -Fc ludarium > "$backup" docker compose -p "$project" -f "$compose" exec -T ludarium createdb -U ludarium ludarium_restore docker compose -p "$project" -f "$compose" exec -T ludarium pg_restore -U ludarium -d ludarium_restore --clean --if-exists < "$backup" restored_schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium_restore -Atc 'select max(version) from schema_versions')" docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium ludarium_restore [ "$restored_schema" = "$expected_schema" ] || { echo "restore schema verification failed: expected $expected_schema, found $restored_schema" >&2 exit 7 } sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$after" cat "$after" cmp -s "$before" "$after" || { echo "source manifest changed" >&2; exit 8; } LUDARIUM_IMAGE="$image" LUDARIUM_RELEASE_VERSION="$release_version" LUDARIUM_RELEASE_TOOL_CACHE="$data_root/release-tools" sh "$repo/deploy/run-security-gates.sh" if [ "$browser_gate_mode" = "docker" ]; then gate_state=passed; else gate_state=infrastructure-passed-browser-external-required; fi printf 'candidate gate %s image=%s digest=%s port=%s schema=%s encoding=%s backup=%s\n' "$gate_state" "$image" "$image_digest" "$candidate_port" "$schema" "$server_encoding" "$backup"