2026-05-16 00:20:09 +02:00
package main
import (
"context"
2026-05-18 13:35:20 +02:00
"dagger/ci/internal/dagger"
2026-06-04 17:35:08 +02:00
"encoding/json"
2026-05-16 00:20:09 +02:00
"fmt"
2026-05-17 10:19:23 +02:00
"time"
2026-05-20 08:58:55 +02:00
"golang.org/x/sync/errgroup"
2026-05-16 00:20:09 +02:00
)
2026-05-18 13:35:20 +02:00
// 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
2026-05-18 17:11:20 +02:00
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
2026-05-18 13:35:20 +02:00
else: raise ValueError(f"wire type {wt}")
def _enc(fn, wt, v):
t = _ve((fn << 3) | wt)
2026-05-18 17:11:20 +02:00
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
2026-05-18 13:35:20 +02:00
def _patch_prim(d, vc):
2026-05-18 17:11:20 +02:00
# Patch int_decimal_value (field 6) or int_hexadecimal_value (field 7),
# whichever is present — AAPT2 may use either.
2026-05-18 13:35:20 +02:00
out = bytearray()
for fn, wt, v in _parse(d):
2026-05-18 17:11:20 +02:00
out += _enc(fn, 0, vc) if (fn in (6, 7) and wt == 0) else _enc(fn, wt, v)
2026-05-18 13:35:20 +02:00
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):
2026-05-18 17:49:10 +02:00
out += _enc(1, 2, _patch_elem(v, vc)) if fn == 1 else _enc(fn, wt, v)
2026-05-18 13:35:20 +02:00
return bytes(out)
2026-05-18 17:23:44 +02:00
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()}")
2026-05-18 17:11:20 +02:00
def _read_vc_from_node(d):
"""Read versionCode from XmlNode proto bytes. Returns int or None."""
for fn, wt, v in _parse(d):
2026-05-18 17:49:10 +02:00
if fn == 1 and wt == 2: # XmlElement
2026-05-18 17:11:20 +02:00
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
2026-05-18 13:35:20 +02:00
def patch(src, dst, vc):
with zipfile.ZipFile(src) as z:
mf = z.read(MANIFEST)
2026-05-18 17:11:20 +02:00
orig_vc = _read_vc_from_node(mf)
2026-05-18 17:23:44 +02:00
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}")
2026-05-18 17:11:20 +02:00
print(f"Original versionCode in manifest: {orig_vc}")
2026-05-18 13:35:20 +02:00
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)
2026-05-18 17:11:20 +02:00
# 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}")
2026-05-18 13:35:20 +02:00
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]))
`
2026-05-17 13:20:26 +02:00
type Ci struct {
2026-06-04 17:35:08 +02:00
Source * dagger . Directory
FlutterVersion string
2026-05-17 13:20:26 +02:00
}
2026-05-16 00:20:09 +02:00
2026-05-17 13:20:26 +02:00
func New (
2026-06-04 17:35:08 +02:00
ctx context . Context ,
2026-05-17 13:20:26 +02:00
// +defaultPath=".."
source * dagger . Directory ,
2026-06-04 17:35:08 +02:00
) ( * Ci , error ) {
fvmrcContents , err := source . File ( ".fvmrc" ). Contents ( ctx )
if err != nil {
return nil , fmt . Errorf ( "failed to read .fvmrc: %w" , err )
}
var fvmrc struct {
Flutter string `json:"flutter"`
}
if err := json . Unmarshal ([] byte ( fvmrcContents ), & fvmrc ); err != nil {
return nil , fmt . Errorf ( "failed to parse .fvmrc: %w" , err )
}
if fvmrc . Flutter == "" {
return nil , fmt . Errorf ( ".fvmrc is missing the 'flutter' field" )
}
2026-05-17 13:20:26 +02:00
return & Ci {
2026-06-04 17:35:08 +02:00
FlutterVersion : fvmrc . Flutter ,
2026-05-17 13:20:26 +02:00
Source : source . Filter ( dagger . DirectoryFilterOpts {
Include : [] string {
2026-06-04 17:35:08 +02:00
".fvmrc" ,
2026-05-17 13:20:26 +02:00
"lib/" ,
"test/" ,
"assets/" ,
"scripts/" ,
"pubspec.yaml" ,
2026-05-19 10:52:57 +02:00
"pubspec.lock" ,
2026-05-17 13:20:26 +02:00
"analysis_options.yaml" ,
"linux/" ,
"android/" ,
"integration_test/" ,
"drift_schemas/" ,
"stalwart-dev/" ,
2026-05-17 18:18:16 +02:00
"website/" ,
2026-05-17 13:20:26 +02:00
},
}),
2026-06-04 17:35:08 +02:00
}, nil
2026-05-17 13:20:26 +02:00
}
2026-05-17 07:15:12 +02:00
2026-05-21 06:35:14 +02:00
// 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 {
2026-05-16 00:20:09 +02:00
return dag . Container ().
2026-06-04 17:35:08 +02:00
From ( "ghcr.io/cirruslabs/flutter:" + m . FlutterVersion ).
2026-05-22 15:37:12 +02:00
WithExec ([] string { "apt-get" , "-qq" , "update" }).
2026-05-24 18:39:23 +02:00
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" }).
2026-05-22 15:19:05 +02:00
WithExec ([] string { "useradd" , "-m" , "-s" , "/bin/bash" , "ci" }).
2026-05-22 15:09:42 +02:00
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" ).
2026-05-22 15:37:12 +02:00
WithExec ([] string { "/bin/sh" , "-c" ,
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
2026-05-24 14:30:07 +02:00
`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" })
2026-05-19 10:52:57 +02:00
}
2026-05-21 06:35:14 +02:00
// Base is the Flutter toolchain container with mutable cache mounts attached.
2026-05-23 10:11:08 +02:00
// Use for Android/Gradle builds that need the Gradle cache.
2026-05-21 06:35:14 +02:00
func ( m * Ci ) Base () * dagger . Container {
return m . toolchain ().
2026-05-22 15:55:30 +02:00
WithMountedCache ( "/home/ci/.gradle" , dag . CacheVolume ( "gradle-cache" ), dagger . ContainerWithMountedCacheOpts { Owner : "ci" })
2026-05-21 06:35:14 +02:00
}
2026-05-19 16:59:19 +02:00
// pubGetLayer runs flutter pub get with only pubspec.yaml + pubspec.lock as
2026-05-19 18:33:20 +02:00
// 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.
2026-05-23 10:11:08 +02:00
// Packages land in the execution-cache snapshot (not a named volume) so that
// dagger prune can reclaim space from stale pubspec.lock snapshots.
2026-05-19 16:59:19 +02:00
func ( m * Ci ) pubGetLayer () * dagger . Container {
pubspecOnly := m . Source . Filter ( dagger . DirectoryFilterOpts {
Include : [] string { "pubspec.yaml" , "pubspec.lock" },
})
2026-05-21 06:35:14 +02:00
return m . toolchain ().
2026-05-22 15:09:42 +02:00
WithDirectory ( "/src" , pubspecOnly , dagger . ContainerWithDirectoryOpts { Owner : "ci" }).
2026-05-19 16:59:19 +02:00
WithWorkdir ( "/src" ).
2026-05-21 14:51:56 +02:00
WithExec ([] string { "/bin/bash" , "-c" ,
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
2026-05-23 17:25:08 +02:00
`grep -vE '^(\+|Downloading packages)' "$tmp" || true` }).
2026-05-19 17:39:20 +02:00
WithExec ([] string { "python3" , "-c" ,
2026-05-19 18:33:20 +02:00
"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" })
2026-05-19 16:59:19 +02:00
}
2026-05-22 12:23:52 +02:00
// 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" },
})
2026-05-19 16:59:19 +02:00
return m . pubGetLayer ().
2026-05-22 15:09:42 +02:00
WithDirectory ( "/src" , codegenSrc , dagger . ContainerWithDirectoryOpts { Owner : "ci" }).
2026-05-22 12:23:52 +02:00
WithWorkdir ( "/src" ).
2026-05-21 14:51:56 +02:00
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; }; ` +
2026-05-23 17:25:08 +02:00
`grep -vE '^\[.*s\] \|' "$tmp" || true` })
2026-05-19 10:52:57 +02:00
}
2026-05-22 12:23:52 +02:00
// 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" },
2026-05-22 15:09:42 +02:00
}), dagger . ContainerWithDirectoryOpts { Owner : "ci" })
2026-05-22 12:23:52 +02:00
}
2026-05-19 10:52:57 +02:00
// 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/" },
})
}
2026-05-21 17:20:26 +02:00
// 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/" },
})
}
2026-05-25 18:50:25 +02:00
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
// Gradle dependencies survive across Dagger execution-cache misses.
func ( m * Ci ) androidBase () * dagger . Container {
return m . setup ( m . androidSrc ()).
WithMountedCache ( "/home/ci/.gradle" , dag . CacheVolume ( "gradle-cache" ),
dagger . ContainerWithMountedCacheOpts { Owner : "ci" })
}
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
func ( m * Ci ) firebaseBase () * dagger . Container {
return m . setup ( m . firebaseSrc ()).
WithMountedCache ( "/home/ci/.gradle" , dag . CacheVolume ( "gradle-cache" ),
dagger . ContainerWithMountedCacheOpts { Owner : "ci" })
}
2026-05-19 10:52:57 +02:00
// 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/" },
})
2026-05-16 00:20:09 +02:00
}
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" }).
2026-05-23 17:10:11 +02:00
WithExec ([] string { "sh" , "-c" , "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -" }).
2026-05-17 10:14:40 +02:00
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
2026-05-24 13:00:04 +02:00
func ( m * Ci ) Deployer ( sshKey * dagger . Secret , knownHosts * dagger . Secret ) * dagger . Container {
2026-05-17 10:17:40 +02:00
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-06-04 07:15:04 +02:00
// Create .ssh with strict permissions before Dagger mounts anything there,
// so the directory is 700 (not Dagger's default 755).
WithExec ([] string { "sh" , "-c" , "mkdir -p /root/.ssh && chmod 700 /root/.ssh" }).
// Mount the raw key outside .ssh so Dagger cannot override the directory
// permissions we just set above.
WithMountedSecret ( "/tmp/id_ed25519.raw" , sshKey , dagger . ContainerWithMountedSecretOpts { Mode : 0600 }).
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
// Using Python3 (not tr) changes the Dagger cache key so stale cached
// results from the old tr-based step are not reused.
WithExec ([] string { "python3" , "-c" ,
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)" }).
2026-05-24 13:00:04 +02:00
WithMountedSecret ( "/root/.ssh/known_hosts" , knownHosts , dagger . ContainerWithMountedSecretOpts { Mode : 0644 }).
WithEnvVariable ( "RSYNC_RSH" , "ssh -i /root/.ssh/id_ed25519" )
2026-05-17 10:17:40 +02:00
}
2026-05-19 10:52:57 +02:00
// Stalwart mail server service for backend and integration tests.
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Stalwart () * dagger . Service {
2026-05-19 10:52:57 +02:00
stalwartSrc := m . Source . Filter ( dagger . DirectoryFilterOpts {
Include : [] string { "stalwart-dev/" },
})
config := stalwartSrc . Directory ( "stalwart-dev" ). File ( "config.toml" )
2026-05-17 13:17:28 +02:00
2026-05-17 17:14:35 +02:00
dataDir := dag . Container ().
From ( "alpine:3.21" ).
WithExec ([] string { "apk" , "add" , "--no-cache" , "sqlite" }).
2026-05-17 13:17:28 +02:00
WithExec ([] string { "/bin/sh" , "-c" , "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart" }).
2026-05-17 16:01:42 +02:00
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');" }).
2026-05-17 17:14:35 +02:00
Directory ( "/tmp/stalwart" )
return dag . Container ().
From ( "stalwartlabs/stalwart:v0.14.1" ).
WithFile ( "/etc/stalwart/config.toml.orig" , config ).
2026-06-07 04:38:21 +02:00
WithExec ([] string { "/bin/sh" , "-c" , "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml" }).
2026-05-17 17:14:35 +02:00
WithDirectory ( "/tmp/stalwart" , dataDir ).
2026-05-17 13:17:28 +02:00
WithExposedPort ( 8080 ). // JMAP
WithExposedPort ( 1430 ). // IMAP
WithExposedPort ( 1025 ). // SMTP
WithExposedPort ( 4190 ). // ManageSieve
2026-05-17 16:01:42 +02:00
WithEntrypoint ([] string { "stalwart" , "--config" , "/etc/stalwart/config.toml" }).
2026-05-17 13:17:28 +02:00
AsService ()
}
2026-05-19 10:52:57 +02:00
// WithStalwart binds the Stalwart service and sets test environment variables.
2026-05-17 16:01:42 +02:00
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" )
}
2026-05-19 10:52:57 +02:00
// CheckHygiene checks that no forbidden home-directory files are in the source.
2026-05-17 13:20:26 +02:00
func ( m * Ci ) CheckHygiene ( ctx context . Context ) ( string , error ) {
return m . Base ().
2026-05-22 15:09:42 +02:00
WithDirectory ( "/src" , m . Source , dagger . ContainerWithDirectoryOpts { Owner : "ci" }).
2026-05-19 10:52:57 +02:00
WithWorkdir ( "/src" ).
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 )
}
2026-05-19 10:52:57 +02:00
// CheckLayers enforces that ui/ does 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-22 15:09:42 +02:00
WithDirectory ( "/src" , m . Source . Filter ( dagger . DirectoryFilterOpts { Include : [] string { "lib/" }}), dagger . ContainerWithDirectoryOpts { Owner : "ci" }).
2026-05-19 10:52:57 +02:00
WithWorkdir ( "/src" ).
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-19 10:52:57 +02:00
// Format runs dart format check.
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Format ( ctx context . Context ) ( string , error ) {
2026-05-19 10:52:57 +02:00
return m . setup ( m . checkSrc ()).
2026-05-17 08:51:17 +02:00
WithExec ([] string { "dart" , "format" , "--output=none" , "--set-exit-if-changed" , "lib" , "test" }).
Stdout ( ctx )
}
2026-06-05 11:50:49 +02:00
// FormatWrite formats Dart files and exports the modified /src directory.
func ( m * Ci ) FormatWrite () * dagger . Directory {
return m . setup ( m . checkSrc ()).
WithExec ([] string { "dart" , "format" , "lib" , "test" }).
Directory ( "/src" )
}
// Analyze runs static analysis with dart analyze --fatal-infos.
func ( m * Ci ) Analyze ( ctx context . Context ) ( string , error ) {
return m . setup ( m . checkSrc ()).
WithExec ([] string { "dart" , "analyze" , "--fatal-infos" }).
Stdout ( ctx )
}
// Codegen runs build_runner and exports the modified /src directory.
func ( m * Ci ) Codegen () * dagger . Directory {
return m . codegenBase (). Directory ( "/src" )
}
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
func ( m * Ci ) AnalyzeFix () * dagger . Directory {
return m . setup ( m . checkSrc ()).
WithExec ([] string { "dart" , "fix" , "--apply" }).
Directory ( "/src" )
}
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
func ( m * Ci ) CheckFast ( ctx context . Context ) ( string , error ) {
ctx , cancel := context . WithTimeout ( ctx , 15 * time . Minute )
defer cancel ()
var eg errgroup . Group
eg . Go ( func () error {
_ , err := m . CheckHygiene ( ctx )
return err
})
eg . Go ( func () error {
_ , err := m . CheckLayers ( ctx )
return err
})
eg . Go ( func () error {
_ , err := m . Format ( ctx )
return err
})
eg . Go ( func () error {
_ , err := m . Analyze ( ctx )
return err
})
eg . Go ( func () error {
_ , err := m . CheckGenerated ( ctx )
return err
})
eg . Go ( func () error {
_ , err := m . Coverage ( ctx )
return err
})
if err := eg . Wait (); err != nil {
return "" , err
}
return "All fast checks passed!" , nil
}
2026-06-04 13:35:38 +02:00
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
2026-06-07 05:30:43 +02:00
// It reuses the codegenBase() output instead of running build_runner a second time,
// diffing committed generated files against the freshly built ones.
2026-06-04 13:35:38 +02:00
func ( m * Ci ) CheckGenerated ( ctx context . Context ) ( string , error ) {
2026-06-07 05:30:43 +02:00
fresh := m . codegenBase (). Directory ( "/src" )
2026-05-22 12:23:52 +02:00
return m . pubGetLayer ().
2026-06-07 05:30:43 +02:00
WithDirectory ( "/committed" , m . checkSrc (), dagger . ContainerWithDirectoryOpts { Owner : "ci" }).
WithDirectory ( "/generated" , fresh , dagger . ContainerWithDirectoryOpts { Owner : "ci" }).
2026-05-21 14:51:56 +02:00
WithExec ([] string { "/bin/bash" , "-c" ,
2026-06-07 05:30:43 +02:00
`stale=$(find /committed -name '*.g.dart' -o -name '*.mocks.dart' | ` +
`while IFS= read -r f; do rel="${f#/committed/}"; diff -q "$f" "/generated/$rel" >/dev/null 2>&1 || echo "$rel"; done); ` +
`if [ -n "$stale" ]; then ` +
`echo "ERROR: Generated files are out of date — run: dart run build_runner build"; echo "$stale"; exit 1; ` +
`else echo "Generated files are up to date."; fi` }).
2026-05-17 08:51:17 +02:00
Stdout ( ctx )
}
2026-06-04 19:34:53 +02:00
// Coverage runs unit and widget tests with coverage gate.
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Coverage ( ctx context . Context ) ( string , error ) {
2026-05-19 10:52:57 +02:00
return m . setup ( m . checkSrc ()).
2026-05-21 14:51:56 +02:00
WithExec ([] string { "/bin/bash" , "-c" ,
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
2026-06-04 19:34:53 +02:00
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
2026-05-21 14:51:56 +02:00
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"` }).
2026-05-17 09:15:53 +02:00
WithExec ([] string { "dart" , "scripts/check_coverage.dart" }).
Stdout ( ctx )
}
2026-05-19 10:52:57 +02:00
// TestBackend runs IMAP/JMAP sync tests against a live Stalwart instance.
2026-05-17 16:01:42 +02:00
func ( m * Ci ) TestBackend ( ctx context . Context ) ( string , error ) {
2026-05-19 10:52:57 +02:00
return m . WithStalwart ( m . setup ( m . backendSrc ())).
2026-05-21 14:51:56 +02:00
WithExec ([] string { "/bin/bash" , "-c" ,
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
2026-06-07 04:24:10 +02:00
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
2026-05-21 14:51:56 +02:00
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"` }).
2026-05-17 16:01:42 +02:00
Stdout ( ctx )
}
2026-05-19 10:52:57 +02:00
// TestIntegration runs UI integration tests via Xvfb.
2026-05-17 16:01:42 +02:00
func ( m * Ci ) TestIntegration ( ctx context . Context ) ( string , error ) {
2026-05-19 10:52:57 +02:00
return m . WithStalwart ( m . setup ( m . integrationSrc ())).
2026-05-17 16:01:42 +02:00
WithEnvVariable ( "LIBGL_ALWAYS_SOFTWARE" , "1" ).
2026-05-21 14:51:56 +02:00
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"` }).
2026-05-17 16:01:42 +02:00
Stdout ( ctx )
}
2026-05-19 10:52:57 +02:00
// TestSyncReliability runs the sync reliability runner.
2026-05-17 16:01:42 +02:00
func ( m * Ci ) TestSyncReliability ( ctx context . Context ) ( string , error ) {
2026-05-19 10:52:57 +02:00
return m . WithStalwart ( m . setup ( m . backendSrc ())).
2026-05-21 14:51:56 +02:00
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"` }).
2026-05-17 16:01:42 +02:00
Stdout ( ctx )
}
2026-06-06 05:29:40 +02:00
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
func ( m * Ci ) ChaosMonkeyBackend ( ctx context . Context ) ( string , error ) {
return m . WithStalwart ( m . setup ( m . backendSrc ())).
WithExec ([] string { "/bin/bash" , "-c" ,
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
2026-06-07 04:24:10 +02:00
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
2026-06-06 05:29:40 +02:00
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"` }).
Stdout ( ctx )
}
2026-05-19 10:52:57 +02:00
// Check runs the full check suite.
2026-05-17 13:20:26 +02:00
func ( m * Ci ) Check ( ctx context . Context ) ( string , error ) {
2026-05-21 11:53:49 +02:00
ctx , cancel := context . WithTimeout ( ctx , 30 * time . Minute )
defer cancel ()
2026-06-03 13:07:37 +02:00
// Run cheap structural checks in parallel for faster fail detection.
var fastEg errgroup . Group
fastEg . Go ( func () error {
_ , err := m . CheckHygiene ( ctx )
return err
})
fastEg . Go ( func () error {
_ , err := m . CheckLayers ( ctx )
return err
})
if err := fastEg . Wait (); err != nil {
return "" , err
2026-05-16 00:20:09 +02:00
}
2026-06-07 04:38:35 +02:00
// Run format, analyze, generated-code check, and coverage in parallel —
// they all share the same setup base and have no dependencies on each other.
var analyze , mocks , coverage string
var checkEg errgroup . Group
checkEg . Go ( func () error {
setup := m . setup ( m . checkSrc ())
_ , err := setup . WithExec ([] string { "dart" , "format" , "--output=none" , "--set-exit-if-changed" , "lib" , "test" }). Stdout ( ctx )
return err
})
checkEg . Go ( func () error {
setup := m . setup ( m . checkSrc ())
var err error
analyze , err = setup . WithExec ([] string { "dart" , "analyze" , "--fatal-infos" }). Stdout ( ctx )
return err
})
checkEg . Go ( func () error {
var err error
mocks , err = m . CheckGenerated ( ctx )
return err
})
checkEg . Go ( func () error {
var err error
coverage , err = m . Coverage ( ctx )
return err
})
if err := checkEg . Wait (); err != nil {
return "" , err
2026-05-16 00:20:09 +02:00
}
2026-06-03 13:07:37 +02:00
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
// sibling via context — which would surface as "context canceled" in dagger
// output and trigger spurious retries in check-dagger.
2026-05-20 08:58:55 +02:00
var testBackend , testIntegration string
2026-06-03 13:07:37 +02:00
var eg errgroup . Group
2026-05-20 08:58:55 +02:00
eg . Go ( func () error {
var e error
2026-06-03 13:07:37 +02:00
testBackend , e = m . TestBackend ( ctx )
2026-05-20 08:58:55 +02:00
return e
})
eg . Go ( func () error {
var e error
2026-06-03 13:07:37 +02:00
testIntegration , e = m . TestIntegration ( ctx )
2026-05-20 08:58:55 +02:00
return e
})
if err := eg . Wait (); err != nil {
return "" , err
2026-05-17 16:01:42 +02:00
}
2026-05-17 18:04:25 +02:00
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
2026-05-16 00:20:09 +02:00
}
2026-05-19 10:52:57 +02:00
// GenerateBuildHistory scans the remote server and produces Hugo content.
2026-05-17 10:16:38 +02:00
func ( m * Ci ) GenerateBuildHistory (
ctx context . Context ,
sshKey * dagger . Secret ,
2026-05-24 13:00:04 +02:00
knownHosts * dagger . Secret ,
2026-05-17 10:16:38 +02:00
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-24 13:00:04 +02:00
WithMountedSecret ( "/root/.ssh/known_hosts" , knownHosts , dagger . ContainerWithMountedSecretOpts { Mode : 0644 }).
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-19 10:52:57 +02:00
// BuildWebsite builds the Hugo-based website.
2026-05-17 10:16:38 +02:00
func ( m * Ci ) BuildWebsite (
ctx context . Context ,
sshKey * dagger . Secret ,
2026-05-24 13:00:04 +02:00
knownHosts * dagger . Secret ,
2026-05-17 10:16:38 +02:00
sshUser string ,
sshHost string ,
2026-06-03 16:43:26 +02:00
// +optional
commitHash string ,
2026-05-17 10:16:38 +02:00
) * dagger . Directory {
2026-05-24 13:00:04 +02:00
buildHistory := m . GenerateBuildHistory ( ctx , sshKey , knownHosts , sshUser , sshHost )
2026-05-17 10:16:38 +02:00
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-06-03 16:43:26 +02:00
hugo := m . Hugo ().
2026-05-17 10:14:40 +02:00
WithDirectory ( "/src" , websiteSource ).
2026-06-03 16:43:26 +02:00
WithWorkdir ( "/src/website" )
if commitHash != "" {
hugo = hugo . WithEnvVariable ( "HUGO_PARAMS_GITVERSION" , commitHash )
}
return hugo .
2026-05-17 10:14:40 +02:00
WithExec ([] string { "hugo" , "--minify" }).
Directory ( "public" )
}
2026-05-19 10:52:57 +02:00
// PublishWebsite builds and deploys the website to the remote server.
2026-05-17 10:17:40 +02:00
func ( m * Ci ) PublishWebsite (
ctx context . Context ,
sshKey * dagger . Secret ,
2026-05-24 13:00:04 +02:00
knownHosts * dagger . Secret ,
2026-05-17 10:17:40 +02:00
sshUser string ,
sshHost string ,
2026-06-03 16:43:26 +02:00
// +optional
commitHash string ,
2026-05-17 10:17:40 +02:00
) ( string , error ) {
2026-06-03 16:43:26 +02:00
public := m . BuildWebsite ( ctx , sshKey , knownHosts , sshUser , sshHost , commitHash )
2026-05-17 10:17:40 +02:00
2026-05-24 13:00:04 +02:00
return m . Deployer ( sshKey , knownHosts ).
2026-05-17 10:17:40 +02:00
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-19 10:52:57 +02:00
// BuildLinux builds the Linux release bundle.
2026-05-17 13:20:26 +02:00
func ( m * Ci ) BuildLinux () * dagger . Directory {
2026-05-19 10:52:57 +02:00
return m . setup ( m . linuxSrc ()).
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
}
2026-05-19 10:52:57 +02:00
// BuildLinuxRelease builds the Linux release bundle.
2026-05-25 15:10:12 +02:00
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 )
}
2026-05-19 10:52:57 +02:00
return m . setup ( m . linuxSrc ()).
2026-05-25 15:10:12 +02:00
WithExec ( args ).
2026-05-16 00:20:09 +02:00
Directory ( "build/linux/x64/release/bundle" )
}
2026-05-19 10:52:57 +02:00
// DeployLinux packages and deploys the Linux release to the server.
2026-05-17 10:19:23 +02:00
func ( m * Ci ) DeployLinux (
ctx context . Context ,
sshKey * dagger . Secret ,
2026-05-24 13:00:04 +02:00
knownHosts * dagger . Secret ,
2026-05-17 10:19:23 +02:00
sshUser string ,
sshHost string ,
commitHash string ,
) ( string , error ) {
2026-05-25 15:10:12 +02:00
bundle := m . BuildLinuxRelease ( commitHash )
2026-05-17 10:19:23 +02:00
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 )
2026-05-24 13:00:04 +02:00
return m . Deployer ( sshKey , knownHosts ).
2026-05-17 10:19:23 +02:00
WithDirectory ( "/bundle" , bundle ).
WithExec ([] string { "/bin/sh" , "-c" , fmt . Sprintf ( "tar -czf /tmp/%s -C /bundle ." , tarball )}).
2026-05-24 13:00:04 +02:00
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 )}).
2026-05-17 10:19:23 +02:00
Stdout ( ctx )
}
2026-05-19 10:52:57 +02:00
// setupKeystore decodes the base64 keystore into the android build container.
2026-05-18 07:49:45 +02:00
func ( m * Ci ) setupKeystore ( keystoreBase64 * dagger . Secret , keystorePassword * dagger . Secret ) * dagger . Container {
2026-05-25 18:50:25 +02:00
return m . androidBase ().
2026-05-18 07:49:45 +02:00
WithSecretVariable ( "ANDROID_KEYSTORE_BASE64" , keystoreBase64 ).
WithSecretVariable ( "ANDROID_KEYSTORE_PASSWORD" , keystorePassword ).
2026-06-05 09:00:26 +02:00
WithExec ([] string { "/bin/sh" , "-c" , `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks` }).
WithEnvVariable ( "ANDROID_KEYSTORE_PATH" , "/tmp/upload-keystore.jks" )
2026-05-18 07:49:45 +02:00
}
2026-05-19 10:52:57 +02:00
// BuildAndroidApk builds a release APK signed with the upload key.
2026-05-25 15:10:12 +02:00
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 )
}
2026-05-18 07:49:45 +02:00
return m . setupKeystore ( keystoreBase64 , keystorePassword ).
2026-05-25 15:10:12 +02:00
WithExec ( args ).
2026-05-17 10:19:23 +02:00
File ( "build/app/outputs/flutter-apk/app-release.apk" )
}
2026-05-19 10:52:57 +02:00
// DeployApk builds and deploys the APK to the server.
2026-05-17 10:19:23 +02:00
func ( m * Ci ) DeployApk (
ctx context . Context ,
sshKey * dagger . Secret ,
2026-05-24 13:00:04 +02:00
knownHosts * dagger . Secret ,
2026-05-17 10:19:23 +02:00
sshUser string ,
sshHost string ,
commitHash string ,
2026-05-18 07:49:45 +02:00
keystoreBase64 * dagger . Secret ,
keystorePassword * dagger . Secret ,
2026-05-18 08:18:33 +02:00
buildNumber string ,
2026-05-17 10:19:23 +02:00
) ( string , error ) {
2026-05-25 15:10:12 +02:00
apk := m . BuildAndroidApk ( keystoreBase64 , keystorePassword , buildNumber , commitHash )
2026-05-17 10:19:23 +02:00
datePath := time . Now (). Format ( "2006/01/02" )
remoteDir := fmt . Sprintf ( "public_html/builds/%s" , datePath )
apkName := fmt . Sprintf ( "sharedinbox-mua-%s.apk" , commitHash )
2026-05-24 13:00:04 +02:00
return m . Deployer ( sshKey , knownHosts ).
2026-05-17 10:19:23 +02:00
WithFile ( "/tmp/app.apk" , apk ).
2026-05-24 13:00:04 +02:00
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 )}).
2026-05-17 10:19:23 +02:00
Stdout ( ctx )
}
2026-05-21 17:20:26 +02:00
// 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 {
2026-05-25 18:50:25 +02:00
built := m . firebaseBase ().
2026-06-10 10:07:02 +00:00
// `flutter build apk` spawns a Gradle daemon. When this WithExec ends the
// container is torn down and the daemon is killed, but its journal-cache
// lock file on the persistent gradle-cache volume keeps its dead PID — the
// next gradlew invocation then times out waiting for that lock. `gradlew
// --stop` shuts the daemon down gracefully so the lock is released before
// Dagger snapshots the layer.
WithExec ([] string { "/bin/bash" , "-c" ,
`flutter build apk --debug --no-pub && (cd android && ./gradlew --stop)` }).
2026-05-21 17:20:26 +02:00
WithWorkdir ( "/src/android" ).
2026-05-23 15:45:08 +02:00
// --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" }).
2026-05-21 17:34:41 +02:00
WithWorkdir ( "/src" ).
WithExec ([] string { "/bin/bash" , "-c" ,
2026-05-21 17:40:17 +02:00
`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` })
2026-05-21 17:20:26 +02:00
return dag . Directory ().
WithFile ( "app-debug.apk" ,
built . File ( "build/app/outputs/flutter-apk/app-debug.apk" )).
WithFile ( "app-debug-androidTest.apk" ,
2026-05-21 17:34:41 +02:00
built . File ( "app-debug-androidTest.apk" ))
2026-05-21 17:20:26 +02:00
}
// 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" ,
2026-05-22 16:31:14 +02:00
`auth_err=$(mktemp); trap 'rm -f "$auth_err"' EXIT; \
2026-05-23 10:54:25 +02:00
gcloud auth activate-service-account --key-file=<(echo "$FIREBASE_SA_KEY") 2>"$auth_err" \
2026-05-22 16:31:14 +02:00
|| { 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; }; \
2026-05-22 08:58:09 +02:00
out=$(gcloud firebase test android run \
2026-05-21 17:20:26 +02:00
--type instrumentation \
--app /apks/app-debug.apk \
--test /apks/app-debug-androidTest.apk \
2026-05-22 07:32:09 +02:00
--device model=oriole,version=33,locale=en,orientation=portrait \
2026-05-22 11:30:56 +02:00
--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"; }; \
2026-05-22 16:31:14 +02:00
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; }` }).
2026-05-21 17:20:26 +02:00
Stdout ( ctx )
}
2026-05-19 10:52:57 +02:00
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
2026-05-18 13:35:20 +02:00
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
2026-05-25 15:10:12 +02:00
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 )
}
2026-05-25 18:50:25 +02:00
return m . androidBase ().
2026-05-25 15:10:12 +02:00
WithExec ( args ).
2026-05-16 00:20:09 +02:00
File ( "build/app/outputs/bundle/release/app-release.aab" )
}
2026-05-17 10:20:33 +02:00
2026-05-21 06:41:04 +02:00
// 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 .
2026-05-22 15:09:42 +02:00
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" )
2026-05-21 06:41:04 +02:00
}
2026-06-08 18:55:58 +02:00
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
2026-05-18 08:18:33 +02:00
func ( m * Ci ) UploadToPlayStore (
2026-05-17 10:20:33 +02:00
ctx context . Context ,
2026-05-18 08:18:33 +02:00
aab * dagger . File ,
2026-05-17 10:20:33 +02:00
playStoreConfig * dagger . Secret ,
) ( string , error ) {
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" },
})
return dag . Container ().
From ( "python:3.12-alpine" ).
WithExec ([] string { "apk" , "add" , "--no-cache" , "curl" }).
2026-05-21 06:41:04 +02:00
WithMountedCache ( "/root/.cache/pip" , dag . CacheVolume ( "pip-cache" )).
2026-05-24 07:32:22 +02:00
WithExec ([] string { "pip" , "install" , "google-auth" , "requests" }).
2026-05-17 10:20:33 +02:00
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 )
}
2026-05-18 13:35:20 +02:00
// 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" )
}
2026-05-19 10:52:57 +02:00
// SignAndroidBundle signs an AAB with the release upload key via jarsigner.
2026-05-18 13:35:20 +02:00
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" ,
2026-05-18 14:17:41 +02:00
`[ -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 && \
2026-05-18 13:35:20 +02:00
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 ,
2026-05-25 15:10:12 +02:00
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string ,
2026-05-18 13:35:20 +02:00
) ( string , error ) {
versionCode := int ( time . Now (). Unix ())
2026-05-25 15:10:12 +02:00
aab := m . BuildAndroidRelease ( commitHash )
2026-05-18 13:35:20 +02:00
stamped := m . StampAndroidVersionCode ( aab , versionCode )
signed := m . SignAndroidBundle ( stamped , keystoreBase64 , keystorePassword )
return m . UploadToPlayStore ( ctx , signed , playStoreConfig )
}
2026-05-21 10:28:28 +02:00
2026-05-25 21:26:44 +02:00
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func ( m * Ci ) Renovate ( ctx context . Context , renovateToken * dagger . Secret ) ( string , error ) {
2026-05-26 18:18:02 +02:00
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
// hardcoded 20/100 values before launching renovate.
2026-05-26 18:39:13 +02:00
const patchCmd = `for f in \
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
done`
2026-05-25 21:26:44 +02:00
return dag . Container ().
2026-05-26 17:28:15 +02:00
From ( "renovate/renovate:43" ).
2026-05-25 21:26:44 +02:00
WithSecretVariable ( "RENOVATE_TOKEN" , renovateToken ).
2026-05-26 17:28:15 +02:00
WithEnvVariable ( "RENOVATE_PLATFORM" , "forgejo" ).
2026-05-25 21:26:44 +02:00
WithEnvVariable ( "RENOVATE_ENDPOINT" , "https://codeberg.org" ).
WithEnvVariable ( "RENOVATE_REPOSITORIES" , "guettli/sharedinbox" ).
2026-05-26 06:24:47 +02:00
WithEnvVariable ( "LOG_LEVEL" , "info" ).
2026-05-26 18:55:31 +02:00
WithUser ( "root" ).
2026-05-26 18:18:02 +02:00
WithExec ([] string { "/bin/sh" , "-c" , patchCmd }).
2026-05-26 18:55:31 +02:00
WithUser ( "ubuntu" ).
2026-05-25 21:26:44 +02:00
WithExec ([] string { "renovate" }).
Stdout ( ctx )
}
2026-05-21 10:28:28 +02:00
// 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 {
2026-06-04 17:35:08 +02:00
return fmt . Sprintf ( `# CI Pipeline Graph
2026-05-21 10:28:28 +02:00
2026-06-04 17:35:08 +02:00
` + "```" + `mermaid
2026-05-21 10:28:28 +02:00
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
2026-06-04 17:35:08 +02:00
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]` , m . FlutterVersion ) + `
2026-05-21 10:28:28 +02:00
pubGet["pubGetLayer\nflutter pub get"]
2026-05-22 12:23:52 +02:00
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
2026-05-21 10:28:28 +02:00
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
toolchain --> pubGet
2026-05-22 12:23:52 +02:00
pubGet --> codegen
2026-05-21 10:28:28 +02:00
pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"]
2026-06-04 13:35:38 +02:00
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
2026-05-22 12:23:52 +02:00
codegen --> fmt["Format"]
codegen --> analyze["Analyze"]
codegen --> coverage["Coverage\nunit tests + gate"]
codegen --> backend["TestBackend\nIMAP / JMAP"]
codegen --> integration["TestIntegration\nXvfb · Linux desktop"]
2026-05-21 10:28:28 +02:00
stalwart --> backend
stalwart --> integration
hygiene --> check {{ "✓ Check" }}
layers --> check
fmt --> check
analyze --> check
mocks --> check
coverage --> check
backend --> check
integration --> check
end
2026-05-24 08:30:10 +02:00
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
2026-05-21 10:28:28 +02:00
ciCheck["check"]
2026-05-24 08:30:10 +02:00
end
2026-05-21 10:28:28 +02:00
2026-05-24 08:30:10 +02:00
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
2026-05-21 10:28:28 +02:00
buildLinux --> pubWeb
deployPS --> pubWeb
2026-05-24 08:30:10 +02:00
deployApk --> pubWeb
2026-05-21 10:28:28 +02:00
end
check -- "task check-dagger" --> ciCheck
` + "```"
}