2026-05-16 00:20:09 +02:00
package main
import (
"context"
"fmt"
2026-05-17 10:19:23 +02:00
"time"
2026-05-16 00:20:09 +02:00
"dagger/ci/internal/dagger"
)
2026-05-17 13:20:26 +02:00
type Ci struct {
// The source directory of the project, filtered surgically.
Source * dagger . Directory
}
2026-05-16 00:20:09 +02:00
2026-05-17 13:20:26 +02:00
func New (
// The source directory of the project.
// +defaultPath=".."
source * dagger . Directory ,
) * Ci {
return & Ci {
Source : source . Filter ( dagger . DirectoryFilterOpts {
Include : [] string {
"lib/" ,
"test/" ,
"assets/" ,
"scripts/" ,
"pubspec.yaml" ,
"analysis_options.yaml" ,
"linux/" ,
"android/" ,
"integration_test/" ,
"drift_schemas/" ,
"stalwart-dev/" ,
},
}),
}
}
2026-05-17 07:15:12 +02:00
2026-05-17 13:20:26 +02:00
// Base container with all dependencies for Flutter and Linux builds
func ( m * Ci ) Base () * dagger . Container {
2026-05-16 00:20:09 +02:00
return dag . Container ().
2026-05-17 00:02:41 +02:00
From ( "ghcr.io/cirruslabs/flutter:3.41.6" ).
2026-05-16 00:20:09 +02:00
WithExec ([] string { "apt-get" , "update" }).
2026-05-17 14:41:00 +02:00
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" }).
2026-05-17 00:02:41 +02:00
WithMountedCache ( "/root/.pub-cache" , dag . CacheVolume ( "flutter-pub-cache" )).
WithMountedCache ( "/root/.gradle" , dag . CacheVolume ( "gradle-cache" )).
WithEnvVariable ( "PUB_CACHE" , "/root/.pub-cache" ).
2026-05-17 13:20:26 +02:00
WithDirectory ( "/src" , m . Source ).
2026-05-16 00:20:09 +02:00
WithWorkdir ( "/src" )
}
2026-05-17 10:14:40 +02:00
// Hugo container for website builds
func ( m * Ci ) Hugo () * dagger . Container {
return dag . Container ().
From ( "alpine:3.21" ).
2026-05-17 10:17:40 +02:00
WithExec ([] string { "apk" , "--no-cache" , "add" , "curl" , "tar" , "libc6-compat" , "libstdc++" , "gcompat" }).
2026-05-17 10:14:40 +02:00
WithExec ([] string { "curl" , "-sL" , "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz" , "-o" , "/tmp/hugo.tar.gz" }).
WithExec ([] string { "tar" , "-xzf" , "/tmp/hugo.tar.gz" , "-C" , "/usr/local/bin" , "hugo" }).
WithExec ([] string { "rm" , "/tmp/hugo.tar.gz" })
}
2026-05-17 10:17:40 +02:00
// Deploy container for rsync/ssh
func ( m * Ci ) Deployer ( sshKey * dagger . Secret ) * dagger . Container {
return dag . Container ().
From ( "alpine:3.21" ).
2026-05-17 10:19:23 +02:00
WithExec ([] string { "apk" , "--no-cache" , "add" , "rsync" , "openssh-client" , "python3" , "tar" }).
2026-05-17 10:28:16 +02:00
WithMountedSecret ( "/root/.ssh/id_ed25519" , sshKey , dagger . ContainerWithMountedSecretOpts { Mode : 0600 }).
WithEnvVariable ( "RSYNC_RSH" , "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519" )
2026-05-17 10:17:40 +02:00
}
2026-05-17 13:17:28 +02:00
// Latest Stalwart Mail Server as a Dagger Service
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Stalwart () * dagger . Service {
config := m . Source . Directory ( "stalwart-dev" ). File ( "config.toml" )
2026-05-17 13:17:28 +02:00
return dag . Container ().
2026-05-17 14:24:06 +02:00
From ( "stalwartlabs/stalwart:v0.14.1" ).
2026-05-17 13:17:28 +02:00
WithFile ( "/etc/stalwart/config.toml" , config ).
// Create data dir in /tmp where permissions are usually more relaxed.
WithExec ([] string { "/bin/sh" , "-c" , "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart" }).
WithExposedPort ( 8080 ). // JMAP
WithExposedPort ( 1430 ). // IMAP
WithExposedPort ( 1025 ). // SMTP
WithExposedPort ( 4190 ). // ManageSieve
AsService ()
}
2026-05-16 00:20:09 +02:00
// Setup environment: pub get and build_runner
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Setup () * dagger . Container {
return m . Base ().
2026-05-16 00:20:09 +02:00
WithExec ([] string { "flutter" , "pub" , "get" }).
2026-05-17 06:28:32 +02:00
// Use --delete-conflicting-outputs to ensure generated files match the current source
2026-05-16 00:20:09 +02:00
WithExec ([] string { "flutter" , "pub" , "run" , "build_runner" , "build" , "--delete-conflicting-outputs" })
}
// Run hygiene check
2026-05-17 13:20:26 +02:00
func ( m * Ci ) CheckHygiene ( ctx context . Context ) ( string , error ) {
return m . Base ().
2026-05-16 00:20:09 +02:00
WithExec ([] string { "/bin/bash" , "-c" , "FORBIDDEN=\".ssh .bashrc .config .local .cache .gitconfig .android Android .gradle .pub-cache .dartServer .flutter .dart-cli-completion .atuin .bash_logout .profile .zcompdump .zshrc snap .emulator_console_auth_token .lesshst .metadata .tmux.conf\"; for f in $FORBIDDEN; do if [ -e \"$f\" ]; then echo \"ERROR: Forbidden file/dir found in source: $f\"; exit 1; fi; done; echo \"Hygiene check passed.\"" }).
Stdout ( ctx )
}
// Enforce architecture — ui/ must not import data/
2026-05-17 13:20:26 +02:00
func ( m * Ci ) CheckLayers ( ctx context . Context ) ( string , error ) {
return m . Base ().
2026-05-16 00:20:09 +02:00
WithExec ([] string { "/bin/bash" , "-c" , "VIOLATIONS=$(grep -rn \"package:sharedinbox/data/\" lib/ui/ 2>/dev/null || true); if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):\"; echo \"$VIOLATIONS\"; exit 1; fi; echo \"Layer check passed.\"" }).
Stdout ( ctx )
}
2026-05-17 08:51:17 +02:00
// Run dart format check
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Format ( ctx context . Context ) ( string , error ) {
return m . Base ().
2026-05-17 08:51:17 +02:00
WithExec ([] string { "dart" , "format" , "--output=none" , "--set-exit-if-changed" , "lib" , "test" }).
Stdout ( ctx )
}
// Verify that mocks are up to date
2026-05-17 13:20:26 +02:00
func ( m * Ci ) CheckMocks ( ctx context . Context ) ( string , error ) {
return m . Setup ().
2026-05-17 09:07:55 +02:00
WithExec ([] string { "git" , "init" }).
WithExec ([] string { "git" , "config" , "user.email" , "ci@sharedinbox.de" }).
WithExec ([] string { "git" , "config" , "user.name" , "CI" }).
WithExec ([] string { "git" , "add" , "." }).
WithExec ([] string { "git" , "commit" , "-m" , "baseline" }).
WithExec ([] string { "flutter" , "pub" , "run" , "build_runner" , "build" , "--delete-conflicting-outputs" }).
2026-05-17 08:51:17 +02:00
WithExec ([] string { "/bin/bash" , "-c" , "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\"" }).
Stdout ( ctx )
}
2026-05-17 09:15:53 +02:00
// Run coverage check
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Coverage ( ctx context . Context ) ( string , error ) {
return m . Setup ().
2026-05-17 09:15:53 +02:00
WithExec ([] string { "flutter" , "test" , "test/unit" , "--coverage" }).
WithExec ([] string { "dart" , "scripts/check_coverage.dart" }).
Stdout ( ctx )
}
2026-05-16 00:20:09 +02:00
// Full check suite (equivalent to task check)
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Check ( ctx context . Context ) ( string , error ) {
setup := m . Setup ()
2026-05-16 00:20:09 +02:00
// Hygiene & Layers
2026-05-17 13:20:26 +02:00
if _ , err := m . CheckHygiene ( ctx ); err != nil {
2026-05-16 00:20:09 +02:00
return "Hygiene check failed" , err
}
2026-05-17 13:20:26 +02:00
if _ , err := m . CheckLayers ( ctx ); err != nil {
2026-05-16 00:20:09 +02:00
return "Layer check failed" , err
}
2026-05-17 10:05:52 +02:00
// 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 {
2026-05-17 08:51:17 +02:00
return "Format check failed" , err
}
2026-05-16 00:20:09 +02:00
// Run analyze
analyze , err := setup . WithExec ([] string { "flutter" , "analyze" }). Stdout ( ctx )
if err != nil {
return analyze , err
}
2026-05-17 09:15:53 +02:00
// Run coverage gate (includes unit tests)
2026-05-17 13:20:26 +02:00
coverage , err := m . Coverage ( ctx )
2026-05-16 00:20:09 +02:00
if err != nil {
2026-05-17 09:15:53 +02:00
return coverage , err
2026-05-16 00:20:09 +02:00
}
2026-05-17 13:17:28 +02:00
// Run backend tests (requires Stalwart Service)
2026-05-17 13:20:26 +02:00
stalwart := m . Stalwart ()
2026-05-17 13:17:28 +02:00
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" ).
2026-05-17 14:41:00 +02:00
// 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" }).
2026-05-17 13:17:28 +02:00
Stdout ( ctx )
2026-05-17 08:47:15 +02:00
if err != nil {
return testBackend , err
}
2026-05-17 09:15:53 +02:00
return fmt . Sprintf ( "All checks passed!\n\nAnalysis:\n%s\n\n%s\n\nBackend Tests:\n%s\n" , analyze , coverage , testBackend ), nil
2026-05-16 00:20:09 +02:00
}
2026-05-17 10:16:38 +02:00
// Generate build history Hugo content by scanning the remote server
func ( m * Ci ) GenerateBuildHistory (
ctx context . Context ,
sshKey * dagger . Secret ,
sshUser string ,
sshHost string ,
) * dagger . Directory {
2026-05-17 13:20:26 +02:00
scriptSource := m . Source . Filter ( dagger . DirectoryFilterOpts {
2026-05-17 10:16:38 +02:00
Include : [] string { "scripts/generate_build_history.py" , "website/" },
})
return dag . Container ().
From ( "python:3.12-alpine" ).
WithExec ([] string { "apk" , "add" , "--no-cache" , "openssh-client" }).
2026-05-17 10:28:16 +02:00
WithMountedSecret ( "/root/.ssh/id_ed25519" , sshKey , dagger . ContainerWithMountedSecretOpts { Mode : 0600 }).
2026-05-17 10:16:38 +02:00
WithEnvVariable ( "SSH_USER" , sshUser ).
WithEnvVariable ( "SSH_HOST" , sshHost ).
WithDirectory ( "/src" , scriptSource ).
WithWorkdir ( "/src" ).
WithExec ([] string { "/bin/sh" , "-c" , "python3 scripts/generate_build_history.py" }).
Directory ( "website/content/builds" )
}
2026-05-17 10:14:40 +02:00
// Build and return the Hugo-based website bundle
2026-05-17 10:16:38 +02:00
func ( m * Ci ) BuildWebsite (
ctx context . Context ,
sshKey * dagger . Secret ,
sshUser string ,
sshHost string ,
) * dagger . Directory {
// 1. Generate build history content
2026-05-17 13:20:26 +02:00
buildHistory := m . GenerateBuildHistory ( ctx , sshKey , sshUser , sshHost )
2026-05-17 10:16:38 +02:00
// 2. Prepare website source (base files + generated history)
2026-05-17 13:20:26 +02:00
websiteSource := m . Source . Filter ( dagger . DirectoryFilterOpts {
2026-05-17 10:14:40 +02:00
Include : [] string { "website/" },
2026-05-17 10:16:38 +02:00
}). WithDirectory ( "website/content/builds" , buildHistory )
2026-05-17 10:14:40 +02:00
2026-05-17 10:16:38 +02:00
// 3. Build with Hugo
2026-05-17 10:14:40 +02:00
return m . Hugo ().
WithDirectory ( "/src" , websiteSource ).
WithWorkdir ( "/src/website" ).
WithExec ([] string { "hugo" , "--minify" }).
Directory ( "public" )
}
2026-05-17 10:17:40 +02:00
// Build and deploy the website to the remote server
func ( m * Ci ) PublishWebsite (
ctx context . Context ,
sshKey * dagger . Secret ,
sshUser string ,
sshHost string ,
) ( string , error ) {
// 1. Build the website
2026-05-17 13:20:26 +02:00
public := m . BuildWebsite ( ctx , sshKey , sshUser , sshHost )
2026-05-17 10:17:40 +02:00
// 2. Deploy using rsync
return m . Deployer ( sshKey ).
WithDirectory ( "/public" , public ).
WithExec ([] string { "rsync" , "-avz" , "--delete" ,
"--exclude=*.apk" , "--exclude=*.tar.gz" ,
"/public/" , fmt . Sprintf ( "%s@%s:public_html/" , sshUser , sshHost )}).
Stdout ( ctx )
}
2026-05-16 00:20:09 +02:00
// Build and return the Linux bundle
2026-05-17 13:20:26 +02:00
func ( m * Ci ) BuildLinux () * dagger . Directory {
return m . Setup ().
2026-05-17 13:17:28 +02:00
WithExec ([] string { "flutter" , "build" , "linux" , "--release" }).
Directory ( "build/linux/x64/release/bundle" )
2026-05-16 00:20:09 +02:00
}
// Build and return the Linux bundle (release)
2026-05-17 13:20:26 +02:00
func ( m * Ci ) BuildLinuxRelease () * dagger . Directory {
return m . Setup ().
2026-05-16 00:20:09 +02:00
WithExec ([] string { "flutter" , "build" , "linux" , "--release" }).
Directory ( "build/linux/x64/release/bundle" )
}
2026-05-17 10:19:23 +02:00
// Package and deploy the Linux release to the server
func ( m * Ci ) DeployLinux (
ctx context . Context ,
sshKey * dagger . Secret ,
sshUser string ,
sshHost string ,
commitHash string ,
) ( string , error ) {
// 1. Build the release bundle
2026-05-17 13:20:26 +02:00
bundle := m . BuildLinuxRelease ()
2026-05-17 10:19:23 +02:00
// 2. Package and deploy
datePath := time . Now (). Format ( "2006/01/02" )
remoteDir := fmt . Sprintf ( "public_html/builds/%s" , datePath )
tarball := fmt . Sprintf ( "sharedinbox-linux-amd64-%s.tar.gz" , commitHash )
return m . Deployer ( sshKey ).
WithDirectory ( "/bundle" , bundle ).
WithExec ([] string { "/bin/sh" , "-c" , fmt . Sprintf ( "tar -czf /tmp/%s -C /bundle ." , tarball )}).
2026-05-17 10:28:16 +02:00
WithExec ([] string { "ssh" , "-o" , "StrictHostKeyChecking=no" , "-i" , "/root/.ssh/id_ed25519" , fmt . Sprintf ( "%s@%s" , sshUser , sshHost ), fmt . Sprintf ( "mkdir -p %s" , remoteDir )}).
WithExec ([] string { "/bin/sh" , "-c" , fmt . Sprintf ( "scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s" , tarball , sshUser , sshHost , remoteDir , tarball )}).
2026-05-17 10:19:23 +02:00
Stdout ( ctx )
}
// Build and return the Android APK
2026-05-17 13:20:26 +02:00
func ( m * Ci ) BuildAndroidApk () * dagger . File {
return m . Setup ().
2026-05-17 10:19:23 +02:00
WithExec ([] string { "flutter" , "build" , "apk" , "--release" }).
File ( "build/app/outputs/flutter-apk/app-release.apk" )
}
// Deploy the Android APK to the server
func ( m * Ci ) DeployApk (
ctx context . Context ,
sshKey * dagger . Secret ,
sshUser string ,
sshHost string ,
commitHash string ,
) ( string , error ) {
// 1. Build the APK
2026-05-17 13:20:26 +02:00
apk := m . BuildAndroidApk ()
2026-05-17 10:19:23 +02:00
// 2. Deploy
datePath := time . Now (). Format ( "2006/01/02" )
remoteDir := fmt . Sprintf ( "public_html/builds/%s" , datePath )
apkName := fmt . Sprintf ( "sharedinbox-mua-%s.apk" , commitHash )
return m . Deployer ( sshKey ).
WithFile ( "/tmp/app.apk" , apk ).
2026-05-17 10:28:16 +02:00
WithExec ([] string { "ssh" , "-o" , "StrictHostKeyChecking=no" , "-i" , "/root/.ssh/id_ed25519" , fmt . Sprintf ( "%s@%s" , sshUser , sshHost ), fmt . Sprintf ( "mkdir -p %s" , remoteDir )}).
WithExec ([] string { "/bin/sh" , "-c" , fmt . Sprintf ( "scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s" , sshUser , sshHost , remoteDir , apkName )}).
2026-05-17 10:19:23 +02:00
Stdout ( ctx )
}
2026-05-16 00:20:09 +02:00
// Build and return the Android App Bundle (AAB)
2026-05-17 13:20:26 +02:00
func ( m * Ci ) BuildAndroidRelease () * dagger . File {
return m . Setup ().
2026-05-16 00:20:09 +02:00
WithExec ([] string { "flutter" , "build" , "appbundle" , "--release" }).
File ( "build/app/outputs/bundle/release/app-release.aab" )
}
2026-05-17 10:20:33 +02:00
// Publish the Android App Bundle to Google Play Store
func ( m * Ci ) PublishAndroid (
ctx context . Context ,
playStoreConfig * dagger . Secret ,
) ( string , error ) {
// 1. Build the AAB
2026-05-17 13:20:26 +02:00
aab := m . BuildAndroidRelease ()
2026-05-17 10:20:33 +02:00
// 2. Prepare script source
2026-05-17 13:20:26 +02:00
scriptSource := m . Source . Filter ( dagger . DirectoryFilterOpts {
2026-05-17 10:20:33 +02:00
Include : [] string { "scripts/deploy_playstore.py" },
})
// 3. Deploy
return dag . Container ().
From ( "python:3.12-alpine" ).
WithExec ([] string { "apk" , "add" , "--no-cache" , "curl" }).
WithExec ([] string { "pip" , "install" , "requests" , "google-auth" }).
WithFile ( "/src/build/app/outputs/bundle/release/app-release.aab" , aab ).
WithFile ( "/src/scripts/deploy_playstore.py" , scriptSource . File ( "scripts/deploy_playstore.py" )).
WithSecretVariable ( "PLAY_STORE_CONFIG_JSON" , playStoreConfig ).
WithWorkdir ( "/src" ).
WithExec ([] string { "python3" , "scripts/deploy_playstore.py" }).
Stdout ( ctx )
}