202 lines
8.9 KiB
YAML
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
|