CI Gradle tuning
How the Android and Desktop publish workflows are tuned to fit a 7 GB GitHub-hosted runner without OOM-killing the build, and the conventions we follow when adding new Gradle-based CI jobs.
TL;DR
ubuntu-latest runners have ~7 GB RAM. Our dev gradle.properties asks for ~16 GB (-Xmx8096M Gradle daemon + -Xmx8072M Kotlin daemon) plus 4 parallel workers, which is fine on a workstation but gets the runner OOM-killed during KSP. GitHub then reports the failure as a plain The operation was canceled. with no stack trace — the kernel kills the process before Gradle can print anything.
The fix is to append CI-only overrides to gradle.properties at runtime, run with --no-daemon, and skip non-essential tasks.
Why we append to gradle.properties instead of using -D flags
The Gradle daemon reads org.gradle.jvmargs and kotlin.daemon.jvmargs only from gradle.properties, not from -D system properties on the gradlew command line. The same is true for kotlin.daemon.jvmargs. So:
./gradlew -Dorg.gradle.jvmargs=-Xmx4g …→ ignored by the daemon.- Appending to
gradle.propertiesbefore invokinggradlew→ works, because Java’sProperties.loaduses the last value for any duplicate key.
The dev values stay in the committed gradle.properties; the workflow appends a CI override block and the runtime sees only the lowered values.
The canonical CI override block
Both publish-android-release.yml and publish-desktop-release.yml use the same shape:
- name: Configure Gradle for CI
run: |
cat >> gradle.properties << 'EOF'
# ---- CI overrides ----
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
kotlin.daemon.jvmargs=-Xmx2048M
org.gradle.workers.max=2
org.gradle.parallel=false
org.gradle.configureondemand=true
EOFMemory budget: 4 GB Gradle + 2 GB Kotlin ≈ 6 GB peak, leaves headroom for the OS, KSP forks, and AAPT2.
configureondemand=true skips configuration of modules outside the requested task’s dependency graph — a meaningful win on this repo because settings.gradle.kts includes ~50 modules but :androidApp:assembleProdRelease only needs the Android subset.
--no-daemon in CI
We pass --no-daemon to every gradlew invocation in CI:
- The daemon is only useful when subsequent builds reuse it. Each CI job is one-shot, so the daemon just consumes ~500 MB for nothing.
- It also avoids stale-daemon issues when the runner is reused across jobs.
Skipping non-essential tasks in release builds
The Android release build adds:
-x test -x lint -x lintProdRelease -x lintVitalProdReleaseTests and lint should run in a separate CI job (PR check or scheduled), not on the publishing path. Skipping them in the release workflow trims minutes off each run and removes failure modes that would block a deploy for reasons unrelated to packaging.
If you add a new variant, double-check which lint* tasks Gradle generates for it and add them to the -x list.
Caching
Two layers:
actions/setup-java@v4withcache: 'gradle'— caches~/.gradle/cachesand~/.gradle/wrapperkeyed on lockfiles + wrapper version. Brings dependency-resolution time on a warm cache from ~2 min to ~10 s.actions/cache@v4for.gradleandbuild-logic/build— caches the project-local Gradle state (configuration cache, build-logic compiled classes) keyed ongradle-wrapper.properties+ all*.gradle.kts+ everything underbuild-logic/. A hit lets Gradle skip reconfiguring and recompiling the convention plugins.
The two caches are complementary: setup-java covers the user-home global cache; actions/cache covers project-local state.
Concurrency
Build workflows use cancel-in-progress: true so re-triggering a publish on the same ref kills the older run instead of double-billing.
Deploy workflows (publish-server-release, publish-web-release) deliberately use cancel-in-progress: false. Mid-flight cancellation of a deploy can leave the target service in a half-rolled-out state; better to queue.
CI pipeline overview (ci.yml)
The ci.yml workflow runs on every PR to main and every push to main:
| Job | Steps | Notes |
|---|---|---|
| lint | ktlintCheck, detekt | Runs without Android SDK (INCLUDE_ANDROID=false). ~20 min timeout. |
| test | Build :server:compileKotlin + :shared:compileKotlinJvm, then :server:test + :shared:test | Covers the modules with actual test coverage. ~30 min timeout. |
Both jobs apply the CI memory override block, --no-daemon, and configureondemand=true.
The release-publish workflows (publish-*.yml) intentionally skip lint and test — they are the packaging path. Quality gates are the responsibility of ci.yml.
What we deliberately do not touch
publish-server-release.yml— builds via Docker. Gradle runs inside the build container; memory tuning belongs inserver/Dockerfile, not the workflow. The Docker BuildKit GHA cache already gives us layer reuse.publish-web-release.yml— Node/npm, no Gradle.actions/setup-node@v4withcache: 'npm'already covers dependency caching.
Pattern checklist for new Gradle workflows
When adding a new GitHub Actions job that runs ./gradlew:
-
concurrencyblock withcancel-in-progress: true(build) orfalse(deploy). -
actions/setup-java@v4withcache: 'gradle'. -
actions/cache@v4for.gradle+build-logic/build, keyed on lockfiles. - Append the CI override block to
gradle.propertiesbefore any./gradlewcall. - Pass
--no-daemon. - Skip
testandlint*on the release path; run them in a separate job. - Set a
timeout-minutes(60 is a reasonable default for this repo).
Troubleshooting
The operation was canceled.with no stack trace — Almost always OOM. Lowerorg.gradle.jvmargsand/orkotlin.daemon.jvmargsin the CI override block, or dropworkers.maxto 1.Daemon will be stopped at the end of the build— Expected with--no-daemon; not a warning to act on.Configuration on demand is an incubating feature— Expected withconfigureondemand=true; harmless.- Cache misses on every run — Check that the cache key inputs haven’t changed unintentionally (e.g. a wrapper bump invalidates everything).