1059 lines
42 KiB
Go
1059 lines
42 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"dagger/ci/internal/dagger"
|
|
"encoding/json"
|
|
"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
|
|
FlutterVersion string
|
|
}
|
|
|
|
func New(
|
|
ctx context.Context,
|
|
// +defaultPath=".."
|
|
source *dagger.Directory,
|
|
) (*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")
|
|
}
|
|
return &Ci{
|
|
FlutterVersion: fvmrc.Flutter,
|
|
Source: source.Filter(dagger.DirectoryFilterOpts{
|
|
Include: []string{
|
|
".fvmrc",
|
|
"lib/",
|
|
"test/",
|
|
"assets/",
|
|
"scripts/",
|
|
"pubspec.yaml",
|
|
"pubspec.lock",
|
|
"analysis_options.yaml",
|
|
"linux/",
|
|
"android/",
|
|
"integration_test/",
|
|
"drift_schemas/",
|
|
"stalwart-dev/",
|
|
"website/",
|
|
},
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
// 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:"+m.FlutterVersion).
|
|
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/"},
|
|
})
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
// 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"}).
|
|
// 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)"}).
|
|
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\"/' /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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
|
// It reuses the codegenBase() output instead of running build_runner a second time,
|
|
// diffing committed generated files against the freshly built ones.
|
|
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
|
fresh := m.codegenBase().Directory("/src")
|
|
return m.pubGetLayer().
|
|
WithDirectory("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
|
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
|
WithExec([]string{"/bin/bash", "-c",
|
|
`if diff -rq --include='*.g.dart' --include='*.mocks.dart' /committed /generated; then ` +
|
|
`echo "Generated files are up to date."; ` +
|
|
`else echo "ERROR: Generated files are out of date — run: dart run build_runner build"; exit 1; ` +
|
|
`fi`}).
|
|
Stdout(ctx)
|
|
}
|
|
|
|
// Coverage runs unit and widget 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 test/widget --exclude-tags golden --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 --exclude-tags=nightly 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)
|
|
}
|
|
|
|
// 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; ` +
|
|
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$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()
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
var testBackend, testIntegration string
|
|
var eg errgroup.Group
|
|
eg.Go(func() error {
|
|
var e error
|
|
testBackend, e = m.TestBackend(ctx)
|
|
return e
|
|
})
|
|
eg.Go(func() error {
|
|
var e error
|
|
testIntegration, e = m.TestIntegration(ctx)
|
|
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,
|
|
// +optional
|
|
commitHash 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)
|
|
|
|
hugo := m.Hugo().
|
|
WithDirectory("/src", websiteSource).
|
|
WithWorkdir("/src/website")
|
|
if commitHash != "" {
|
|
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
|
|
}
|
|
return hugo.
|
|
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,
|
|
// +optional
|
|
commitHash string,
|
|
) (string, error) {
|
|
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
|
|
|
|
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.androidBase().
|
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
|
|
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/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.firebaseBase().
|
|
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.androidBase().
|
|
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)
|
|
}
|
|
|
|
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
|
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
|
// 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.
|
|
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`
|
|
return dag.Container().
|
|
From("renovate/renovate:43").
|
|
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
|
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
|
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
|
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
|
WithEnvVariable("LOG_LEVEL", "info").
|
|
WithUser("root").
|
|
WithExec([]string{"/bin/sh", "-c", patchCmd}).
|
|
WithUser("ubuntu").
|
|
WithExec([]string{"renovate"}).
|
|
Stdout(ctx)
|
|
}
|
|
|
|
// 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 fmt.Sprintf(`# CI Pipeline Graph
|
|
|
|
`+"```"+`mermaid
|
|
flowchart TD
|
|
subgraph dagger ["Dagger · Check pipeline"]
|
|
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
|
|
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["CheckGenerated\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
|
|
` + "```"
|
|
}
|