# Phase 36 Step 12 — build images, migrate, deploy on version tags. # Requires act_runner with docker, curl, ssh, scp, bash and label ubuntu-latest. # # Secrets: REGISTRY_USERNAME, REGISTRY_TOKEN, PG_CONNECTION_DDL, # DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY # Variables: PROD_API_URL, REGISTRY (optional; defaults below) name: CD on: push: tags: ["v*"] workflow_dispatch: inputs: image_tag: description: "Image tag to deploy (defaults to the pushed tag)" required: false env: REGISTRY: gitea.example.com/vigilcare jobs: build-and-push: runs-on: ubuntu-latest outputs: tag: ${{ steps.meta.outputs.tag }} steps: - uses: actions/checkout@v4 - name: Resolve tag and registry id: meta run: | if [ -n "${{ vars.REGISTRY }}" ]; then echo "REGISTRY=${{ vars.REGISTRY }}" >> "$GITHUB_ENV" fi if [ -n "${{ inputs.image_tag }}" ]; then echo "tag=${{ inputs.image_tag }}" >> "$GITHUB_OUTPUT" elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" else echo "image_tag input is required for workflow_dispatch without a tag" >&2 exit 1 fi - name: Log in to the Gitea registry run: | echo "${{ secrets.REGISTRY_TOKEN }}" \ | docker login "${REGISTRY%%/*}" \ -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin - name: Build and push clinical-api # Context MUST be repo root — ClinicalContracts is a sibling ProjectReference. run: | docker build -f VigilCareClinicalAPI/Dockerfile \ -t "${REGISTRY}/clinical-api:${{ steps.meta.outputs.tag }}" \ -t "${REGISTRY}/clinical-api:latest" . docker push "${REGISTRY}/clinical-api:${{ steps.meta.outputs.tag }}" docker push "${REGISTRY}/clinical-api:latest" - name: Build and push ward-gateway # Same repo-root context as docker-compose.yml's ward-gateway-api service. run: | docker build -f VigilCare.WardGateway/Dockerfile \ -t "${REGISTRY}/ward-gateway:${{ steps.meta.outputs.tag }}" \ -t "${REGISTRY}/ward-gateway:latest" . docker push "${REGISTRY}/ward-gateway:${{ steps.meta.outputs.tag }}" docker push "${REGISTRY}/ward-gateway:latest" - name: Build and push dashboard # Context is vigilcare-dashboard/ — package.json and nginx.conf live there. run: | docker build -f vigilcare-dashboard/Dockerfile \ --build-arg VITE_API_URL="${{ vars.PROD_API_URL }}" \ -t "${REGISTRY}/dashboard:${{ steps.meta.outputs.tag }}" \ -t "${REGISTRY}/dashboard:latest" vigilcare-dashboard docker push "${REGISTRY}/dashboard:${{ steps.meta.outputs.tag }}" docker push "${REGISTRY}/dashboard:latest" migrate: needs: build-and-push runs-on: ubuntu-latest # Checkout/setup-* are Node actions and must run on the job host (act_runner # default image). Do not set job-level container: — that replaced the host # with dotnet/sdk (no node) and broke checkout. Use docker run for the SDK # instead; the runner already needs Docker for build-and-push. steps: - uses: actions/checkout@v4 - name: Build migration bundle run: | docker run --rm \ -v "$PWD:/src" \ -w /src \ -e HOME=/tmp \ mcr.microsoft.com/dotnet/sdk:8.0 \ bash -euo pipefail -c ' dotnet tool install --global dotnet-ef --version 8.0.4 \ || dotnet tool update --global dotnet-ef --version 8.0.4 export PATH="$PATH:/tmp/.dotnet/tools" bash ./scripts/build-api-migration-bundle.sh ' cp -f ./artifacts/migrate-api ./migrate-api chmod +x ./migrate-api # Runs while the previous release is still serving traffic, so every # migration must be backwards-compatible with the outgoing image. # See Step 6 — expand-then-contract. # Self-contained linux-x64 binary — runs on the host, not inside the SDK image. - name: Apply migrations run: ./migrate-api --connection "${{ secrets.PG_CONNECTION_DDL }}" deploy: needs: [build-and-push, migrate] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Configure SSH run: | mkdir -p ~/.ssh echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts - name: Copy compose file run: | scp -i ~/.ssh/id_ed25519 docker-compose.prod.yml \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/vigilcare/docker-compose.prod.yml" - name: Deploy env: IMAGE_TAG: ${{ needs.build-and-push.outputs.tag }} run: | ssh -i ~/.ssh/id_ed25519 \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ IMAGE_TAG="$IMAGE_TAG" bash -euo pipefail <<'EOF' cd /opt/vigilcare # Record the currently deployed tag so a rollback has a target. grep '^IMAGE_TAG=' .env > .env.previous || true if grep -q '^IMAGE_TAG=' .env; then sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=${IMAGE_TAG}|" .env else echo "IMAGE_TAG=${IMAGE_TAG}" >> .env fi docker compose -f docker-compose.prod.yml --env-file .env pull docker compose -f docker-compose.prod.yml --env-file .env up -d --remove-orphans docker image prune -f EOF - name: Smoke test run: | ssh -i ~/.ssh/id_ed25519 \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" bash -euo pipefail <<'EOF' for i in $(seq 1 30); do if curl -fsS http://localhost:5270/health/ready >/dev/null; then echo "Ready check passed." curl -fsS http://localhost:5081/health/live >/dev/null && echo "Gateway live." curl -fsS http://localhost:8080/ >/dev/null && echo "Dashboard serving." exit 0 fi sleep 5 done echo "Ready check never passed — dumping API logs:" docker compose -f /opt/vigilcare/docker-compose.prod.yml --env-file /opt/vigilcare/.env logs --tail 100 api exit 1 EOF - name: Roll back on failure if: failure() run: | ssh -i ~/.ssh/id_ed25519 \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" bash -euo pipefail <<'EOF' cd /opt/vigilcare # Restores the previous image tag only. Schema changes are NOT # reverted — this is why migrations must be backwards-compatible. if [ -f .env.previous ]; then PREV=$(cut -d= -f2 .env.previous) sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=${PREV}|" .env docker compose -f docker-compose.prod.yml --env-file .env up -d echo "Rolled back to ${PREV}" fi EOF