#!/usr/bin/env bash # Deploy one immutable, revision-tagged image and prove it landed. # # scripts/deploy.sh [expected-revision] # scripts/deploy.sh git.danvics.com/danvics/pediatric-ai-scribe-v3:fed4bd15 fed4bd15… # # The point is that a deploy is repeatable and checkable: the image is named by # digest or by revision tag, the container is asked afterwards which revision it # is actually running, and anything that does not line up is rolled back to the # image that was serving a moment ago. # # Schema migrations are applied by the container's own entrypoint before the app # starts, so code and schema arrive together and a failed migration stops the # container rather than serving on a schema that does not match. set -euo pipefail cd "$(dirname "$0")/.." IMAGE="${1:-}" EXPECTED_REVISION="${2:-}" SERVICE=pediatric-scribe CONTAINER=pediatric-ai-scribe HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3552}" if [ -z "$IMAGE" ]; then echo "usage: scripts/deploy.sh [expected-revision]" >&2 exit 2 fi # The image has to exist before anything is torn down. if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then echo "==> pulling $IMAGE" docker pull "$IMAGE" fi # What is serving right now, so there is something to go back to. PREVIOUS_IMAGE="$(docker inspect "$CONTAINER" --format '{{.Config.Image}}' 2>/dev/null || true)" if [ -n "$PREVIOUS_IMAGE" ]; then echo "==> currently running: $PREVIOUS_IMAGE" fi # Pin the image in the file Compose interpolates from, so a later plain # `docker compose up -d` brings up this same image rather than silently # reverting to the local build tag. Exactly one line is touched. pin_image() { local ref="$1" touch .env if grep -q '^PED_AI_IMAGE=' .env; then # A literal replacement: image refs contain / : @ and must not be # re-interpreted by sed's replacement syntax. grep -v '^PED_AI_IMAGE=' .env > .env.deploy-tmp printf 'PED_AI_IMAGE=%s\n' "$ref" >> .env.deploy-tmp mv .env.deploy-tmp .env else printf 'PED_AI_IMAGE=%s\n' "$ref" >> .env fi } rollback() { if [ -z "$PREVIOUS_IMAGE" ]; then echo "==> nothing to roll back to; leaving the stack as it is" >&2 return fi echo "==> rolling back to $PREVIOUS_IMAGE" >&2 pin_image "$PREVIOUS_IMAGE" PED_AI_IMAGE="$PREVIOUS_IMAGE" docker compose up -d --wait "$SERVICE" >&2 || true } echo "==> deploying $IMAGE" pin_image "$IMAGE" if ! PED_AI_IMAGE="$IMAGE" docker compose up -d --wait "$SERVICE"; then echo "==> the container did not become healthy" >&2 rollback exit 1 fi # Healthy is not the same as "running what was asked for". The revision is baked # into the image at build time and reported by /api/build, so this catches a # stale tag, a cached layer or a rollback that never took. RUNNING_REVISION="$(curl -fsS --max-time 10 "$HEALTH_URL/api/build" | sed -n 's/.*"buildId":"\([^"]*\)".*/\1/p' || true)" echo "==> running revision: ${RUNNING_REVISION:-}" if [ -n "$EXPECTED_REVISION" ]; then case "$RUNNING_REVISION" in "$EXPECTED_REVISION"*) echo "==> ✅ $IMAGE is serving $RUNNING_REVISION" ;; *) echo "==> FATAL: expected revision $EXPECTED_REVISION but the app reports '${RUNNING_REVISION:-}'" >&2 rollback exit 1 ;; esac elif [ -z "$RUNNING_REVISION" ] || [ "$RUNNING_REVISION" = "unknown" ]; then # Not fatal on its own — a local build has no revision — but it means this # deploy cannot be traced back to a commit. echo "==> WARNING: this image records no source revision, so what is running cannot be traced to a commit." >&2 fi