Skip to Content
InfraCI: Gradle tuning

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.properties before invoking gradlewworks, because Java’s Properties.load uses 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 EOF

Memory 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 lintVitalProdRelease

Tests 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:

  1. actions/setup-java@v4 with cache: 'gradle' — caches ~/.gradle/caches and ~/.gradle/wrapper keyed on lockfiles + wrapper version. Brings dependency-resolution time on a warm cache from ~2 min to ~10 s.
  2. actions/cache@v4 for .gradle and build-logic/build — caches the project-local Gradle state (configuration cache, build-logic compiled classes) keyed on gradle-wrapper.properties + all *.gradle.kts + everything under build-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:

JobStepsNotes
lintktlintCheck, detektRuns without Android SDK (INCLUDE_ANDROID=false). ~20 min timeout.
testBuild :server:compileKotlin + :shared:compileKotlinJvm, then :server:test + :shared:testCovers 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 in server/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@v4 with cache: 'npm' already covers dependency caching.

Pattern checklist for new Gradle workflows

When adding a new GitHub Actions job that runs ./gradlew:

  • concurrency block with cancel-in-progress: true (build) or false (deploy).
  • actions/setup-java@v4 with cache: 'gradle'.
  • actions/cache@v4 for .gradle + build-logic/build, keyed on lockfiles.
  • Append the CI override block to gradle.properties before any ./gradlew call.
  • Pass --no-daemon.
  • Skip test and lint* 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. Lower org.gradle.jvmargs and/or kotlin.daemon.jvmargs in the CI override block, or drop workers.max to 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 with configureondemand=true; harmless.
  • Cache misses on every run — Check that the cache key inputs haven’t changed unintentionally (e.g. a wrapper bump invalidates everything).