ci: use idiomatic Dagger service bindings for Stalwart

Refactor the CI pipeline to use WithServiceBinding for the Stalwart mail
server, replacing legacy shell scripts and manual port management.
Introduces pre-seeded data for the Stalwart service to avoid network
hits and improves headless UI testing with Xvfb.
This commit is contained in:
Gemini CLI
2026-05-17 16:01:42 +02:00
parent e6fc65a345
commit 8cbe8c01bb
5 changed files with 131 additions and 66 deletions
+34 -4
View File
@@ -1,13 +1,23 @@
# Dagger context ignore file.
# Since we use explicit inclusion in ci/main.go (Base function),
# we only need to ignore large or sensitive directories here to
# avoid unnecessary upload overhead to the Dagger engine.
# Version control
.git/
.gitmodules
# Build artifacts and tools
build/
.dart_tool/
.fvm/
.pub-cache/
.dart-cli-completion/
.dartServer/
.direnv/
.dotnet/
.rustup/
.task/
.vscode/
.vscode-server/
coverage/
node_modules/
ios/Pods/
macos/Pods/
@@ -15,6 +25,26 @@ linux/flutter/ephemeral/
website/public/
website/resources/
# Sensitive files
# Sensitive or system files
.env*
.ssh/
.local/
.cache/
.config/
.atuin/
.gemini/
.bash*
.profile
.zsh*
.zcompdump
.tmux.conf
.lesshst
.metadata
.emulator_console_auth_token
snap/
.gitconfig
.flutter-plugins-dependencies
.flutter
.forgejo
.fvmrc
.envrc
+15 -12
View File
@@ -172,22 +172,25 @@ tasks:
- fvm flutter test
test-backend:
desc: Backend tests against a local Stalwart mail server
deps: [_flutter-check]
sources:
- lib/**/*.dart
- test/backend/**/*.dart
desc: Backend tests against a local Stalwart mail server (via Dagger)
cmds:
- stalwart-dev/test.sh
- dagger call -m ci test-backend --source .
integration-ui:
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
deps: [_preflight, _linux-deps-check, _pub-get]
sources:
- lib/**/*.dart
- integration_test/app_e2e_test.dart
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
cmds:
- stalwart-dev/integration_ui_test.sh
- dagger call -m ci test-integration --source .
sync-reliability:
desc: Run sync reliability runner (via Dagger)
cmds:
- dagger call -m ci test-sync-reliability --source .
stalwart:
desc: Start a Stalwart instance for local development (via Dagger)
cmds:
- echo "Starting Stalwart on default ports (JMAP=8080, IMAP=1430, SMTP=1025, SIEVE=4190)"
- dagger call -m ci stalwart up --ports 8080:8080 --ports 1430:1430 --ports 1025:1025 --ports 4190:4190
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
+58 -24
View File
@@ -41,7 +41,7 @@ func (m *Ci) Base() *dagger.Container {
return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6").
WithExec([]string{"apt-get", "update"}).
WithExec([]string{"apt-get", "install", "-y", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "curl", "python3", "iproute2", "netcat-openbsd"}).
WithExec([]string{"apt-get", "install", "-y", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "curl", "python3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libgles2-mesa", "libegl1"}).
WithMountedCache("/root/.pub-cache", dag.CacheVolume("flutter-pub-cache")).
WithMountedCache("/root/.gradle", dag.CacheVolume("gradle-cache")).
WithEnvVariable("PUB_CACHE", "/root/.pub-cache").
@@ -75,15 +75,35 @@ func (m *Ci) Stalwart() *dagger.Service {
return dag.Container().
From("stalwartlabs/stalwart:v0.14.1").
WithFile("/etc/stalwart/config.toml", config).
// Create data dir in /tmp where permissions are usually more relaxed.
// Pre-seed data directory and spam-filter version to avoid network hits on startup.
WithExec([]string{"/bin/sh", "-c", "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart"}).
WithExec([]string{"sqlite3", "/tmp/stalwart/data.sqlite", "CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL); INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');"}).
WithExposedPort(8080). // JMAP
WithExposedPort(1430). // IMAP
WithExposedPort(1025). // SMTP
WithExposedPort(4190). // ManageSieve
// Explicitly run the binary with the config path to avoid bootstrap mode.
WithEntrypoint([]string{"stalwart", "--config", "/etc/stalwart/config.toml"}).
AsService()
}
// Helper to bind Stalwart service and set up environment variables for tests
func (m *Ci) WithStalwart(container *dagger.Container) *dagger.Container {
stalwart := m.Stalwart()
return container.
WithServiceBinding("stalwart", stalwart).
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
WithEnvVariable("STALWART_IMAP_PORT", "1430").
WithEnvVariable("STALWART_SMTP_PORT", "1025").
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
WithEnvVariable("STALWART_USER_B", "alice@example.com").
WithEnvVariable("STALWART_PASS_B", "secret").
WithEnvVariable("STALWART_USER_C", "bob@example.com").
WithEnvVariable("STALWART_PASS_C", "secret")
}
// Setup environment: pub get and build_runner
func (m *Ci) Setup() *dagger.Container {
return m.Base().
@@ -129,15 +149,37 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
// Run coverage check
func (m *Ci) Coverage(ctx context.Context) (string, error) {
return m.Setup().
WithExec([]string{"flutter", "test", "test/unit", "--coverage"}).
WithExec([]string{"flutter", "test", "test/unit", "--coverage", "--reporter", "expanded"}).
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
Stdout(ctx)
}
// Run backend tests (IMAP/JMAP sync logic)
func (m *Ci) TestBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.Setup()).
WithExec([]string{"flutter", "test", "--concurrency=1", "--reporter", "expanded", "test/backend"}).
Stdout(ctx)
}
// Run UI integration tests via Xvfb
func (m *Ci) TestIntegration(ctx context.Context) (string, error) {
return m.WithStalwart(m.Setup()).
// Use xvfb-run for simpler X11 management.
// LIBGL_ALWAYS_SOFTWARE=1 ensures software rendering for Flutter in headless environments.
WithEnvVariable("LIBGL_ALWAYS_SOFTWARE", "1").
WithExec([]string{"xvfb-run", "-s", "-screen 0 1280x720x24", "flutter", "test", "integration_test/", "-d", "linux"}).
Stdout(ctx)
}
// Run sync reliability runner
func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
return m.WithStalwart(m.Setup()).
WithExec([]string{"flutter", "test", "test/backend/sync_reliability_test.dart", "--reporter", "expanded", "--concurrency=1"}).
Stdout(ctx)
}
// Full check suite (equivalent to task check)
func (m *Ci) Check(ctx context.Context) (string, error) {
setup := m.Setup()
// Hygiene & Layers
if _, err := m.CheckHygiene(ctx); err != nil {
return "Hygiene check failed", err
@@ -146,6 +188,8 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return "Layer check failed", err
}
setup := m.Setup()
// Format (Running after Setup/pub get ensures package resolution context)
if _, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
return "Format check failed", err
@@ -163,29 +207,19 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return coverage, err
}
// Run backend tests (requires Stalwart Service)
stalwart := m.Stalwart()
testBackend, err := setup.
WithServiceBinding("stalwart", stalwart).
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
WithEnvVariable("STALWART_IMAP_PORT", "1430").
WithEnvVariable("STALWART_SMTP_PORT", "1025").
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
WithEnvVariable("STALWART_USER_B", "alice@example.com").
WithEnvVariable("STALWART_PASS_B", "secret").
WithEnvVariable("STALWART_USER_C", "bob@example.com").
WithEnvVariable("STALWART_PASS_C", "secret").
// Wait for Stalwart to be ready before running tests.
WithExec([]string{"/bin/bash", "-c", "for i in {1..30}; do nc -z stalwart 1430 && echo 'Stalwart is ready' && break; echo 'Waiting for Stalwart...'; sleep 1; done"}).
WithExec([]string{"flutter", "test", "--concurrency=1", "test/backend"}).
Stdout(ctx)
// Run backend tests
testBackend, err := m.TestBackend(ctx)
if err != nil {
return testBackend, err
}
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\nBackend Tests:\n%s\n", analyze, coverage, testBackend), nil
// Run UI integration tests
testIntegration, err := m.TestIntegration(ctx)
if err != nil {
return testIntegration, err
}
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\nBackend Tests:\n%s\n\nIntegration Tests:\n%s\n", analyze, coverage, testBackend, testIntegration), nil
}
// Generate build history Hugo content by scanning the remote server
+22 -19
View File
@@ -6,16 +6,6 @@
# STALWART_TMPDIR/ports.env for other scripts to source.
set -euo pipefail
command -v stalwart >/dev/null || {
echo "stalwart not in PATH — run inside nix develop"
exit 1
}
command -v ss >/dev/null || {
echo "ss not in PATH — cannot verify Stalwart ports"
exit 1
}
if [ "${STALWART_RANDOM_PORTS:-0}" = "1" ] || [ "${STALWART_PORT:-0}" = "0" ]; then
command -v python3 >/dev/null || {
echo "python3 not in PATH — cannot choose random Stalwart ports"
@@ -61,17 +51,30 @@ export STALWART_SIEVE_PORT=${STALWART_SIEVE_PORT}
export STALWART_URL=${STALWART_URL}
EOF
# Find a container runtime
if command -v podman >/dev/null 2>&1; then
RUNTIME="podman"
elif command -v docker >/dev/null 2>&1; then
RUNTIME="docker"
else
echo "No container runtime (podman or docker) found" >&2
exit 1
fi
echo "Stalwart ports: JMAP=${STALWART_PORT} IMAP=${STALWART_IMAP_PORT} SMTP=${STALWART_SMTP_PORT} SIEVE=${STALWART_SIEVE_PORT}" >&2
echo "Stalwart is running in the foreground. Press Ctrl+C to stop." >&2
echo "Stalwart is running in a container (${RUNTIME}). Press Ctrl+C to stop." >&2
echo "Connection info written to ${TMPDIR}/ports.env" >&2
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
sed -e "s|0.0.0.0:8080|0.0.0.0:${STALWART_PORT}|" \
-e "s|0.0.0.0:1430|0.0.0.0:${STALWART_IMAP_PORT}|" \
-e "s|0.0.0.0:1025|0.0.0.0:${STALWART_SMTP_PORT}|" \
-e "s|0.0.0.0:4190|0.0.0.0:${STALWART_SIEVE_PORT}|" \
-e "s|/tmp/stalwart|${TMPDIR}|" \
"${REPO_ROOT}/stalwart-dev/config.toml" >"${TMPDIR}/config.toml"
exec stalwart --config "${TMPDIR}/config.toml"
# Run Stalwart in container, mapping the random host ports to the fixed container ports.
# We mount the config.toml and use /tmp/stalwart for data (mapped to our local TMPDIR).
exec "${RUNTIME}" run --rm -i \
-p "${STALWART_PORT}:8080" \
-p "${STALWART_IMAP_PORT}:1430" \
-p "${STALWART_SMTP_PORT}:1025" \
-p "${STALWART_SIEVE_PORT}:4190" \
-v "${REPO_ROOT}/stalwart-dev/config.toml:/etc/stalwart/config.toml:ro" \
-v "${TMPDIR}:/tmp/stalwart:rw" \
docker.io/stalwartlabs/stalwart:v0.14.1 \
stalwart --config /etc/stalwart/config.toml
+2 -7
View File
@@ -12,11 +12,6 @@ export STALWART_RANDOM_PORTS=1
STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)"
export STALWART_TMPDIR
command -v stalwart >/dev/null || {
echo "stalwart not in PATH — run inside nix develop"
exit 1
}
# Kill any stalwart left over from a previous run.
pkill -x stalwart 2>/dev/null && sleep 0.5 || true
@@ -34,8 +29,8 @@ tmp=$(mktemp)
STALWART_PID=$!
trap 'kill "$STALWART_PID" 2>/dev/null || true; wait "$STALWART_PID" 2>/dev/null || true; rm -f "$tmp"' EXIT
# Wait until Stalwart is accepting connections (up to 10 s).
for _i in $(seq 1 20); do
# Wait until Stalwart is accepting connections (up to 60 s).
for _i in $(seq 1 120); do
# shellcheck source=/dev/null
[ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env"
grep -E "already in use" "$LOGFILE" >/dev/null 2>&1 && {