Reproducibility means two things here: the same commit builds the same image,
and the running container can be asked which commit it is.
- Base images are pinned by digest, not by tag. A tag moves; two builds of one
commit could otherwise differ. These are manifest-list digests, so buildx
still picks the right architecture.
- scripts/build-image.sh also writes ped-ai-local:<revision>, an immutable
name a deploy can refer to instead of chasing :latest. Its summary goes to
stderr so stdout stays the Compose invocation.
- Compose takes the image from PED_AI_IMAGE, so a deploy runs a specific
revision-tagged image while a local build still uses the local tag.
- scripts/deploy.sh pins that image in the file Compose interpolates from,
waits for health, then asks /api/build which revision is actually serving
and rolls back to the previous image if it does not match. Healthy is not
the same as running what you asked for. The rollback path was exercised.
- The entrypoint applies migrations before the app starts, so code and schema
arrive together. node-pg-migrate takes an advisory lock; losing it is not an
error, it waits and looks again, so a rolling restart does not fail. A real
migration failure stops the container rather than serving on a schema that
does not match the build. RUN_MIGRATIONS=false opts out.
- The Forgejo workflow builds through that same script, tags by full revision,
and has an opt-in deploy job. It refuses to run if the deploy directory has
uncommitted work rather than resetting over it.
The running image was labelled revision=unknown, and /api/build said "unknown",
because `docker compose up --build` never passes GIT_REVISION. That is exactly
the hole this closes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
98 lines
3.5 KiB
Bash
Executable file
98 lines
3.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Deploy one immutable, revision-tagged image and prove it landed.
|
|
#
|
|
# scripts/deploy.sh <image-ref> [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 <image-ref> [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:-<unreadable>}"
|
|
|
|
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:-<unreadable>}'" >&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
|