Files
selfpost/docs/development.md
T
mixandClaude Opus 5.5 48832d223a
test / test (push) Canceled after 0s
release: 2.5.3: audit stage 3 - persistence and restore
Cut without asking, as the owner said on 2026-10-15: "Вопросы про резку не
задавай, просто режь."

A restore is ended by its Resync: the manifest stays until the first start
has regenerated the maps (M1). Every archive ends with data/contents.json and
the first start after a restore compares /data with it before it opens the
database: half an archive, a damaged file, a journal left beside the
database is refused, named. The entrypoint hands every map under
/data/postfix to the panel (a restore unpacked as root left the DMARC maps
unreadable - found by the review). Migration 0010: the send log's sweep,
refusal-line check, level-2 count and pages each by its index, pinned by
EXPLAIN tests on the statements the store runs (M2). The backup CLI refuses
a missing database (M3) and a password under 12 (M5), writes -o whole,
checks -decrypt's password before the output (L1), warns of what it leaves
out and refuses an empty certs/ (L2). An encrypted file may ask scrypt for
128 MiB at most, r <= 64, p <= 2, and nothing may follow its last chunk
(M4, L3). TRUSTED_PROXY_CIDR's bare ::ffff:a.b.c.d is one host (L4). Maps
are durable across a power loss: the directory is synced (L7). Commits
8e3241e, 508ac96, 242515c, 76b21b3.

Rolled out to the test server as 2.5.2-post.76b21b3 (the same code):
migration 0010 applied, schema 10, the four new indexes present; all
programs RUNNING, Postfix with its three milters, nothing in the logs. On
the live container: a CLI backup ends with data/contents.json, a wrong
SELFPOST_DB_PATH is refused and creates nothing, a short password is
refused.

CHANGELOG cut; pin 2.5.3 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 3 marked shipped in the plan. No
tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:44:08 +03:00

