Engineering practice
Build and tooling
Gradle convention plugins, the quality gates, and the one command that matters.
# Convention plugins
Build configuration lives in build-logic as a set of convention plugins rather
than being copied across thirty build files:
| Plugin | Applied to |
|---|---|
quiblo.android.application | The two apps |
quiblo.android.library | Android libraries |
quiblo.android.feature | Feature modules — library plus Compose |
quiblo.android.core | Core modules |
quiblo.jvm.library | Pure-JVM modules, and where enforceNoCompose() lives |
quiblo.detekt | Static analysis |
Test dependencies — JUnit 5, coroutines-test, Turbine, MockK — come from the library and
application plugins, so a module with tests never has to declare them. That sounds
bureaucratic until you learn that :app once had no test framework on its
classpath at all, so any test added under app/src/test simply failed to
compile.
# The gate
One command, and it is the thing a change has to pass:
./gradlew build detektAll coverageAll lint
It compiles everything, runs every unit test, runs detekt across all modules, checks coverage thresholds and runs Android Lint.
CI runs the same gate on every push and every pull request, and adds three checks that only make sense against the whole tree:
- Wrapper validation — the Gradle wrapper jar is checked against its published checksums.
- A leak guard — no playlist, provider host or credential-bearing URL may enter the repository. Cheap to check on a commit, expensive to remove from history later.
- A licence-header check over every tracked Kotlin file, which is how a new module joins without one.
Run the gate locally before pushing anyway. CI is the backstop, not the loop — waiting ten minutes to be told about a formatting violation is a bad way to spend an afternoon.
# What the first CI run found
A workflow that has never executed is not a safety net; it is a document describing one.
This one is worth stating plainly because the failures were not subtle, and none of them
could have been found by reading. The very first run to execute failed immediately at
./gradlew assemble with "Permission denied" — gradlew had been
committed as 100644 from Windows, where the executable bit does not exist, and a
Linux runner honours what the index says.
The second lesson is older and worse: the acceptance sweep described the licence-header
check as "re-checked on every CI run" while it was in fact only ever run by hand. And
detektAll was broken for its entire existence without anyone knowing, because
nothing ever invoked it.
The general form: an unrun check and a passing check are indistinguishable from the inside. Both produce no failures. It is worth knowing which one you have.
# Three build gotchas
Do not pin the daemon's JVM in a file that gets committed. Gradle reads
org.gradle.java.home and gradle/gradle-daemon-jvm.properties, and a
JDK that happens to be installed on one machine is not a property of the project. Pinned to a
JDK newer than the toolchain expects, detekt fails on class-file versions it cannot read —
locally, while CI stays green on its own JDK, which is the most confusing shape a build
failure can take.
The toolchain declaration is the place to say which Java a build needs. Anything else is one machine's configuration wearing the repository's clothes.
The Gradle daemon outlives your shell. If unit tests die with
Could not find or load main class <a fragment of your PATH>, the daemon is
holding an inherited PATH containing a stray quote. ./gradlew --stop and let a
fresh one start. Because the daemon is long-lived, a bad PATH from one shell persists across
sessions — including into a session that has done nothing wrong.
Room's schema JSON is generated and committed. After changing an entity, build the database module so the new schema file is written, and commit it. Room validates the live database against it at open time, so a mismatch fails at launch rather than subtly.