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
109 lines
4 KiB
YAML
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"
|