604 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SelfPost — development
**What this file is.** How to build, test, document, and ship changes. Open
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
[architecture.md](architecture.md).
---
## Resuming work
After `/clear` or a fresh chat:
1. Read this file (process, docs rules, model routing).
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
plan file for the active item. Accepted risks — [security.md](security.md);
as-built — [architecture.md](architecture.md).
3. Skim [product.md](product.md) if scope is in doubt.
4. Continue from the next unchecked step in the **active** plan file (not the
roadmap index).
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
not duplicated here.
---
## Authorship and disclosure
**Decided, not open for re-litigation.** SelfPost is written by AI agents under
a maintainer's direction, and the project says so rather than hiding it.
Concretely, this is what "says so" means, and none of it is an oversight to be
tidied away later:
- `Co-Authored-By: Claude <model>` trailers stay in commit messages, including
the ~140 commits that predate v1.0.
- The model routing table below is public, in a file the README links to.
- [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc) ships in
the repository.
- Process notes written for an agent — "after a context reset, pick an item
marked `agreed`" in [roadmap.md](roadmap.md) — stay as they are.
**Why not quietly drop it.** Once the trailers are in the history, removing the
routing table or the rules file would not conceal authorship, it would only
make the project look like it was trying to. Partial concealment reads worse
than the plain statement, and the plain statement costs nothing: the code is
reviewed, tested, and shipped under the same rules either way, and the
[security design](security.md) records what was audited and what was accepted.
**Revisit if:** the disclosure ever conflicts with the licence or a downstream
obligation — not because the convention around AI authorship shifts.
---
## Model routing
| Kind of work | Model | Examples |
|---|---|---|
| Security, infra, file permissions, Postfix/`postqueue`, open-relay risk | **Opus** | `mail.log` under `/data`, entrypoint permissions, queue reconcile |
| UI / JS / CSS, templates, documentation (English), README | **Sonnet** | adaptive polling, this file's Documentation section |
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile / release.yml comment fixes, deleting closed plan files |
| Security **review** (not authorship) | **Fable** | pre-release checklist (CHANGELOG `[0.5.0]` Security / [security.md](security.md) — done) |
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
mechanics → Haiku. Reviewers must not be the author of the code under review.
**Composer → Opus → Fable** (when a plan specifies it): **Composer** writes all
implementation for that item (code, tests, docs); **Opus** does the technical
review of the result; **Fable** does the security review. A plan's checklist
overrides the default routing table for that item only.
---
## Plan checklists
Open [roadmap.md](roadmap.md) items with a linked plan file carry an
**Implementation checklist** in [plans/](plans/). After a context reset, work
the **active** plan — not the roadmap prose — from the first unchecked line.
**Checklist format**
- `- [ ] Step description — **Model**` (model from the routing table above).
- Mark `[x]` only in the commit that actually finishes the step.
- `candidate` items: checklist may exist, but no code until the roadmap status
is agreed.
**Progress column** in the roadmap index (`0/N`) counts every checklist line in
that plan (including docs and `go vet` / `go test` steps). Update `N` when the
checklist changes; update the numerator when steps are checked off.
### Roadmap track: commit and version
For the current 1.x+ roadmap queue ([roadmap.md](roadmap.md) index), stricter
than the default «meaningful step» rule in [§ Commits and release build](#commits-and-release-build):
**After each checklist step**
1. Check off the step in the plan file; bump `Progress` in the roadmap index.
2. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]` for that step.
3. Commit on `main` (push / tag / Release only on explicit request).
**After each roadmap stage** (all checklist steps done; «Done when» satisfied)
1. Version cut in one commit: rename `[Unreleased]` to `[X.Y.Z] - date`, open a
fresh `[Unreleased]`, bump the image pin in
[deploy/docker-compose.yml](../deploy/docker-compose.yml).
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)).
**Planned version cuts** (from pin `2.5.3`; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
| 1 | queue-retries | `1.3.1` (PATCH) |
| 2 | inbound-relay | `1.4.0` |
| 3 | send-log-retention | `1.5.0` |
| 4 | domain-stats-auto-ratelimit | `1.6.0` |
| 5 | dmarc-reports | `1.7.0` |
| 6 | panel-docs | `1.8.0` |
| 7 | application auth IPs | `1.9.0` |
| 8 | port-25 Postfix startup fix | `1.9.1` (PATCH) |
| 9 | 2026-08-19 code review pass | `1.9.2` (PATCH) |
| 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) |
| 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) |
| 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) |
| 13 | panel-redesign | `2.0.0` (MAJOR, no compatibility) |
| 14 | DMARC ingest drops and bounds, full backup from the panel, no queue in a backup | `2.0.1` (PATCH) |
| 15 | inbound-antispam-panel | `2.1.0` (was `1.10.0`, reordered 2026-10-09) |
| 16 | delivery-log-storage | `2.2.0` |
| 17 | panel-notifications | `2.3.0` |
| 18 | from-header-binding | `2.4.0` |
| 19 | code audit 2026-10-12 and its plan (docs only, owner-requested cut) | `2.4.1` (PATCH) |
| 20 | audit-2026-10-12 stage 0: release hygiene | `2.4.2` (PATCH) |
| 21 | audit-2026-10-12 stages 1 and 2 | `2.5.0` |
| 22 | audit-2026-10-12 stage 2b: the log tailer | `2.5.1` |
| 23 | audit-2026-10-12 stage 2c: the sender check reads From: as OpenDKIM does | `2.5.2` |
| 24 | audit-2026-10-12 stage 3: persistence and restore | `2.5.3` |
| 25 | audit-2026-10-12 stage 4 | `2.5.4` |
| 26 | audit-2026-10-12 stage 5 | `2.6.0` |
Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships.
---
## 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): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) |
| **Container** | Docker + Compose v2 on the dev host and in CI |
| **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit |
| **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)). Copyright holder and
third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
`SPDX-License-Identifier` headers; AGPL-3.0 does not require them. 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 | <https://github.com/emersion/go-milter> | BSD-2-Clause |
| `golang.org/x/crypto` | v0.54.0 | <https://github.com/golang/crypto> | BSD-3-Clause |
| `modernc.org/sqlite` | v1.53.0 | <https://gitlab.com/cznic/sqlite> (mirror: <https://github.com/modernc-org/sqlite>) | BSD-3-Clause |
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
the tree are AGPL-3.0-compatible.
### Vendored front-end
| Asset | Version | Repository | License |
|---|---|---|---|
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
| `internal/web/view/static/ibm-plex-*.woff2` | latin subset | <https://github.com/IBM/plex> | SIL OFL 1.1 (`OFL.txt` beside the files) |
| `internal/web/view/static/bulma.min.css` | 1.0.4 | <https://github.com/jgthms/bulma> | MIT |
| `internal/web/view/static/tabler-icons.css` | 3.49.0 (57-icon subset) | <https://github.com/tabler/tabler-icons> | MIT |
| `internal/web/view/static/tabler-icons.woff2` | 3.49.0 (57-icon subset) | <https://github.com/tabler/tabler-icons> | MIT |
### E2e module (`test/e2e/go.mod`)
| Package | Version | Repository | License |
|---|---|---|---|
| `github.com/emersion/go-msgauth` | v0.6.8 | <https://github.com/emersion/go-msgauth> | 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 <https://packages.debian.org/bookworm/>.
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
the OFL text at `/static/OFL.txt`.
### Code that relies on another component's behaviour
The owner's rule (2026-10-14): what another program does is read in its
documentation or its source - never guessed. SelfPost reads Postfix's log
lines and reply codes, feeds OpenDKIM's tables, parses `postqueue`,
`postconf`, `supervisorctl` and `db_dump` output, talks the milter protocol
through go-milter, relies on SQLite's locking, on logrotate's and Docker's
behaviour. Every block of code that does so carries a comment that says
what it relies on and where that is written, for the version the image runs
(the Debian bookworm package, not upstream's head):
```go
// Postfix 3.7.11, src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): the SASL login is compared with
// each owner in sender_login_maps by strcasecmp_utf8.
```
The Debian sources of the image's versions are fetched with `apt-get source`
inside a `debian:bookworm` container (`postfix`, `opendkim`, `cyrus-sasl2`,
`supervisor`, `logrotate`, and `sendmail` for libmilter); `dev/upstream/`
(not in git) is where they are kept for reading. Go modules are read in the
module cache at the version `go.mod` pins.
The image takes `debian:bookworm-slim` and installs without pinning (pinned
Debian versions leave the mirrors), so a rebuild after a point or security
release runs packages the citations were not read against. The versions
read are these; the e2e stand compares them with `dpkg-query` in the image
it built (`checkCitedPackageVersions`), and a difference fails the run -
re-read what changed, update the citations, then this table:
<!-- cited-package-versions -->
| Package | Version read |
|---|---|
| `postfix` | `3.7.11-0+deb12u1` |
| `opendkim` | `2.11.0~beta2-8+deb12u1` |
| `libopendkim11` | `2.11.0~beta2-8+deb12u1` |
| `libmilter1.0.1` | `8.17.1.9-2+deb12u2` |
| `libsasl2-2` | `2.1.28+dfsg-10` |
| `sasl2-bin` | `2.1.28+dfsg-10` |
| `supervisor` | `4.2.5-1` |
| `logrotate` | `3.21.0-1` |
<!-- /cited-package-versions -->
A document instead of source: its URL and section. Where neither answers the
question, the comment says so and what was measured instead - "measured on
the e2e stand, 2026-10-14: ..." - and a measurement is never written as if
it were read. A test fixture copied from a real log says where and when it
was seen; one composed by hand says that it was composed. The heuristics
review of 2026-10-14 (audit-2026-10-12, stage 2c) brought the code that
existed then up to this rule.
---
## 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.2.3
```
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). Runtime config and scripts use `COPY
--chmod` so file modes in the image do not depend on how the build context was
synced (e.g. a Windows checkout widening permissions on `logrotate-mail.conf`).
See [architecture.md](architecture.md) § Image and processes.
---
## Commits and release build
Commit on every meaningful step (a working sub-feature, a green build, end of a
phase) — not every file save, and not only at phase end. Minimum: one commit per
closed phase, plus intermediate commits for coherent sub-steps. Branch `main`
unless a separate branch is requested. Push / PR only on explicit request.
Commit messages end with
`Co-Authored-By: Claude <model> <noreply@anthropic.com>` for the model that did
the step (e.g. `Claude Sonnet 4.6`).
Every such step also updates [CHANGELOG.md](../CHANGELOG.md) under
`[Unreleased]` (Keep a Changelog). On an explicit version cut, rename
`[Unreleased]` to `[X.Y.Z] - date` and open a fresh empty `[Unreleased]`. Image
tag / push only on explicit request (see `release.yml`).
### Release image
The release image is published **only** for a SemVer version `X.Y.Z`: a
**published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
that supplies that version. Pushing a git tag alone does not publish. Ordinary
commits, and a dispatch from `main` without a version input, do not publish.
The version is the single source that drives the image tag and `-ldflags` in
the binaries so they cannot drift apart. The e2e gate rebuilds the image it
then pushes, so `release.yml` hands it the version as `SELFPOST_E2E_VERSION`;
the suite's `image_stamped_with_version` and a check right before the push
both compare `panel -version` and `selfpost-backup -version` with it. (Until
`2.4.2` the rebuild stamped every published image `e2e`; those images stay as
they are.)
**Steps (on explicit request):**
1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) and bump the pinned
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
local-trial image references) in the **same** release commit. While no
image of the current line is published, README, guide § Fixed image tag
and the compose file's header say so and show the local build under the
pinned name; bump the version there too. The first publication of the
line removes those notes.
2. Create and push git tag `vX.Y.Z` on that commit.
3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`).
**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases)
page lists only **published** releases. A draft is visible to maintainers only —
it looks like “no releases” to everyone else. CI does not create or publish the
GitHub Release; you do that in the UI. Deleting a release’s git tag on GitHub
(or re-pushing tags while cleaning the registry) converts a published release
back into a **draft** — that matches “I published three times and it keeps
disappearing”. After publish, leave the tag on GitHub; clean up only unwanted
GHCR package versions, not the git tag.
Push workflow and source changes to **github.com/mixeme/selfpost** before
publishing — Actions reads that repo, not Gitea.
**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to
GitHub, two things follow:
1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups
prune remote tags that are absent on Gitea (or re-push with `--force` /
`--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published
Release back to draft. Mirror **branches and new tags forward**; do not
delete release tags on the GitHub side. GHCR cleanup is package versions in
the UI — not `git push github --delete` and not tag prune on the mirror.
2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0`
still points at a commit whose `release.yml` has `on: push: tags` and no
per-arch GHCR cleanup — every mirror (re)push of that tag can republish
`1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run
`release.yml` on **Publish release** (`release: published`), so mirroring
those tags alone does not start the image build.
Safe mirror: push tags to GitHub without deleting existing ones; keep release
tags on Gitea; publish the GitHub Release on github.com after the mirror has
the tag.
Ordinary commits **do not** publish an image. The compose pin and the git tag
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development history before that cut.
---
## Phase closure
Before `/clear` at the end of a finished step:
1. Update [roadmap.md](roadmap.md) (and the active plan checklist, if any): what
changed, what is next.
2. Check the applicable «Done when…» criteria.
3. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]`.
4. Make the final commit for the step (when the user asks for a commit).
---
## 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 'TestLoadConfigKeysDocumented|TestBuildScriptKeysDocumented|TestDocumentedKeysAreRead'`
— 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)).
### Panel pages: the component kit and its guards
Every panel page is built from the component kit in
[internal/web/view](../internal/web/view): partials in
`templates/components.html`, their typed input in `components.go`, one stylesheet
`static/panel.css`, one layout `layout.html`. A page template calls partials with
a struct from a `view.New…` constructor and writes no component markup of its
own ([architecture.md § Panel HTTP surface](architecture.md#panel-http-surface)).
The guard tests (`internal/web/guard_*_test.go`, `internal/web/view/guard_*_test.go`)
run in `go test ./...` and read their rules from the accepted mockups under
[docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html): which
classes and icons exist, what `panel.css` may contain, what component skeleton
each page has, which routes exist. A failing guard means the page is wrong, not
the guard.
Changing the design is mockup first: edit the fragment under `panel/src/`, run
`build.py` and `check.py --write`, commit as `design: …`, get it accepted, and
only then touch `internal/web`. CI enforces the order with `design-first`
([.github/scripts/design-first.sh](../.github/scripts/design-first.sh)): a commit
that changes the mockups, a guard or the script touches nothing else under
`internal/web/`, and a template that returns from git history is refused.
[plans/panel-redesign.md](plans/panel-redesign.md) § The contract and
§ Enforcement have the full rules.
### 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, and the image's
`VERSION` from `SELFPOST_E2E_VERSION` (unset locally: `e2e`). 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`); startup checks that supervisord actually brought up
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
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`
`design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
`go test ./...` (main module, no e2e; includes the panel guard tests, see
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)) → the e2e
module compiled, not run (`go vet ./...` and `go test -c -o /dev/null .` in
`test/e2e`).
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
Publishing a GitHub Release runs `release.yml` directly (`release: published`,
same pattern as gosentry / imap-scrub). You can also run it manually via
`workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD.
`prepare` takes the version from `github.event.release.tag_name` on a release
event, or from the `workflow_dispatch` `version` input. A dispatch whose input
is missing or not `X.Y.Z` fails in `prepare`.
```
release: published
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
→ docker build --load (VERSION from prepare)
→ e2e (test/e2e; rebuilds selfpost:e2e with SELFPOST_E2E_VERSION=X.Y.Z)
→ check panel / selfpost-backup -version = X.Y.Z
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge
→ docker buildx imagetools create → unified manifest X.Y.Z
→ GitHub Packages API → drop untagged versions no tagged manifest references
```
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. Each release leaves three tags in GHCR:
`X.Y.Z` (the multi-arch manifest operators pin) and `X.Y.Z-amd64` /
`X.Y.Z-arm64` (the images it points at). The per-arch tags must stay — GHCR
cannot drop a tag without deleting the image, and deleting it breaks pulls of
`X.Y.Z`. The last merge step removes only orphans: untagged versions left
behind when a version is published again.
A failed e2e **blocks** image publication.
---
## Documentation
History of deleted plans lives in git and [CHANGELOG.md](../CHANGELOG.md).
There is no `docs/archive/` directory.
### Documentation map
| Home | File |
|---|---|
| Operator install / quick start | [README.md](../README.md) |
| Operator guide | [guide.md](guide.md) |
| Product boundaries | [product.md](product.md) |
| As-built design | [architecture.md](architecture.md) |
| Development process (this file) | [development.md](development.md) |
| Security requirements and accepted risks | [security.md](security.md) |
| SQLite schema and migrations | [schema-migrations.md](schema-migrations.md) |
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
| Active design plans | [plans/](plans/) |
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
### User-facing deliverables
| Artefact | Role |
|---|---|
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
| [SECURITY.md](../SECURITY.md) | Private reporting channel, supported versions, scope |
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
| [NOTICE](../NOTICE) | Copyright holder and third-party attributions |
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
(candidates in [roadmap.md](roadmap.md)).
### Maintaining documentation
1. **Step rule:** a new or renamed env key, panel route, or observable mail-path
behaviour ships together with [guide.md](guide.md) / `.env.example` and a
CHANGELOG entry (see [§ Commits and release build](#commits-and-release-build)).
2. **Env regression:** [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)
fails on an undocumented `loadConfig` or build-script key.
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
silent drive-by edits.
4. **Schema changes:** update [schema-migrations.md](schema-migrations.md) in the
same change as a new `internal/store/migrations/*.sql` file.
### Verifying docs against code
Every claim in the docs has a **source of truth in the tree**; verify from code
to prose.
| Claim class | Source of truth |
|---|---|
| Env keys and defaults | `loadConfig` — [cmd/panel/main.go](../cmd/panel/main.go); `${VAR:-…}` in [build/](../build/) |
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) |
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
| Product / out of scope | [product.md](product.md) |
| As-built | [architecture.md](architecture.md) |
| Mandatory security | [security.md](security.md) |
Order: list what the code actually does → find it in [guide.md](guide.md) /
`architecture.md`. Before every tag, a short pass over this table — not a full
prose rewrite.