diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 517c56d2..1a4b39c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56546acd..268c8971 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/Server/Dockerfile b/Server/Dockerfile index 3ad527b1..0db0489b 100644 --- a/Server/Dockerfile +++ b/Server/Dockerfile @@ -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 diff --git a/Server/scripts/docker-smoke.sh b/Server/scripts/docker-smoke.sh new file mode 100755 index 00000000..2344f74d --- /dev/null +++ b/Server/scripts/docker-smoke.sh @@ -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 +# +# 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 }" +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