docs: deploy.sh is the deploy path, and it was documented nowhere
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m22s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s

scripts/deploy.sh landed on 2026-09-11 as "a deploy you can repeat, and prove
afterwards". It moves the PED_AI_IMAGE pin, waits for health, reads /api/build
to find out which revision is actually serving, and rolls back when that is not
the revision asked for. Its own comment says it: "Healthy is not the same as
running what was asked for."

No doc mentioned it. Both deployment.md and DEVELOPMENT.md instead showed
`build-image.sh` then `docker compose up -d --no-build` then `curl /api/health`
— a sequence that moves no pin and proves no revision. Following it deployed
nothing four times in one session, eventually reverting the app by 31 commits
and removing a feature, which was then reported as a bug.

Both docs now lead with deploy.sh and say why `up` by hand is not a deploy.

Also replaced yesterday's build-image.sh warning, which told the operator to
`sed` the pin themselves. That reimplemented, badly, one of the three things
deploy.sh already does — and it was written while auditing these very files
without noticing the script was there. It now prints the deploy.sh line to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 06:04:13 +02:00
parent 739286b53b
commit c9b6d6b3c9
3 changed files with 59 additions and 42 deletions

View file

@ -41,18 +41,36 @@ cd pediatric-ai-scribe-v3
cp .env.example .env cp .env.example .env
# edit .env — required: APP_URL, JWT_SECRET, DATA_ENCRYPTION_KEY, DB_PASSWORD, an AI provider # edit .env — required: APP_URL, JWT_SECRET, DATA_ENCRYPTION_KEY, DB_PASSWORD, an AI provider
./scripts/build-image.sh ./scripts/build-image.sh
sed -i "s|^PED_AI_IMAGE=.*|PED_AI_IMAGE=ped-ai-local:$(git rev-parse HEAD)|" .env REV=$(git rev-parse HEAD)
docker compose up -d --no-build scripts/deploy.sh "ped-ai-local:$REV" "$REV"
curl -fsS http://127.0.0.1:3552/api/build # must equal `git rev-parse HEAD`
``` ```
**Building does not change what `up` starts.** `docker compose` takes the image **Use `scripts/deploy.sh`. Do not run `docker compose up` by hand.**
from `PED_AI_IMAGE` in `.env`, and `build-image.sh` deliberately does not move
that pin — naming a revision is also how a rollback is done. A pin left from an Building is not deploying. `docker compose` takes its image from
earlier deploy therefore starts the older image, and nothing short of `PED_AI_IMAGE` in `.env`, and `build-image.sh` does not move that pin — naming a
`/api/build` shows it: the build succeeds, `up` reports the container started, revision is also how a rollback is done. So a pin left behind by an earlier
and `/api/health` returns `{ok:true}` from the wrong revision. `build-image.sh` deploy starts *that* image, and every signal still reports success: the build
warns when the pin does not match what it just built. completes, `up` says the container started, and `/api/health` returns
`{ok:true}` from the wrong revision. This has happened: a stale pin silently
reverted the app by 31 commits, removing a feature, and the missing feature was
reported as a new bug.
`scripts/deploy.sh <image-ref> [expected-revision]` is what closes that gap:
1. pulls the image if it is not local, and refuses to tear anything down until
it exists;
2. records what is serving now, so there is something to go back to;
3. moves the `PED_AI_IMAGE` pin, so a later plain `docker compose up` brings up
the same image rather than reverting;
4. waits for the container to become healthy;
5. asks `/api/build` which revision is *actually* serving and compares it to the
expected one — catching a stale tag, a cached layer, or a rollback that never
took;
6. rolls back to the previous image if either check fails.
`build-image.sh` prints the exact `deploy.sh` line to run whenever the pin does
not match the revision it just built.
The build uses Node 24 LTS and `npm ci --omit=dev` from the root lockfile. The build uses Node 24 LTS and `npm ci --omit=dev` from the root lockfile.
`./scripts/build-image.sh` resolves the full checkout Git commit (including `./scripts/build-image.sh` resolves the full checkout Git commit (including

View file

@ -451,29 +451,28 @@ If a dynamic value enters an HTML string, escape it at the point of insertion. I
## Deployment checks ## Deployment checks
After deployment, check *which revision* is running, not only that something is: Deploy with `scripts/deploy.sh`, never with `docker compose up` by hand:
```bash
./scripts/build-image.sh
REV=$(git rev-parse HEAD)
scripts/deploy.sh "ped-ai-local:$REV" "$REV"
```
`deploy.sh` moves the `PED_AI_IMAGE` pin in `.env`, waits for health, then reads
`/api/build` and rolls back if the container came up on a different revision.
Running `up` by hand does none of that: `/api/health` passing proves a container
is up, not that it is the one you built, and a pin left from an earlier deploy
will happily start an older image while everything looks fine. See
[`deployment.md`](deployment.md) for the full sequence.
To check by hand what is serving:
```bash ```bash
curl -fsS http://127.0.0.1:3552/api/build # must equal `git rev-parse HEAD` curl -fsS http://127.0.0.1:3552/api/build # must equal `git rev-parse HEAD`
curl -fsS http://127.0.0.1:3552/api/health
docker compose ps pediatric-scribe docker compose ps pediatric-scribe
``` ```
`/api/health` passing proves a container is up, not that it is the one you
built. `docker compose up` starts whatever `PED_AI_IMAGE` in `.env` names, and
that pin does not move when you build — so a pin left from an earlier deploy
silently starts the older image, and every signal short of `/api/build` looks
fine. `scripts/build-image.sh` warns when the pin does not match the revision it
just built; the fix is to update the pin:
```bash
sed -i "s|^PED_AI_IMAGE=.*|PED_AI_IMAGE=ped-ai-local:$(git rev-parse HEAD)|" .env
docker compose up -d --no-build
```
The pin is not updated for you, because naming a revision is also how a
deliberate rollback is done.
If the browser still shows old frontend behavior after the revision checks out, If the browser still shows old frontend behavior after the revision checks out,
force-refresh or check the injected `BUILD_ID` asset query string. force-refresh or check the injected `BUILD_ID` asset query string.

View file

@ -38,15 +38,16 @@ else
echo 'Built ped-ai-local:latest with no recorded revision' >&2 echo 'Built ped-ai-local:latest with no recorded revision' >&2
fi fi
# A deploy runs `docker compose up`, which reads PED_AI_IMAGE from .env — not # Building is not deploying. `docker compose up` takes its image from
# the tag just built. A pin left behind from an earlier deploy therefore starts # PED_AI_IMAGE in .env, which this script does not move, so a pin left behind by
# that older image, and `up` reports success either way: the build looks done, # an earlier deploy starts that older image while every signal reports success:
# the health check passes, and the running app is silently an older revision. # the build completes, `up` says the container started, and /api/health returns
# That has already cost a session — it rolled the app back far enough to remove # {ok:true} from the wrong revision.
# a feature, and the missing feature was read as a new bug.
# #
# Not corrected automatically: the pin is how a deploy names a revision on # scripts/deploy.sh is the answer to this and has been since it landed: it moves
# purpose, including a deliberate rollback. Said loudly instead. # the pin, waits for health, then asks /api/build which revision is actually
# serving and rolls back if it is not the one requested. Running `up` by hand
# skips all three. This points at it rather than repeating half of it.
if [ "$GIT_REVISION" != unknown ] && [ -f .env ]; then if [ "$GIT_REVISION" != unknown ] && [ -f .env ]; then
PINNED=$(sed -n 's/^PED_AI_IMAGE=//p' .env | tail -1) PINNED=$(sed -n 's/^PED_AI_IMAGE=//p' .env | tail -1)
case "$PINNED" in case "$PINNED" in
@ -54,15 +55,14 @@ if [ "$GIT_REVISION" != unknown ] && [ -f .env ]; then
*"$GIT_REVISION") ;; *"$GIT_REVISION") ;;
*) *)
echo >&2 echo >&2
echo "WARNING: .env pins PED_AI_IMAGE=$PINNED" >&2 echo "NOTE: .env still pins PED_AI_IMAGE=$PINNED" >&2
echo " which is NOT the image just built." >&2 echo " so 'docker compose up' would start that image, not this build." >&2
echo " 'docker compose up -d --no-build' will start the pinned image," >&2
echo " not this build. To deploy what was just built:" >&2
echo >&2 echo >&2
echo " sed -i 's|^PED_AI_IMAGE=.*|PED_AI_IMAGE=ped-ai-local:$GIT_REVISION|' .env" >&2 echo " Deploy this build with:" >&2
echo " scripts/deploy.sh ped-ai-local:$GIT_REVISION $GIT_REVISION" >&2
echo >&2 echo >&2
echo " Then confirm after starting it:" >&2 echo " which moves the pin and then verifies /api/build, rolling back" >&2
echo " curl -fsS http://127.0.0.1:3552/api/build" >&2 echo " if the container comes up on a different revision." >&2
echo >&2 echo >&2
;; ;;
esac esac