Assessor_2.0/.gitea/workflows/deploy.yml

202 lines
8.9 KiB
YAML

# 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 and artifact
# volumes. 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; 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; 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) ;;
*)
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