# SelfPost — development **What this file is.** How to build, test, and ship changes. Current sprint state lives in [progress.md](progress.md) — read that first after `/clear`. Product boundaries: [product.md](product.md). As-built layout: [architecture.md](architecture.md). --- ## Technology stack and tools | Component | Version / notes | |---|---| | **Go** | 1.26+ (`go.mod`); `CGO_ENABLED=0` — pure Go, static linking | | **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) | | **Build** | [Makefile](../Makefile): `vet`, `test`, `build`, `e2e` | | **Container** | Docker + Compose v2 on the dev host and in CI | | **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile) | | **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate | | **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) | | **Image registry** | `ghcr.io/mixeme/selfpost` | **Repository layout** (brief; process details in [architecture.md](architecture.md)): - `cmd/panel` — HTTP panel + journal-milter + log-tailer - `cmd/selfpost-backup` — backup CLI (`docker exec … selfpost-backup`) - `internal/` — domain logic, store, web, health - `build/` — Dockerfile, supervisord, Postfix/OpenDKIM, entrypoint - `deploy/` — `docker-compose.yml`, proxy examples, `.env.example` - `test/e2e/` — **separate Go module**; container integration tests --- ## External libraries The project is **AGPL-3.0** ([LICENSE](../LICENSE)). New Go dependencies must be permissive or GPL-family (see [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). ### Main module (`go.mod`) | Package | Version | Repository | License | |---|---|---|---| | `github.com/emersion/go-milter` | v0.4.1 | | BSD-2-Clause | | `golang.org/x/crypto` | v0.54.0 | | BSD-3-Clause | | `modernc.org/sqlite` | v1.53.0 | (mirror: ) | BSD-3-Clause | Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in the tree are AGPL-3.0-compatible. ### E2e module (`test/e2e/go.mod`) | Package | Version | Repository | License | |---|---|---|---| | `github.com/emersion/go-msgauth` | v0.6.8 | | BSD-2-Clause | The test module is not part of the main `go build` graph and is not shipped in the image. ### Debian packages in the runtime image Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come from Debian bookworm repositories; licenses are in each package's `copyright` file on . --- ## Building binaries and the image ### Local binaries Requires Go 1.26+ and `CGO_ENABLED=0`. ```sh make build # bin/panel, bin/selfpost-backup (VERSION=dev by default) make build VERSION=1.0.0 ``` Or directly: ```sh go build -trimpath -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel ``` The version is stamped into both binaries via `-ldflags` and **must match the Docker image tag** — restore checks backup version compatibility. ### Docker image From the repository root: ```sh docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . ``` The Dockerfile has a build stage (`go vet`, `go build` with `VERSION`) and a runtime stage (Debian + mail stack). See [architecture.md](architecture.md) § Image and processes. --- ## Release build The release image is published **only on tag** `vX.Y.Z` (not on every push to `main`). The tag is the single source of version: it drives the image tag and `-ldflags` in the binaries so they cannot drift apart. **Steps (on explicit request):** 1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md). 2. Create and push git tag `vX.Y.Z`. 3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates, and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`. 4. Update the pinned tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (see [roadmap.md](roadmap.md) § «v1.x — documentation and deploy tail»). Ordinary commits **do not** publish an image. --- ## Testing ### Static analysis and unit tests Main module (`go test ./...`); e2e is a separate module — see below. ```sh make vet # go vet ./... make test # go test ./... ``` Or directly: ```sh gofmt -l . # in CI — fails on drift go vet ./... go test ./... ``` ### Env documentation regression `go test ./cmd/panel -run TestLoadConfig` — every new `loadConfig` key must appear in the env lists in [guide.md](guide.md) ([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)). ### End-to-end (container suite) Separate Go module `test/e2e/`; **not** included in the main module's `go test ./...`. ```sh make e2e # same as: cd test/e2e && go test -v -timeout 20m ./... ``` **Stack:** [deploy/docker-compose.yml](../deploy/docker-compose.yml) + [test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same `cap_drop`/`cap_add`/`no-new-privileges` as production. Override: high ports (`20465`/`20587`/`20080`), test hostname, self-signed TLS, `PANEL_COOKIE_SECURE=false`, isolated compose project. Mail is hermetic: CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from the panel and published into the zone — the test verifies the records an operator would actually use. **Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify → send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch, L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives `docker restart`). Polling with timeouts only — no fixed `sleep`. Requires **Docker + Compose v2** on the machine running the suite. --- ## CI Workflows in [.github/workflows/](../.github/workflows/). What each job runs — [§ Testing](#testing) above. ### `test.yml` — every push and PR to `main` `gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e). ### `release.yml` — push of tag `vX.Y.Z` or `workflow_dispatch` ``` prepare (version from tag) → build [matrix: ubuntu-latest / ubuntu-24.04-arm] → docker build --load (VERSION from tag) → e2e (test/e2e) → push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64 → merge → docker buildx imagetools create → unified manifest X.Y.Z ``` Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under emulation for e2e is impractical. E2e first, then push — the registry receives the bytes that passed the gate. A failed e2e **blocks** image publication.