fix(docker): ship /app owned by the runtime uid; run the boot-smoke in CI too (#1378)

* fix(release): give the Docker boot-smoke a writable /app, and run it in CI

The v1.2.0-alpha.3 release run died at "Boot-smoke Docker image": a bare
`docker run` of the distroless image has nowhere the uid-65532 server can
write — /app is root-owned, and the VOLUME /app/data anonymous volume is
created root-owned too — so config.Load failed on "writing default config:
open config.yaml: permission denied" and the container exited. Real
deployments bind-mount config.yaml and data/, which is why the image itself
is fine.

Move the smoke into Server/scripts/docker-smoke.sh, run the container with
`--tmpfs /app --tmpfs /app/data` (Docker's tmpfs default mode is 1777, so
the non-root server can write both), and call the same script from ci.yml's
docker-build job — loading the image it already builds — so the smoke is
exercised on every PR to main instead of for the first time at tag time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): ship /app and /app/data owned by the runtime uid so a bare run boots

The tmpfs approach did not survive CI: runc re-applies the underlying
directory's mode to a tmpfs mounted over an existing path, so /app stayed
root:755 and the write still failed. Fix the image instead of the harness:
stage /app/data in the builder, chown it to 65532, COPY --chown it into the
distroless stage before WORKDIR. Docker seeds the VOLUME's anonymous volume
from that image dir, ownership included, so `docker run <image>` with no
mounts now boots and answers /health — which is also the contract the smoke
should be testing, so it goes back to a bare `docker run`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-15 22:17:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fb4329dd94
commit fb04a579c4
4 changed files with 62 additions and 18 deletions
+8
View File
@@ -405,10 +405,18 @@ jobs:
with:
context: Server/
push: false
load: true
tags: owncord-smoke:candidate
build-args: VERSION=ci
cache-from: type=gha
cache-to: type=gha,mode=max
# Same script release.yml runs before it signs or pushes anything. A
# boot regression — or a bug in the smoke harness itself, as happened on
# the first v1.2.0-alpha.3 release run — must fail here, not at tag time.
- name: Boot-smoke Docker image
run: bash Server/scripts/docker-smoke.sh owncord-smoke:candidate
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier).
#
# Skipped for Dependabot: its PRs run under the separate `dependabot` secrets
+5 -18
View File
@@ -434,25 +434,12 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Shared with ci.yml's docker-build job so the smoke itself is exercised
# on every PR to main — the first alpha.3 release run died here on a
# smoke-harness bug (bare `docker run`, nowhere writable for the
# default config) that no pre-merge check had ever run.
- name: Boot-smoke Docker image
run: |
docker run -d --name owncord-smoke owncord-smoke:candidate
ok=0
for _ in $(seq 1 30); do
sleep 1
if [ "$(docker inspect -f '{{.State.Running}}' owncord-smoke)" != "true" ]; then
echo "::error::container exited during boot smoke"
docker logs owncord-smoke
exit 1
fi
if docker exec owncord-smoke /chatserver healthcheck; then ok=1; break; fi
done
docker logs owncord-smoke
docker rm -f owncord-smoke
if [ "$ok" != "1" ]; then
echo "::error::container never reported healthy within 30s"
exit 1
fi
run: bash Server/scripts/docker-smoke.sh owncord-smoke:candidate
- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+10
View File
@@ -15,11 +15,21 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-s -w -X main.version=${VERSION}" \
.
# Skeleton for the runtime image's working dir. The server writes its default
# config.yaml to the cwd and the SQLite DB, cert and uploads under data/, and
# it runs as uid 65532 — so both must be owned by that uid or a bare
# `docker run` dies on boot with "writing default config: permission denied"
# (a WORKDIR created by the final stage is root-owned, and Docker seeds the
# VOLUME's anonymous volume from the image dir, ownership included). Staged
# here because distroless has no shell to chown with.
RUN mkdir -p /skel/app/data && chown -R 65532:65532 /skel/app
# ─── Final stage ─────────────────────────────────────────────────────────────
# gcr.io/distroless/static-debian12: no shell, includes CA certs (needed for
# TLS/ACME) and timezone data. Attack surface is minimal.
FROM gcr.io/distroless/static-debian12
COPY --from=builder --chown=65532:65532 /skel/app /app
WORKDIR /app
COPY --from=builder /chatserver /chatserver
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Boot-smoke a freshly built server image: start it, wait for
# `/chatserver healthcheck` to pass, print the logs, tear it down.
#
# Used by both ci.yml (every PR to main) and release.yml (before anything is
# signed or pushed), so a boot regression is caught pre-merge instead of at
# tag time. Usage: docker-smoke.sh <image>
#
# Deliberately a bare `docker run` — no mounts, no env, no config. That is
# the contract being tested: the image boots on its own, as uid 65532, and
# writes its default config.yaml and data/ into the /app skeleton the
# Dockerfile ships owned by that uid. The first v1.2.0-alpha.3 release run
# died exactly here ("writing default config: permission denied") when /app
# was still root-owned; adding mounts to the smoke would only hide a repeat.
set -euo pipefail
image="${1:?usage: docker-smoke.sh <image>}"
name="owncord-smoke-$$"
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker run -d --name "$name" "$image" >/dev/null
ok=0
for _ in $(seq 1 30); do
sleep 1
if [ "$(docker inspect -f '{{.State.Running}}' "$name")" != "true" ]; then
echo "::error::container exited during boot smoke"
docker logs "$name"
exit 1
fi
if docker exec "$name" /chatserver healthcheck; then ok=1; break; fi
done
docker logs "$name"
if [ "$ok" != "1" ]; then
echo "::error::container never reported healthy within 30s"
exit 1
fi