# 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: 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: git.vectur45.com/trent/vigilcare-records 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 vigilcare-records-api run: | docker build -f VigilCareRecordsAPI/Dockerfile \ -t "${REGISTRY}/vigilcare-records-api:${{ steps.meta.outputs.tag }}" \ -t "${REGISTRY}/vigilcare-records-api:latest" . docker push "${REGISTRY}/vigilcare-records-api:${{ steps.meta.outputs.tag }}" docker push "${REGISTRY}/vigilcare-records-api:latest" - name: Build and push vigilcare-records-dashboard # Context is vigilcare-records-web/ — package.json and nginx.conf live there. run: | docker build -f vigilcare-records-web/Dockerfile \ -t "${REGISTRY}/vigilcare-records-dashboard:${{ steps.meta.outputs.tag }}" \ -t "${REGISTRY}/vigilcare-records-dashboard:latest" vigilcare-records-web docker push "${REGISTRY}/vigilcare-records-dashboard:${{ steps.meta.outputs.tag }}" docker push "${REGISTRY}/vigilcare-records-dashboard:latest" migrate: needs: build-and-push runs-on: ubuntu-latest # Checkout must run on the job host. Build the EF bundle via Dockerfile # --target migrate (context upload), not docker run -v — under act_runner # bind mounts resolve on the Docker host, not the job workspace. # Postgres lives on the deploy host's shared-services network and is not # reachable from act_runner, so the bundle is copied there and executed in # a one-shot container joined to that network (Host=postgres resolves). # NOTE: Program.cs also runs db.Database.MigrateAsync() on API startup, so # this job is a defense-in-depth pre-deploy step using a DDL-privileged # credential the API container never sees, not the only migration path. steps: - uses: actions/checkout@v4 - name: Build migration bundle run: | docker build -f VigilCareRecordsAPI/Dockerfile --target migrate \ -t vigilcare-records-migrate-bundle:local . cid=$(docker create vigilcare-records-migrate-bundle:local) docker cp "$cid:/out/migrate-api" ./migrate-api docker rm "$cid" chmod +x ./migrate-api - 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 # Runs while the previous release is still serving traffic, so every # migration must be backwards-compatible with the outgoing image # (expand-then-contract). - name: Apply migrations env: PG_CONNECTION_DDL: ${{ secrets.PG_CONNECTION_DDL }} run: | scp -i ~/.ssh/id_ed25519 ./migrate-api \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/vigilcare-records-migrate-api" ssh -i ~/.ssh/id_ed25519 \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ PG_CONNECTION_DDL="$PG_CONNECTION_DDL" bash -euo pipefail <<'EOF' chmod +x /tmp/vigilcare-records-migrate-api docker run --rm \ --network shared-services \ -v /tmp/vigilcare-records-migrate-api:/migrate-api:ro \ --entrypoint /migrate-api \ mcr.microsoft.com/dotnet/runtime-deps:8.0 \ --connection "$PG_CONNECTION_DDL" rm -f /tmp/vigilcare-records-migrate-api EOF 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-records/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-records # 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' cd /opt/vigilcare-records # Do not `source` .env — compose env files are not bash (semicolons, # spaces in "SSL Mode=...", CRLF). Read only the host ports we need. env_val() { sed -n "s/^${1}=//p" .env | tail -n1 | tr -d '\r'; } API_PORT="$(env_val API_PORT)"; API_PORT="${API_PORT:-5217}" DASHBOARD_PORT="$(env_val DASHBOARD_PORT)"; DASHBOARD_PORT="${DASHBOARD_PORT:-8089}" for i in $(seq 1 30); do if curl -fsS "http://localhost:${API_PORT}/health/ready" >/dev/null; then echo "Ready check passed." curl -fsS "http://localhost:${DASHBOARD_PORT}/" >/dev/null && echo "Dashboard serving." exit 0 fi sleep 5 done echo "Ready check never passed — dumping API logs:" docker compose -f docker-compose.prod.yml --env-file .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-records # 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