pediatric-ai-scribe-v3/.forgejo/workflows/docker-build.yml
Daniel 8d0dc968b3 feat: a deploy you can repeat, and prove afterwards
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
2026-09-11 00:41:11 +02:00

109 lines
4 KiB
YAML

name: Forgejo Docker Build
on:
push:
branches: [main]
workflow_dispatch:
inputs:
push_image:
description: Push image to Forgejo container registry
required: false
default: 'true'
deploy:
description: Deploy the built image to the host after pushing
required: false
default: 'false'
jobs:
root-test:
name: Root app tests
runs-on: forgejo-local
steps:
- uses: actions/checkout@v4
- uses: https://github.com/actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm test
build:
needs: root-test
name: Build Docker image
runs-on: forgejo-local
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare compose env files
run: |
touch .env
- name: Validate Compose config
run: docker compose -f docker-compose.yml config >/tmp/ped-ai-compose.yml
# The same script a person runs locally, so a CI image and a hand-built
# one cannot drift. It validates the revision and bakes it into the image,
# which is what makes /api/build able to say what is running.
- name: Build compose service
run: ./scripts/build-image.sh
- name: Tag image by revision
run: |
IMAGE="git.danvics.com/danvics/pediatric-ai-scribe-v3"
REVISION=$(git rev-parse HEAD)
# The full revision is the immutable name; :latest is only a pointer.
docker tag ped-ai-local:latest "$IMAGE:$REVISION"
docker tag ped-ai-local:latest "$IMAGE:latest"
- name: Push image to Forgejo registry
if: ${{ github.event.inputs.push_image != 'false' }}
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
IMAGE="git.danvics.com/danvics/pediatric-ai-scribe-v3"
REVISION=$(git rev-parse HEAD)
echo "$FORGEJO_TOKEN" | docker login git.danvics.com -u danvics --password-stdin
docker push "$IMAGE:$REVISION"
docker push "$IMAGE:latest"
# ── Deploy ──────────────────────────────────────────────────────────────
# Opt-in, because the deploy directory is also a working tree: this refuses to
# run if it has uncommitted changes rather than resetting over someone's work.
#
# It moves that checkout to the built revision first, so the Compose file and
# the entrypoint that ship with the image are the ones used to run it, then
# hands over to scripts/deploy.sh, which pins the image, waits for health,
# asks /api/build what is actually running and rolls back if it disagrees.
# Schema migrations are applied by the container's own entrypoint.
deploy:
needs: build
name: Deploy to the host
runs-on: forgejo-local
if: ${{ github.event.inputs.deploy == 'true' }}
env:
DEPLOY_DIR: ${{ vars.DEPLOY_DIR || '/home/danvics/docker/ped-ai' }}
steps:
- name: Refuse to deploy over uncommitted work
run: |
if [ -n "$(git -C "$DEPLOY_DIR" status --porcelain)" ]; then
echo "$DEPLOY_DIR has uncommitted changes; commit or stash them first." >&2
git -C "$DEPLOY_DIR" status --short >&2
exit 1
fi
# Detaches HEAD at the deployed revision, which is what you want a
# deployed tree to be. If DEPLOY_DIR is also where you write code, point
# it at a checkout of its own instead — vars.DEPLOY_DIR.
- name: Move the deploy checkout to this revision
run: |
REVISION="${{ github.sha }}"
git -C "$DEPLOY_DIR" fetch --quiet --all
git -C "$DEPLOY_DIR" checkout --quiet --detach "$REVISION"
- name: Deploy and verify
run: |
IMAGE="git.danvics.com/danvics/pediatric-ai-scribe-v3"
REVISION="${{ github.sha }}"
"$DEPLOY_DIR/scripts/deploy.sh" "$IMAGE:$REVISION" "$REVISION"