Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ name: ci
# - go — golangci-lint, go vet, go test (every package, race detector on)
# - frontend — TypeScript build + ESLint + vitest
# - build — `scripts/build.sh` smoke (proves the embed pipeline still works)
# - docker — builds the image and boots it on its DEFAULT CMD (proves
# `docker run <image>` works out of the box; the raw-binary smoke
# can't catch image-level regressions like a bad CMD)
# - docker — builds Dockerfile + Dockerfile.goreleaser and boots each
# on DEFAULT CMD (proves `docker run <image>` works out of the box;
# the raw-binary smoke can't catch image-level regressions like a
# bad CMD or a root-owned VOLUME mount point)
#
# OpenAPI drift is covered by the `go` job — internal/api/openapi_drift_test.go
# walks the live router and fails if anything's out of sync with the spec.
Expand Down Expand Up @@ -109,20 +110,23 @@ jobs:
kill "$PID"

docker:
# Builds the image and boots it with its DEFAULT CMD — exactly what
# `docker run mailtrap/mailtrap-local` does. The raw-binary
# Builds both images and boots each with its DEFAULT CMD — exactly
# what `docker run mailtrap/mailtrap-local` does. The raw-binary
# smoke above runs ./bin/mailtrap-local with explicit loopback flags,
# so it can't catch image-level regressions: a broken CMD, or the
# so it can't catch image-level regressions: a broken CMD, the
# loopback guard rejecting the image's 0.0.0.0 bind (which once
# crash-looped the published image on boot).
# crash-looped the published image on boot), or a VOLUME mount
# point that is root-owned so nonroot cannot create db.sqlite3.
# Dockerfile.goreleaser is what goreleaser publishes; the source
# Dockerfile alone does not cover that path.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: docker build
- name: docker build (Dockerfile)
run: docker build -t mailtrap-local:ci .

- name: boot on default CMD + curl
- name: boot Dockerfile image on default CMD + curl
run: |
docker run -d --name mtl -p 13550:3550 -p 13535:3535 mailtrap-local:ci
ok=
Expand All @@ -138,3 +142,29 @@ jobs:
fi
curl -sf http://127.0.0.1:13550/api/v1/openapi.yaml | head -1 | grep -q '^openapi:'
docker rm -f mtl

# Reuse the binary from the source image so we do not rebuild the
# frontend/Go stages just to exercise the release Dockerfile.
- name: docker build (Dockerfile.goreleaser)
run: |
cid=$(docker create mailtrap-local:ci)
docker cp "$cid:/usr/local/bin/mailtrap-local" ./mailtrap-local
docker rm "$cid"
docker build -f Dockerfile.goreleaser -t mailtrap-local:ci-goreleaser .

- name: boot goreleaser image on default CMD + curl
run: |
docker run -d --name mtl-gr -p 13551:3550 -p 13536:3535 mailtrap-local:ci-goreleaser
ok=
for _ in $(seq 1 20); do
sleep 0.5
if curl -sf http://127.0.0.1:13551/api/v1/messages >/dev/null; then ok=1; break; fi
done
docker logs mtl-gr
if [ -z "$ok" ]; then
echo "::error::goreleaser image did not serve the API on its default CMD"
docker rm -f mtl-gr
exit 1
fi
curl -sf http://127.0.0.1:13551/api/v1/openapi.yaml | head -1 | grep -q '^openapi:'
docker rm -f mtl-gr
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ RUN CGO_ENABLED=0 GOOS=linux \
go build -trimpath -ldflags="-s -w" \
-o /out/mailtrap-local ./cmd/mailtrap-local

# distroless lacks `mkdir`, so create the data dir owned by nonroot here
# and copy it in. UID/GID 65532 = `nonroot` in distroless images.
RUN mkdir -p /data-empty && chown -R 65532:65532 /data-empty
# distroless lacks `mkdir`, so create an empty data dir here and copy
# it in. COPY --chown=nonroot sets ownership (UID 65532).
RUN mkdir -p /data-empty

# ----- Stage 3: runtime ----------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
Expand Down
11 changes: 7 additions & 4 deletions Dockerfile.goreleaser
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
# goreleaser cross-compiles the binary itself (see .goreleaser.yaml's
# `builds:` section); this Dockerfile just wraps that binary in a
# distroless layer. The full source-build is in `Dockerfile`.
#
# distroless lacks `mkdir`, so seed an empty data dir from alpine.
# COPY --chown=nonroot sets ownership (UID 65532) for volume init.
FROM alpine:3.21 AS data
RUN mkdir -p /data-empty

FROM gcr.io/distroless/static-debian12:nonroot

# goreleaser puts the matching binary in the build context root.
COPY mailtrap-local /usr/local/bin/mailtrap-local

# Persistent storage volume. distroless has no `mkdir`, but the COPY
# above + the VOLUME directive together create the mount point on first
# `docker run` even though the directory is initially absent.
COPY --from=data --chown=nonroot:nonroot /data-empty /var/lib/mailtrap-local
VOLUME ["/var/lib/mailtrap-local"]

# Keep the at-rest encryption key on the persisted volume. By default the
Expand Down
Loading