# Parallel deployment of the new ASSESSOR application. # main -> stable (10.0.0.154:4173) # experimental -> experimental (10.0.0.154:4174) # # The existing HTTPS production deployment is not managed by this workflow. # Both new environments use separate Compose projects, databases, artifact # volumes and rebuildable Qdrant indexes. Their complete environment files come # from different Gitea secrets. name: Deploy on: push: branches: - main - experimental jobs: deploy: name: Test, build & deploy runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Configure stable environment if: github.ref_name == 'main' env: ASSESSOR_ENV_FILE: ${{ secrets.PROD_ENV_FILE }} run: | set -eu if [ -z "$ASSESSOR_ENV_FILE" ]; then echo "::error::PROD_ENV_FILE is empty or not configured." exit 1 fi printf '%s\n' "$ASSESSOR_ENV_FILE" > .env chmod 600 .env for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD ASSESSOR_QDRANT_API_KEY; do if ! grep -q "^${key}=." .env; then echo "::error::PROD_ENV_FILE does not contain a value for ${key}." exit 1 fi done echo "ASSESSOR_ENVIRONMENT=stable" >> "$GITHUB_ENV" echo "ASSESSOR_STACK=assessor_prod" >> "$GITHUB_ENV" echo "ASSESSOR_PUBLISH_PORT=4173" >> "$GITHUB_ENV" echo "ASSESSOR_BIND=10.0.0.154" >> "$GITHUB_ENV" echo "Stable configuration validated; secret values were not printed." - name: Configure experimental environment if: github.ref_name == 'experimental' env: ASSESSOR_ENV_FILE: ${{ secrets.STAGING_ENV_FILE }} run: | set -eu if [ -z "$ASSESSOR_ENV_FILE" ]; then echo "::error::STAGING_ENV_FILE is empty or not configured." exit 1 fi printf '%s\n' "$ASSESSOR_ENV_FILE" > .env chmod 600 .env for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD ASSESSOR_QDRANT_API_KEY; do if ! grep -q "^${key}=." .env; then echo "::error::STAGING_ENV_FILE does not contain a value for ${key}." exit 1 fi done echo "ASSESSOR_ENVIRONMENT=experimental" >> "$GITHUB_ENV" echo "ASSESSOR_STACK=assessor_experimental" >> "$GITHUB_ENV" echo "ASSESSOR_PUBLISH_PORT=4174" >> "$GITHUB_ENV" echo "ASSESSOR_BIND=10.0.0.154" >> "$GITHUB_ENV" echo "Experimental configuration validated; secret values were not printed." - name: Install dependencies run: npm ci - name: Test run: npm test - name: Build run: npm run build # Gitea currently ignores the GitHub-compatible concurrency key. An # atomic Docker container name therefore serializes each target stack on # the shared Docker host. --rm releases a crashed job after two hours. - name: Acquire environment deployment lock run: | set -eu lock_name="${ASSESSOR_STACK}_deployment_lock" attempt=0 until docker run -d --rm \ --name "$lock_name" \ --label "de.assessor.deployment-lock=$ASSESSOR_STACK" \ postgres:16-alpine sleep 7200 >/dev/null 2>&1; do attempt=$((attempt + 1)) if [ "$attempt" -ge 180 ]; then echo "::error::Timed out waiting for the $ASSESSOR_STACK deployment lock." exit 1 fi sleep 10 done echo "ASSESSOR_LOCK_ACQUIRED=true" >> "$GITHUB_ENV" echo "Environment=$ASSESSOR_ENVIRONMENT deployment-lock=acquired" - name: Validate Compose configuration run: docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml config --quiet - name: Check deployment isolation run: | set -eu published_containers="$(docker ps --filter "publish=$ASSESSOR_PUBLISH_PORT" --format '{{.ID}}')" for container_id in $published_containers; do project="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container_id")" if [ "$project" != "$ASSESSOR_STACK" ]; then echo "::error::Port $ASSESSOR_PUBLISH_PORT is already published outside $ASSESSOR_STACK." exit 1 fi done services="$(docker ps -a --filter "label=com.docker.compose.project=$ASSESSOR_STACK" --format '{{.Label "com.docker.compose.service"}}' | sort -u)" for service in $services; do case "$service" in app|postgres|qdrant) ;; *) echo "::error::Compose project $ASSESSOR_STACK contains an unexpected service." exit 1 ;; esac done echo "Environment=$ASSESSOR_ENVIRONMENT stack=$ASSESSOR_STACK port=$ASSESSOR_PUBLISH_PORT isolation=ok" - name: Back up existing environment run: | set -euo pipefail postgres_id="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps -a -q postgres)" if [ -z "$postgres_id" ]; then echo "Environment=$ASSESSOR_ENVIRONMENT backup=not-required-first-deployment" exit 0 fi if [ "$(docker inspect --format '{{.State.Running}}' "$postgres_id")" != "true" ]; then echo "::error::Existing $ASSESSOR_STACK database is not running; deployment stopped before migration." exit 1 fi backup_volume=assessor_deployment_backups backup_stamp="$(date -u +%Y%m%dT%H%M%SZ)" backup_path="$ASSESSOR_STACK/$backup_stamp" docker volume create "$backup_volume" >/dev/null docker run --rm -v "$backup_volume:/backups" postgres:16-alpine \ mkdir -p "/backups/$backup_path" docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml exec -T postgres \ sh -c 'pg_dump --format=custom --no-owner --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' \ | docker run --rm -i -v "$backup_volume:/backups" postgres:16-alpine \ sh -c "cat > '/backups/$backup_path/postgres.dump'" artifact_volume="$(docker volume ls \ --filter "label=com.docker.compose.project=$ASSESSOR_STACK" \ --filter "label=com.docker.compose.volume=assessor-artifacts" \ --format '{{.Name}}')" if [ -z "$artifact_volume" ]; then echo "::error::Existing $ASSESSOR_STACK artifact volume was not found." exit 1 fi docker run --rm \ -v "$artifact_volume:/artifacts:ro" \ -v "$backup_volume:/backups" \ postgres:16-alpine \ tar -C /artifacts -czf "/backups/$backup_path/artifacts.tar.gz" . docker run --rm -v "$backup_volume:/backups" postgres:16-alpine \ sh -c "cd '/backups/$backup_path' && sha256sum postgres.dump artifacts.tar.gz > SHA256SUMS" echo "Environment=$ASSESSOR_ENVIRONMENT backup=created timestamp=$backup_stamp" - name: Deploy run: | docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml up -d --build --remove-orphans \ --wait --wait-timeout 180 published_port="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml port app 4173)" echo "Environment=$ASSESSOR_ENVIRONMENT stack=$ASSESSOR_STACK published=$published_port state=started" # A containerized Actions runner does not share the Docker host's loopback # namespace, so readiness is checked inside the application container. - name: Check application health run: | docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml exec -T app \ node -e "fetch('http://127.0.0.1:4173/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" echo "Environment=$ASSESSOR_ENVIRONMENT health=ok" - name: Show content-free diagnostics on failure if: failure() run: | docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps || true app_id="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps -a -q app 2>/dev/null || true)" if [ -n "$app_id" ]; then docker inspect --format 'app={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$app_id" || true fi - name: Release environment deployment lock if: always() run: | if [ "${ASSESSOR_LOCK_ACQUIRED:-false}" = "true" ]; then docker rm -f "${ASSESSOR_STACK}_deployment_lock" >/dev/null 2>&1 || true fi