package main import ( "context" "dagger/ci/internal/dagger" "fmt" "time" "golang.org/x/sync/errgroup" ) // patchAabScript patches android:versionCode in an AAB's compiled manifest proto. // It strips META-INF/ (old signature) and repacks the ZIP. No external dependencies. const patchAabScript = `#!/usr/bin/env python3 import sys, zipfile MANIFEST = "base/manifest/AndroidManifest.xml" VERSION_CODE_RID = 0x0101021b def _vr(b, p): n = s = 0 while True: c = b[p]; p += 1; n |= (c & 127) << s if not (c & 128): return n, p s += 7 def _ve(n): r = [] while n > 127: r.append((n & 127) | 128); n >>= 7 return bytes(r + [n]) def _parse(d): p = 0 while p < len(d): tag, p = _vr(d, p); fn, wt = tag >> 3, tag & 7 if wt == 0: v, p = _vr(d, p); yield fn, 0, v elif wt == 2: ln, p = _vr(d, p); yield fn, 2, d[p:p+ln]; p += ln elif wt == 5: yield fn, 5, d[p:p+4]; p += 4 # fixed32 elif wt == 1: yield fn, 1, d[p:p+8]; p += 8 # fixed64 else: raise ValueError(f"wire type {wt}") def _enc(fn, wt, v): t = _ve((fn << 3) | wt) if wt == 0: return t + _ve(v) if wt in (1, 5): return t + v # fixed-width, pass bytes as-is return t + _ve(len(v)) + v def _patch_prim(d, vc): # Patch int_decimal_value (field 6) or int_hexadecimal_value (field 7), # whichever is present — AAPT2 may use either. out = bytearray() for fn, wt, v in _parse(d): out += _enc(fn, 0, vc) if (fn in (6, 7) and wt == 0) else _enc(fn, wt, v) return bytes(out) def _patch_item(d, vc): out = bytearray() for fn, wt, v in _parse(d): out += _enc(7, 2, _patch_prim(v, vc)) if fn == 7 else _enc(fn, wt, v) return bytes(out) def _has_rid(d): return any(fn == 5 and wt == 0 and v == VERSION_CODE_RID for fn, wt, v in _parse(d)) def _patch_attr(d, vc): out = bytearray() for fn, wt, v in _parse(d): if fn == 3 and wt == 2: out += _enc(3, 2, str(vc).encode()) elif fn == 6 and wt == 2: out += _enc(6, 2, _patch_item(v, vc)) else: out += _enc(fn, wt, v) return bytes(out) def _patch_elem(d, vc): out = bytearray() for fn, wt, v in _parse(d): out += _enc(4, 2, _patch_attr(v, vc)) if (fn == 4 and _has_rid(v)) else _enc(fn, wt, v) return bytes(out) def _patch_node(d, vc): out = bytearray() for fn, wt, v in _parse(d): out += _enc(1, 2, _patch_elem(v, vc)) if fn == 1 else _enc(fn, wt, v) return bytes(out) def _dump_proto(d, depth=0, limit=3): """Print proto field structure for debugging.""" pad = " " * depth for fn, wt, v in _parse(d): if wt == 0: print(f"{pad}[{fn}] varint={v} (0x{v:x})") elif wt == 2: print(f"{pad}[{fn}] bytes len={len(v)}") if depth < limit: _dump_proto(v, depth + 1, limit) elif wt == 5: print(f"{pad}[{fn}] fixed32={v.hex()}") elif wt == 1: print(f"{pad}[{fn}] fixed64={v.hex()}") def _read_vc_from_node(d): """Read versionCode from XmlNode proto bytes. Returns int or None.""" for fn, wt, v in _parse(d): if fn == 1 and wt == 2: # XmlElement for efn, ewt, attr in _parse(v): if efn == 4 and ewt == 2 and _has_rid(attr): # XmlAttribute with versionCode RID for afn, awt, item in _parse(attr): if afn == 6 and awt == 2: # compiled_value (Item) for ifn, iwt, prim in _parse(item): if ifn == 7 and iwt == 2: # prim (Primitive) for pfn, pwt, pv in _parse(prim): if pfn in (6, 7) and pwt == 0: return pv return None def patch(src, dst, vc): with zipfile.ZipFile(src) as z: mf = z.read(MANIFEST) orig_vc = _read_vc_from_node(mf) if orig_vc is None: print("DEBUG: could not find versionCode — dumping manifest proto structure:") _dump_proto(mf, limit=4) sys.exit(f"ERROR: versionCode not found in {MANIFEST}") print(f"Original versionCode in manifest: {orig_vc}") patched = _patch_node(mf, vc) with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, 'w') as zout: for info in zin.infolist(): if info.filename.startswith('META-INF/'): continue # strip old signature; jarsigner re-signs after d = patched if info.filename == MANIFEST else zin.read(info.filename) zi = zipfile.ZipInfo(info.filename, info.date_time) zi.compress_type = info.compress_type zi.external_attr = info.external_attr zout.writestr(zi, d) # Verify the patch actually took effect with zipfile.ZipFile(dst) as z: actual = _read_vc_from_node(z.read(MANIFEST)) if actual != vc: sys.exit(f"ERROR: versionCode patch failed — wrote {vc} but read back {actual} (original was {orig_vc})") print(f"versionCode={actual} -> {dst}") if __name__ == "__main__": if len(sys.argv) != 4: sys.exit(f"usage: {sys.argv[0]} in.aab out.aab versionCode") patch(sys.argv[1], sys.argv[2], int(sys.argv[3])) ` type Ci struct { Source *dagger.Directory } func New( // +defaultPath=".." source *dagger.Directory, ) *Ci { return &Ci{ Source: source.Filter(dagger.DirectoryFilterOpts{ Include: []string{ "lib/", "test/", "assets/", "scripts/", "pubspec.yaml", "pubspec.lock", "analysis_options.yaml", "linux/", "android/", "integration_test/", "drift_schemas/", "stalwart-dev/", "website/", }, }), } } // toolchain returns the Flutter+Android toolchain without any mutable cache mounts. // Its execution cache key is stable until the image, apt packages, or SDK versions change. // Used as the base for pubGetLayer so flutter pub get is execution-cached between runs. func (m *Ci) toolchain() *dagger.Container { return dag.Container(). From("ghcr.io/cirruslabs/flutter:3.41.6"). WithExec([]string{"apt-get", "-qq", "update"}). WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}). WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}). WithExec([]string{"/bin/sh", "-c", `flutter_dir=$(dirname $(dirname $(which flutter))); ` + `chown -R ci:ci "$flutter_dir"; ` + `[ -n "$ANDROID_HOME" ] && chown -R ci:ci "$ANDROID_HOME" || true; ` + `mkdir -p /src && chown ci:ci /src`}). WithEnvVariable("PUB_CACHE", "/home/ci/.pub-cache"). WithEnvVariable("HOME", "/home/ci"). WithUser("ci"). WithExec([]string{"/bin/sh", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}). WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"}) } // Base is the Flutter toolchain container with mutable cache mounts attached. // Use for Android/Gradle builds that need the Gradle cache. func (m *Ci) Base() *dagger.Container { return m.toolchain(). WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}) } // pubGetLayer runs flutter pub get with only pubspec.yaml + pubspec.lock as // inputs, then removes non-deterministic fields from both package_config.json // and .flutter-plugins-dependencies so the snapshot is byte-for-byte stable // across runs. Re-executes only when pubspec.yaml or pubspec.lock changes. // Packages land in the execution-cache snapshot (not a named volume) so that // dagger prune can reclaim space from stale pubspec.lock snapshots. func (m *Ci) pubGetLayer() *dagger.Container { pubspecOnly := m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"pubspec.yaml", "pubspec.lock"}, }) return m.toolchain(). WithDirectory("/src", pubspecOnly, dagger.ContainerWithDirectoryOpts{Owner: "ci"}). WithWorkdir("/src"). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -vE '^(\+|Downloading packages)' "$tmp" || true`}). WithExec([]string{"python3", "-c", "import json, os\n" + "f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" + "g='.flutter-plugins-dependencies'\n" + "if os.path.exists(g):\n" + " d=json.load(open(g)); d.pop('date_created',None); json.dump(d,open(g,'w'))\n"}) } // codegenBase runs build_runner on the source subset common to all build // variants (lib/, test/, assets/, pubspec.*), excluding committed generated // files so the cache key is stable. All setup() calls share this single // Dagger cache entry, so build_runner compiles only once per pipeline run. func (m *Ci) codegenBase() *dagger.Container { codegenSrc := m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock"}, Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"}, }) return m.pubGetLayer(). WithDirectory("/src", codegenSrc, dagger.ContainerWithDirectoryOpts{Owner: "ci"}). WithWorkdir("/src"). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -vE '^\[.*s\] \|' "$tmp" || true`}) } // setup overlays platform-specific source files onto the shared codegen base. // Generated files (*.g.dart, *.mocks.dart) are excluded from the overlay so // the freshly built output from codegenBase() is not overwritten by stale // committed copies. func (m *Ci) setup(src *dagger.Directory) *dagger.Container { return m.codegenBase(). WithDirectory("/src", src.Filter(dagger.DirectoryFilterOpts{ Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"}, }), dagger.ContainerWithDirectoryOpts{Owner: "ci"}) } // Setup is the exported variant (CLI / Taskfile). Uses the full check source. func (m *Ci) Setup() *dagger.Container { return m.setup(m.checkSrc()) } // checkSrc is the source subset for static checks and unit tests. func (m *Ci) checkSrc() *dagger.Directory { return m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock", "analysis_options.yaml", "scripts/"}, }) } // androidSrc is the source subset for Android builds. func (m *Ci) androidSrc() *dagger.Directory { return m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"lib/", "android/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"}, }) } // firebaseSrc is the source subset for Firebase Test Lab builds (app + instrumented tests). func (m *Ci) firebaseSrc() *dagger.Directory { return m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"lib/", "android/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"}, }) } // linuxSrc is the source subset for Linux builds and integration tests. func (m *Ci) linuxSrc() *dagger.Directory { return m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"lib/", "linux/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"}, }) } // backendSrc is the source subset for IMAP/JMAP backend tests. func (m *Ci) backendSrc() *dagger.Directory { return m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"lib/", "test/", "assets/", "scripts/", "stalwart-dev/", "pubspec.yaml", "pubspec.lock"}, }) } // integrationSrc is the source subset for UI integration tests (runs on Linux desktop). func (m *Ci) integrationSrc() *dagger.Directory { return m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"lib/", "linux/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"}, }) } // Hugo container for website builds func (m *Ci) Hugo() *dagger.Container { return dag.Container(). From("alpine:3.21"). WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}). 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{"sh", "-c", "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -"}). WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}). WithExec([]string{"rm", "/tmp/hugo.tar.gz"}) } // Deploy container for rsync/ssh func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container { return dag.Container(). From("alpine:3.21"). WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}). WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}). WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519") } // Stalwart mail server service for backend and integration tests. func (m *Ci) Stalwart() *dagger.Service { stalwartSrc := m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"stalwart-dev/"}, }) config := stalwartSrc.Directory("stalwart-dev").File("config.toml") dataDir := dag.Container(). From("alpine:3.21"). WithExec([]string{"apk", "add", "--no-cache", "sqlite"}). 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');"}). Directory("/tmp/stalwart") return dag.Container(). From("stalwartlabs/stalwart:v0.14.1"). WithFile("/etc/stalwart/config.toml.orig", config). WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}). WithDirectory("/tmp/stalwart", dataDir). WithExposedPort(8080). // JMAP WithExposedPort(1430). // IMAP WithExposedPort(1025). // SMTP WithExposedPort(4190). // ManageSieve WithEntrypoint([]string{"stalwart", "--config", "/etc/stalwart/config.toml"}). AsService() } // WithStalwart binds the Stalwart service and sets test environment variables. 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") } // CheckHygiene checks that no forbidden home-directory files are in the source. func (m *Ci) CheckHygiene(ctx context.Context) (string, error) { return m.Base(). WithDirectory("/src", m.Source, dagger.ContainerWithDirectoryOpts{Owner: "ci"}). WithWorkdir("/src"). 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) } // CheckLayers enforces that ui/ does not import data/. func (m *Ci) CheckLayers(ctx context.Context) (string, error) { return m.Base(). WithDirectory("/src", m.Source.Filter(dagger.DirectoryFilterOpts{Include: []string{"lib/"}}), dagger.ContainerWithDirectoryOpts{Owner: "ci"}). WithWorkdir("/src"). 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) } // Format runs dart format check. func (m *Ci) Format(ctx context.Context) (string, error) { return m.setup(m.checkSrc()). WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}). Stdout(ctx) } // CheckMocks verifies that generated mocks are up to date. // It snapshots the committed source (including any stale *.mocks.dart) before // running build_runner, so git diff detects real staleness instead of always // comparing two freshly-generated outputs. func (m *Ci) CheckMocks(ctx context.Context) (string, error) { return m.pubGetLayer(). WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}). WithWorkdir("/src"). 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", "-q", "-m", "baseline"}). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -vE '^\[.*s\] \|' "$tmp" || true`}). 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) } // Coverage runs unit tests with coverage gate. func (m *Ci) Coverage(ctx context.Context) (string, error) { return m.setup(m.checkSrc()). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). WithExec([]string{"dart", "scripts/check_coverage.dart"}). Stdout(ctx) } // TestBackend runs IMAP/JMAP sync tests against a live Stalwart instance. func (m *Ci) TestBackend(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } // TestIntegration runs UI integration tests via Xvfb. func (m *Ci) TestIntegration(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.integrationSrc())). WithEnvVariable("LIBGL_ALWAYS_SOFTWARE", "1"). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `xvfb-run -s '-screen 0 1280x720x24' flutter test integration_test/ -d linux >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } // TestSyncReliability runs the sync reliability runner. func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `flutter test test/backend/sync_reliability_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } // Check runs the full check suite. func (m *Ci) Check(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) defer cancel() if _, err := m.CheckHygiene(ctx); err != nil { return "Hygiene check failed", err } if _, err := m.CheckLayers(ctx); err != nil { return "Layer check failed", err } checkSetup := m.setup(m.checkSrc()) if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil { return "Format check failed", err } analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx) if err != nil { return analyze, err } mocks, err := m.CheckMocks(ctx) if err != nil { return mocks, err } coverage, err := m.Coverage(ctx) if err != nil { return coverage, err } var testBackend, testIntegration string eg, egCtx := errgroup.WithContext(ctx) eg.Go(func() error { var e error testBackend, e = m.TestBackend(egCtx) return e }) eg.Go(func() error { var e error testIntegration, e = m.TestIntegration(egCtx) return e }) if err := eg.Wait(); err != nil { return "", err } return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\n%s\n\nBackend Tests:\n%s\n\nIntegration Tests:\n%s\n", analyze, mocks, coverage, testBackend, testIntegration), nil } // GenerateBuildHistory scans the remote server and produces Hugo content. func (m *Ci) GenerateBuildHistory( ctx context.Context, sshKey *dagger.Secret, knownHosts *dagger.Secret, sshUser string, sshHost string, ) *dagger.Directory { scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"scripts/generate_build_history.py", "website/"}, }) return dag.Container(). From("python:3.12-alpine"). WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}). WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}). 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") } // BuildWebsite builds the Hugo-based website. func (m *Ci) BuildWebsite( ctx context.Context, sshKey *dagger.Secret, knownHosts *dagger.Secret, sshUser string, sshHost string, ) *dagger.Directory { buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost) websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"website/"}, }).WithDirectory("website/content/builds", buildHistory) return m.Hugo(). WithDirectory("/src", websiteSource). WithWorkdir("/src/website"). WithExec([]string{"hugo", "--minify"}). Directory("public") } // PublishWebsite builds and deploys the website to the remote server. func (m *Ci) PublishWebsite( ctx context.Context, sshKey *dagger.Secret, knownHosts *dagger.Secret, sshUser string, sshHost string, ) (string, error) { public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost) return m.Deployer(sshKey, knownHosts). WithDirectory("/public", public). WithExec([]string{"rsync", "-avz", "--delete", "--exclude=*.apk", "--exclude=*.tar.gz", "/public/", fmt.Sprintf("%s@%s:public_html/", sshUser, sshHost)}). Stdout(ctx) } // BuildLinux builds the Linux release bundle. func (m *Ci) BuildLinux() *dagger.Directory { return m.setup(m.linuxSrc()). WithExec([]string{"flutter", "build", "linux", "--release"}). Directory("build/linux/x64/release/bundle") } // BuildLinuxRelease builds the Linux release bundle. func (m *Ci) BuildLinuxRelease( // Git commit hash injected as GIT_HASH dart-define so the About page can display it. // +optional commitHash string, ) *dagger.Directory { args := []string{"flutter", "build", "linux", "--release"} if commitHash != "" { args = append(args, "--dart-define=GIT_HASH="+commitHash) } return m.setup(m.linuxSrc()). WithExec(args). Directory("build/linux/x64/release/bundle") } // DeployLinux packages and deploys the Linux release to the server. func (m *Ci) DeployLinux( ctx context.Context, sshKey *dagger.Secret, knownHosts *dagger.Secret, sshUser string, sshHost string, commitHash string, ) (string, error) { bundle := m.BuildLinuxRelease(commitHash) 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, knownHosts). WithDirectory("/bundle", bundle). WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}). WithExec([]string{"ssh", "-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 -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}). Stdout(ctx) } // setupKeystore decodes the base64 keystore into the android build container. func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container { return m.setup(m.androidSrc()). WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64). WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword). WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`}) } // BuildAndroidApk builds a release APK signed with the upload key. func (m *Ci) BuildAndroidApk( keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string, // Git commit hash injected as GIT_HASH dart-define so the About page can display it. // +optional commitHash string, ) *dagger.File { args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber} if commitHash != "" { args = append(args, "--dart-define=GIT_HASH="+commitHash) } return m.setupKeystore(keystoreBase64, keystorePassword). WithExec(args). File("build/app/outputs/flutter-apk/app-release.apk") } // DeployApk builds and deploys the APK to the server. func (m *Ci) DeployApk( ctx context.Context, sshKey *dagger.Secret, knownHosts *dagger.Secret, sshUser string, sshHost string, commitHash string, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string, ) (string, error) { apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash) 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, knownHosts). WithFile("/tmp/app.apk", apk). WithExec([]string{"ssh", "-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 -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}). Stdout(ctx) } // BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab. // Returns a flat directory with app-debug.apk and app-debug-androidTest.apk. func (m *Ci) BuildAndroidDebugApks() *dagger.Directory { built := m.setup(m.firebaseSrc()). WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}). WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}). WithWorkdir("/src/android"). // --no-daemon avoids connecting to a stale daemon whose registry file was // preserved in the Dagger layer snapshot but whose process no longer exists. WithExec([]string{"./gradlew", "--no-daemon", "app:assembleAndroidTest"}). WithWorkdir("/src"). WithExec([]string{"/bin/bash", "-c", `apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \ [ -n "$apk" ] || { echo "ERROR: no androidTest APK found; APKs present:"; find /src -name "*.apk" -type f 2>/dev/null; exit 1; } && \ echo "Found test APK: $apk" && \ cp "$apk" /src/app-debug-androidTest.apk`}) return dag.Directory(). WithFile("app-debug.apk", built.File("build/app/outputs/flutter-apk/app-debug.apk")). WithFile("app-debug-androidTest.apk", built.File("app-debug-androidTest.apk")) } // TestAndroidFirebase builds Android APKs and runs instrumented tests on Firebase Test Lab. func (m *Ci) TestAndroidFirebase( ctx context.Context, serviceAccountKey *dagger.Secret, projectID string, ) (string, error) { apks := m.BuildAndroidDebugApks() return dag.Container(). From("google/cloud-sdk:slim"). WithDirectory("/apks", apks). WithSecretVariable("FIREBASE_SA_KEY", serviceAccountKey). WithEnvVariable("FIREBASE_PROJECT_ID", projectID). WithExec([]string{"/bin/bash", "-c", `auth_err=$(mktemp); trap 'rm -f "$auth_err"' EXIT; \ gcloud auth activate-service-account --key-file=<(echo "$FIREBASE_SA_KEY") 2>"$auth_err" \ || { cat "$auth_err"; exit 1; }; \ gcloud config set project "$FIREBASE_PROJECT_ID" 2>>"$auth_err" \ || { cat "$auth_err"; exit 1; }; \ unknown=$(grep -vF "Activated service account credentials for:" "$auth_err" \ | grep -vF "Updated property [core/project]." | grep -v "^$" || true); \ [ -z "$unknown" ] || { echo "ERROR: unexpected gcloud auth output: $unknown"; exit 1; }; \ out=$(gcloud firebase test android run \ --type instrumentation \ --app /apks/app-debug.apk \ --test /apks/app-debug-androidTest.apk \ --device model=oriole,version=33,locale=en,orientation=portrait \ --results-bucket=gs://sharedinbox-ftl-results 2>&1); rc=$?; echo "$out"; \ [ "$rc" -eq 0 ] || { echo "ERROR: gcloud firebase test exited with code $rc"; exit "$rc"; }; \ expected_devices=1; \ actual_devices=$(echo "$out" | grep "│" | grep -cE "(Passed|Failed|Inconclusive|Skipped)") || actual_devices=0; \ [ "$actual_devices" -eq "$expected_devices" ] || \ { echo "ERROR: expected $expected_devices test result(s) but found $actual_devices"; exit 1; }; \ echo "$out" | grep -q "Passed" || { echo "ERROR: no passing test results — tests failed or did not run"; exit 1; }`}). Stdout(ctx) } // BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it. // versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle. func (m *Ci) BuildAndroidRelease( // Git commit hash injected as GIT_HASH dart-define so the About page can display it. // +optional commitHash string, ) *dagger.File { args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"} if commitHash != "" { args = append(args, "--dart-define=GIT_HASH="+commitHash) } return m.setup(m.androidSrc()). WithExec(args). File("build/app/outputs/bundle/release/app-release.aab") } // withGoCache mounts Dagger cache volumes for GOCACHE and GOMODCACHE so Go // builds inside the container reuse cached packages between pipeline runs. func withGoCache(c *dagger.Container) *dagger.Container { return c. WithMountedCache("/home/ci/.cache/go-build", dag.CacheVolume("go-build-cache")). WithMountedCache("/home/ci/go/pkg/mod", dag.CacheVolume("go-mod-cache")). WithEnvVariable("GOCACHE", "/home/ci/.cache/go-build"). WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod") } // UploadToPlayStore uploads a pre-built AAB to the Play Store internal track. func (m *Ci) UploadToPlayStore( ctx context.Context, aab *dagger.File, playStoreConfig *dagger.Secret, ) (string, error) { scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"scripts/deploy_playstore.py"}, }) return dag.Container(). From("python:3.12-alpine"). WithExec([]string{"apk", "add", "--no-cache", "curl"}). WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")). WithExec([]string{"pip", "install", "google-auth", "requests"}). 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) } // StampAndroidVersionCode patches the versionCode in a built AAB without rebuilding. func (m *Ci) StampAndroidVersionCode(aab *dagger.File, versionCode int) *dagger.File { return dag.Container(). From("python:3.12-alpine"). WithNewFile("/patch.py", patchAabScript). WithFile("/in.aab", aab). WithExec([]string{"python3", "/patch.py", "/in.aab", "/out.aab", fmt.Sprintf("%d", versionCode)}). File("/out.aab") } // SignAndroidBundle signs an AAB with the release upload key via jarsigner. func (m *Ci) SignAndroidBundle(aab *dagger.File, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.File { return dag.Container(). From("eclipse-temurin:17-jdk-alpine"). WithFile("/app.aab", aab). WithSecretVariable("KS_BASE64", keystoreBase64). WithSecretVariable("KS_PASS", keystorePassword). WithExec([]string{"sh", "-c", `[ -n "$KS_BASE64" ] || { echo "ERROR: KS_BASE64 secret is empty — ANDROID_KEYSTORE_BASE64 not set"; exit 1; } [ -n "$KS_PASS" ] || { echo "ERROR: KS_PASS secret is empty — ANDROID_KEYSTORE_PASSWORD not set"; exit 1; } echo "$KS_BASE64" | base64 -d > /keystore.jks && \ jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \ -signedjar /signed.aab \ -keystore /keystore.jks \ -storepass "$KS_PASS" -keypass "$KS_PASS" \ /app.aab upload`}). File("/signed.aab") } // PublishAndroid builds a cached AAB, stamps the versionCode, re-signs, and uploads to Play Store. func (m *Ci) PublishAndroid( ctx context.Context, playStoreConfig *dagger.Secret, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, // Git commit hash injected as GIT_HASH dart-define so the About page can display it. // +optional commitHash string, ) (string, error) { versionCode := int(time.Now().Unix()) aab := m.BuildAndroidRelease(commitHash) stamped := m.StampAndroidVersionCode(aab, versionCode) signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword) return m.UploadToPlayStore(ctx, signed, playStoreConfig) } // Graph returns a Mermaid diagram of the CI pipeline structure. // Paste the output into any Mermaid renderer (codeberg, github, mermaid.live) // or save it as a .md file to get a rendered diagram. // // Usage: // // dagger call --progress=plain -q -m ci --source=. graph func (m *Ci) Graph() string { return `# CI Pipeline Graph ` + "```" + `mermaid flowchart TD subgraph dagger ["Dagger · Check pipeline"] toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"] pubGet["pubGetLayer\nflutter pub get"] codegen["codegenBase\nbuild_runner build\n(shared cache)"] stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"]) toolchain --> pubGet pubGet --> codegen pubGet --> hygiene["CheckHygiene"] pubGet --> layers["CheckLayers"] pubGet --> mocks["CheckMocks\n(own build_runner run)"] codegen --> fmt["Format"] codegen --> analyze["Analyze"] codegen --> coverage["Coverage\nunit tests + gate"] codegen --> backend["TestBackend\nIMAP / JMAP"] codegen --> integration["TestIntegration\nXvfb · Linux desktop"] stalwart --> backend stalwart --> integration hygiene --> check{{"✓ Check"}} layers --> check fmt --> check analyze --> check mocks --> check coverage --> check backend --> check integration --> check end subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"] ciCheck["check"] end subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"] detectChanges["check-changes\ndetect android / linux diff"] buildLinux["build-linux\n(linux changed)"] deployPS["deploy-playstore\n(android changed)"] deployApk["deploy-apk\n(android changed)"] fbTest["test-android-firebase\n(android changed)"] pubWeb["publish-website\n(any build succeeded)"] detectChanges --> buildLinux detectChanges --> deployPS detectChanges --> deployApk detectChanges --> fbTest buildLinux --> pubWeb deployPS --> pubWeb deployApk --> pubWeb end check -- "task check-dagger" --> ciCheck ` + "```" }