Primer
Yukta has 27 Gradle modules (a module is one separately built piece of the product: the engine, the web layer, one plugin and so on). If each module wrote its own build file, they would drift: one would use another Java version, another would skip the bug checks, a third would allow a licence the company cannot ship. build-logic prevents that. It is a small build of its own, included by the root build, that defines the rules once as convention plugins (a convention plugin is a reusable bundle of build settings that a module switches on with one line). Every module then applies the bundle and inherits the rules.Where build-logic sits
The vocabulary in eight cards
One example, end to end
A developer adds a new plugin module and opens a pull request. Click a step to see what happens, why it is built that way, and what can go wrong.Add the module
Apply conventions
Compile
Format and lint
Test and coverage
Scan and licences
CI runs the same
Merge
The build rules in plain words
Include
Apply
Resolve
Compile
Check
Measure
Prove in CI
Components
Catalog & Root Build
versions, modules, locking Holds the repository-wide facts the plugins read: every library and tool version, the list of modules, dependency locking and an on-demand Trivy task.-
gradle/libs.versions.toml is the version catalog. build-logic/settings.gradle.kts loads it with
from(files("../gradle/libs.versions.toml")), so the plugins read exactly the same file the modules do. Example: checkstyle = “13.2.0” is read by quality-conventions; springBoot = “4.1.1” by spring-conventions. - build-logic/build.gradle.kts lists the Gradle plugins the conventions need (Spotless, SpotBugs, the Lombok plugin, node-gradle, Spring dependency management, Error Prone, licensee) and takes each version from the catalog. A bump in the catalog therefore changes the plugin jars too.
-
The root settings.gradle.kts includes build-logic first, then lists 27 modules. The root build.gradle.kts sets the group, declares
dependencyLocking { lockAllConfigurations() }for every project, and registers a root-onlytrivytask (HIGH and CRITICAL findings fail it). Trivy is deliberately not part of check; CI owns scanning. -
gradle.properties holds the build switches: build cache on, parallel on with 8 workers max, configuration cache off (the Go tasks in cli are not compatible with it), a 4 GB Gradle heap, one shared project version (0.0.1-SNAPSHOT) and a leftover
netty.version=4.2.16.Finalnext to the catalog’snetty = 4.2.18.Final. The leftover no longer wins: spring-conventions setsnetty.versionfrom the catalog (checked on 2026-10-15 with dependencyInsight: boot resolves 4.2.18.Final). - Gradle itself is pinned by the wrapper (9.8.0). Dependabot proposes catalog and Action bumps weekly with a 7-day cooldown, grouped into one minor/patch PR.
- Guarantees. Every module and build-logic read one catalog, so the same tool version applies everywhere. The project version is defined in one place.
- State. Plain files in git. Nothing runs at run time. Lock files are declared but none are committed, so there is no stored record of resolved versions.
- Ceiling. A catalog or build-logic change makes CI build every module (the root path filter includes gradle/**, config/** and build-logic/**), so version bumps are still the most expensive pull requests. Since PR #391 that full build is split into four parallel shards (platform, infra, plugins, frontend) with shared caches.
- On failure. A typo in the catalog fails configuration of the whole build. A stale leftover (the Netty line in gradle.properties) can mislead a reader even though it no longer wins.
gradle/libs.versions.toml, gradle.properties: org.gradle.caching, org.gradle.parallel, org.gradle.workers.max=8, netty.version, version, ./gradlew trivy (on demand)
Plugin Hierarchy
who applies what Defines the six convention plugins and how they stack, so a module opts in with one line.-
The plugins are precompiled Kotlin scripts (
com.infenia.yukta.<name>.gradle.kts) in build-logic/src/main/kotlin. ‘Precompiled’ means Gradle compiles them once into real plugins instead of interpreting each one in every module. - They stack. library-conventions = java-library + spring-conventions. spring-conventions = java-conventions + quality-conventions + jacoco-conventions + Spring dependency management. A library module therefore needs only one line, and still gets every gate.
- Who applies what (checked in each build file): 24 modules apply library-conventions (core, web, mcp, messaging, plugin-api and 19 plugins). boot and ui apply spring-conventions directly (they are an application and a bundle of static files, not libraries). ui also applies node-conventions. cli applies only Gradle’s base plugin and wraps make targets for Go.
- In our view the name spring-conventions is misleading: it is the ‘standard Yukta module’ bundle, because even a module with no Spring code gets the Spring BOMs and test starters through it.
- The two remaining plugins are leaf bundles: java-conventions (toolchain, tests) and node-conventions (UI tooling). quality-conventions and jacoco-conventions are the heavy ones (about 200 and 150 lines).
- Guarantees. A module that applies library-conventions or spring-conventions gets every gate; there is no way to get the toolchain without the quality tools.
- State. None. These are build scripts evaluated at every Gradle run.
- Ceiling. Plugin logic runs at configuration time for each of the 27 projects; a heavy convention slows every build even for a one-module change.
- On failure. A compile error in any convention script fails the whole build before any module is configured. A module can still weaken itself in its own build file (see Risks).
id("com.infenia.yukta.library-conventions"), id("com.infenia.yukta.spring-conventions"), id("com.infenia.yukta.node-conventions"), includeBuild("build-logic")
Java & Test Setup
toolchain, Lombok, tests Sets how every module compiles and how its tests run.-
The Java toolchain is 25 and the compiler flag
releaseis also 25. A toolchain means Gradle looks for a matching JDK instead of using whatever runs Gradle.-parametersis added so method parameter names survive in the class files (Spring and Jackson need them for binding). - The freefair Lombok plugin wires Lombok into compilation. The repository’s lombok.config stops searching parent folders and tags generated code with @Generated, which is what lets JaCoCo ignore Lombok-written code.
- Dependencies come from Maven Central only (build-logic itself also uses the Gradle plugin portal). The only dependency added here is junit-platform-launcher at test runtime.
-
Every test task uses JUnit Platform with parallel forks:
maxParallelForks = max(1, CPUs / 2). Tests run with-XX:+UseParallelGC -XX:TieredStopAtLevel=1: ParallelGC has little overhead for short test runs, and stopping at the first compiler tier makes short-lived JVMs start tests sooner. Example: a 16-core laptop runs 8 test JVMs at a time. -
-PfailFaststops at the first failing test. The group is com.infenia.yukta and the version comes from gradle.properties. - Guarantees. Every module is compiled for Java 25 with the same flags, and every module’s tests run on JUnit 5.
- State. None.
- Ceiling. Test parallelism is capped by memory: each fork is its own JVM. Because tests run with the fast tier only, timing-sensitive behaviour measured in tests may differ from production.
- On failure. No JDK 25 on the machine fails the build at configuration: no auto-download resolver (such as foojay) is configured in either settings file.
-PfailFast, -Pcoverage (reports on every test run), lombok.config
Dependency Platform
BOMs, Netty import Pins the versions of third-party libraries through three imported BOMs, and adds the common test libraries.-
spring-conventions applies Spring’s dependency-management plugin and imports three BOMs in this order: Spring Boot (
springBoot4.1.1), Spring AI (springAi2.0.0) and Netty (netty4.2.18.Final). A module can then writeimplementation(libs.spring.boot.starter.webflux)with no version. - Netty comes last on purpose. Spring Boot pins an older Netty that lacks CVE fixes (the catalog and properties file name five CVEs), so the patched BOM is imported after it to win. Example: Netty is the network library under WebFlux; an outdated copy means every HTTP request passes through code with known holes.
-
History: the boot jar built on 2026-10-11, after the catalog moved to 4.2.18, still contained Netty 4.2.16 files, because
netty.version=4.2.16.Finalin gradle.properties overrode the Boot BOM’s own Netty property. spring-conventions now assigns that property from the catalog, and on 2026-10-15:boot:dependencyInsightshows io.netty:netty-codec-http at 4.2.18.Final. The stale line in gradle.properties is still there and nothing asserts the resolved version. - Every standard module also gets spring-boot-starter-test and reactor-test as test dependencies. Reactor-test is what makes StepVerifier available for the project’s reactive code.
- Guarantees. All modules resolve a library to the same version, because they all import the same three BOMs in the same order.
- State. None. Resolution happens per Gradle run; no lock file is stored.
- Ceiling. BOM imports are fine at this size. The ceiling is human: two places to set one version, and nothing checks that they agree.
- On failure. A BOM that moves a library to an incompatible major version breaks compile or run time in several modules at once. A wrong Netty version stays invisible because tests do not assert it.
springBoot, springAi, netty in gradle/libs.versions.toml, netty.version in gradle.properties, libs.spring.boot.starter.test, libs.reactor.test
Format & Static Analysis
Spotless, Checkstyle, PMD… Formats code and refuses code that breaks style or bug-pattern rules, using five tools whose rule files live in config/.-
Spotless formats Java with google-java-format (2-space indent, 100-character lines, long strings reflowed) and checks the Apache licence header from config/license/header.txt on Java, Kotlin build scripts and XML files.
./gradlew spotlessApplyrewrites files; check only verifies. The Java target excludes bin, build, out and node_modules. -
Checkstyle 13.2.0 uses config/checkstyle/checkstyle.xml (a Google-style rule set; its default severity is warning). With
maxWarnings = 0a single warning fails the build, so ‘warning’ really means ‘error’. HTML and XML reports are produced. - PMD 7.21.0 runs config/pmd/ruleset.xml with whole categories switched on: best practices, code style, design, documentation, error-prone, multithreading, performance and security, with two rules excluded. SpotBugs (plugin 6.5.12) reads bytecode for bug patterns; config/spotbugs/exclude.xml lists a handful of reviewed false positives such as copies that are already immutable.
- Error Prone 2.50.0 runs inside javac. Generated code under build/generated is skipped. Error Prone and SpotBugs are used at default levels.
- A loop at the end of quality-conventions disables the Checkstyle, PMD, SpotBugs, OpenGrep and Spotless tasks whose names contain ‘Aot’, because Spring’s native-image step generates code that nobody edits. A pin of plexus-utils 3.6.1 on the Checkstyle classpath fixes a directory-traversal CVE in the older copy.
-
Not everything is applied:
removeUnusedImports()is commented out in Spotless (we did not check whether google-java-format already removes unused imports), and the Kotlin build-logic scripts themselves are not checked (see Risks). - Guarantees. Any Spotless difference, any Checkstyle warning, any PMD or SpotBugs finding in the main or test code of a module fails its check.
- State. Reports under each module’s build/reports. Nothing persistent.
- Ceiling. Static analysis runs per module, so total time grows with module count and code size. The tools see Java only; Go (cli) and TypeScript (ui) have their own tooling.
- On failure. A module can switch a task off in its own build file and nothing in build-logic stops it: mcp and plugin-api turn off pmdTest, internal-core turns off pmdMain, pmdTest and checkstyleTest, and constant-source turns off pmdMain and pmdTest (each with a ‘temporarily disabled’ comment). Error Prone warnings do not fail a compile.
./gradlew spotlessApply, config/checkstyle/checkstyle.xml, config/pmd/ruleset.xml, config/spotbugs/exclude.xml, config/license/header*.txt
Licence Gate
licensee allow-list Fails the build if any dependency uses a licence that is not on the approved list.-
The licensee plugin (1.14.1) reads the licence of every dependency (from the library’s metadata) and compares it to an allow-list: Apache-2.0, MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, ISC, Unlicense, 0BSD, Python-2.0, EPL-1.0, EPL-2.0 and UPL-1.0. A dependency with any other licence fails
./gradlew licensee. - Some libraries only publish a licence URL, not a licence name. About a dozen exact URLs are allowed (ANTLR, ASM, EDL, the MIT and UPL pages, and so on). A new URL form means a new line.
-
Copyleft licences are not allowed by default. The only exception is one named artifact:
org.openjdk.nashorn:nashorn-core:15.4under GPL-2.0 with Classpath Exception. It arrives through Handlebars (the template library used by internal-core), not through the GraalVM JS plugin; the written reason in the code says ‘pulled in by GraalVM JS in internal-core’, which is out of date. - Why per-artifact: a general GPL allowance would cover anything that later arrives by accident. Naming one artifact and one version means a version bump or a new copyleft library fails the build and forces a conscious decision. Legal sign-off for shipping it inside the GraalVM native image (which links libraries statically) is still pending.
-
CI runs this in its own workflow (license-compliance.yml): a REUSE check of file headers,
./gradlew licensee, and a Trivy licence scan. - Guarantees. No dependency enters a module without a licence that is on the list or an exact per-artifact exception.
- State. Reports under build/reports/licensee in each module; CI uploads them as an artifact.
- Ceiling. Resolution of every module’s classpath makes it as slow as dependency resolution; it scales with the number of dependencies.
- On failure. A dependency that declares no licence, or a new URL form, fails the build even if the library is fine. The failure is noisy but safe.
allow(...) and allowUrl(...) in quality-conventions, allowDependency(group, artifact, version) { because(...) }, ./gradlew licensee
OpenGrep Gate
security pattern rules Scans each module’s src/main with four project-specific security rules and fails the build on any finding.-
OpenGrep is a rule-based code scanner (an open fork of Semgrep). The ruleset is the local file config/opengrep/.semgrep.yml, so no rules are downloaded at build time. The four rules ban:
new StandardEvaluationContext(...)(SpEL with unlimited access),HostAccess.ALL(scripts reach every Java method),allowHostClassLookup(s -> true)(scripts load any class) and actuatorinclude: "*"in YAML (exposes every management endpoint). -
The task runs from the repository root and passes
--error, so any finding exits non-zero and fails the build. A deliberate use is allowed with a comment// nosem: <rule-id> - reasonon the flagged line. -
Before it runs, the task probes for the program with
opengrep --version. If the program is missing, the task is skipped without a message and the build stays green. CI installs a pinned version (v1.25.0) and checks its SHA-256, so CI never skips. - All modules share one build service limited to one user at a time. Reason: starting the wrapper in parallel could fail with ‘Text file busy’, so runs are serialised. Each module starts a new scan of its own src/main.
-
checkdepends on the task, so it runs on every check of every module. It declares the rule file and src/main as inputs and the SARIF report as an output and is cacheable, so an unchanged module is skipped; a changed one still starts the scanner (one at a time, through a shared build service). - Guarantees. If the program is present, a banned pattern in main code cannot pass check. Rule changes are reviewed in git, not fetched live.
- State. A SARIF report per module in build/reports/opengrep/opengrep-report.sarif.
- Ceiling. Serialised across 27 modules and rerun every time. Four rules only: coverage of risky patterns is narrow by design. Test code and non-Java files are not scanned, except YAML for the actuator rule.
- On failure. A missing program means the gate is open locally. A finding fails the build with the rule id, and the report is in SARIF for tooling.
./gradlew opengrep, config/opengrep/.semgrep.yml, OPENGREP_VERSION and OPENGREP_SHA256 in ci.yml, // nosem: <rule-id> - reason
Coverage Policy
JaCoCo, exceptions Measures test coverage with JaCoCo and fails a class that is below the required ratio, unless it holds a reasoned exception.-
JaCoCo (0.8.14) records which lines and branches ran during tests. Only LINE and BRANCH are enforced; the other counters mostly repeat them. The default for every class is 100% of lines and 100% of branches (the code default; CLAUDE.md and the guide say 90/85, see Mismatches). Example: a class with 20 lines of which 19 ran fails; so does a class whose one
ifwas only ever tested with true. -
Per-class exceptions are declared in a module with
coverageConfig { exception(pattern, reason, mapOf("BRANCH" to 0.9)) }. The pattern is a class-name glob such ascom.infenia.yukta.mapper.*MapperImpl. A counter not listed keeps its default. A ratio below 80% (the floor) requires a reason or the build throws; a reason is recorded for any exception. -
A stale check runs before verification: every exception pattern must match at least one compiled class, or the build fails with ‘coverage exceptions match no class (stale, delete them)’. This is why an empty
coverageConfig { }is harmless but a leftover pattern is not. - An after-configuration guard throws if a module disables jacocoTestCoverageVerification. The rule: fix the code or add a narrow, reasoned exception, never turn the check off. Other tasks (PMD, Checkstyle) have no such guard.
-
Reports (XML and HTML) are only produced on request with
-Pcoverage, but verification always depends on the report task, socheckstill builds one. Verification and the report skip themselves if no execution data file exists. JDK packages (java.*, javax.*, sun.*, jdk.*, …) are not instrumented. -
Today only one exception exists in the repository: web allows generated MapStruct mappers 90% branch coverage (the generated loops contain compiler-added branches no test can reach). Other modules declare an empty
coverageConfig { }. - Guarantees. No class in a module can fall below its ratio without a visible, reasoned line in that module’s build file, and the list of exceptions cannot contain dead entries.
- State. JaCoCo .exec files and reports under each module’s build directory. CI uploads coverage to Codecov (best-effort).
- Ceiling. Per-class strictness scales linearly with code but makes every new defensive branch cost a test. Coverage is a floor on executed code, not proof that assertions are good.
- On failure. A violation fails jacocoTestCoverageVerification with the class name and ratio. A module with no execution data is skipped silently.
coverageConfig { exception(pattern, reason, thresholds) }, -Pcoverage, -PfailFast, ./gradlew jacocoTestCoverageVerification
Node Conventions
UI toolchain, ui only Pins Node and pnpm for the ui module and makes its tasks use them.- The versions come from the catalog: Node 24.13.1 and pnpm 10.30.1. The node-gradle plugin (7.1.0) downloads them into the project so every developer builds the UI with the same tools.
-
When the environment variable
CIis set, downloading is turned off and the system Node is used. Reason: GitHub Actions already installs Node and pnpm through its own setup steps, and downloading again wastes time. Side effect: CI only asks for Node 24 (any minor) and pnpm 10.30.1 in the build job, but the E2E job asks for pnpm 10. - npm and yarn Gradle tasks are disabled, because the UI uses only pnpm. The ui module itself defines pnpmBuild, header validation and a package.json version check; its unit tests (pnpmTest) are defined but not wired into check (commented out in ui/build.gradle.kts).
- Guarantees. Locally, the UI build always uses the pinned Node and pnpm. In CI, the pnpm version is pinned but the Node minor version is not.
- State. Node and pnpm are downloaded by the node-gradle plugin into its own locations (not the system install) and reused between builds.
- Ceiling. Only the ui module uses it; cost is the first-time download and pnpm install.
- On failure. If the CI environment variable is set locally, Gradle expects a system Node and fails if there is none. If the pinned Node is not on the download server, the UI build fails.
node and pnpm in gradle/libs.versions.toml, CI environment variable
CI Pipeline
GitHub Actions gates Runs the conventions’ gates on every pull request and push, plus scans that are not part of check.-
On a pull request, a path filter decides what changed. If any of build-logic/**, config/**, gradle/**, build.gradle.kts, settings.gradle.kts, gradlew, lombok.config or the workflows changed, the whole build runs (
./gradlew build jacocoTestReport). Otherwise each changed module runsbuildDependents, meaning the module plus everything that depends on it. On pushes to main and the nightly schedule everything builds. - The lint job insists that every module in settings.gradle.kts has a path filter in ci.yml, and the shard planner (.github/scripts/ci-tasks.sh) fails if a module has no task mapping. So a new module needs edits in settings, the filter list in ci.yml, and the path map and group_of function in ci-tasks.sh.
- CI installs the same toolchain locally needed: JDK 25, Go, Node 24, pnpm 10.30.1, OpenGrep v1.25.0 (checksum verified) and Trivy v0.72.0. The wrapper jar is validated and Actions are pinned by commit hash.
- Separate workflows run Trivy (security.yml: secret scan blocks every run; vulnerability scan blocks only on main, schedule and manual runs, and reports without failing on pull requests), the licence checks, CodeQL and OpenSSF Scorecard. A dependency-review job fails PRs that add HIGH-severity vulnerable dependencies.
- A lock-file check exists, but it exits early when no gradle.lockfile is tracked, which is the current state. The final job, CI Gate, is the single required check: it passes if every job succeeded or was skipped.
- Guarantees. Pull requests cannot merge unless CI Gate is green; scans run with pinned tool versions, so a tool upgrade is a visible diff.
- State. Reports and test results are uploaded as run artifacts for 7 days; Trivy SARIF goes to GitHub security. Nothing else persists.
- Ceiling. A root-file change runs every module, now as four parallel shards (platform, infra, plugins, frontend) with a 30-minute timeout each and shared caches; a library change (core, web, messaging, plugin-api, mcp) re-runs every shard.
- On failure. A skipped job counts as success, so a wrong path filter could skip a needed job. A failing Trivy vulnerability scan on a PR does not block the PR (main does).
OPENGREP_VERSION, OPENGREP_SHA256, Trivy version in ci.yml, changes job path filters, timeout-minutes: 30
Scenarios
Run ./gradlew check
What one ./gradlew check does to a library module, stage by stage, and which tool is responsible for each stage.Configure the module
Compile with Error Prone
Format and style
Tests with the coverage agent
Verify coverage
Scan and licence check
Add a new module
How little a new module needs, and where the one manual step (CI registration) is caught.Include and apply
Dependencies by name
First check run
CI registration
Merge and boot wiring
A class misses coverage
How the coverage rule fails, the honest ways out, and the two shortcuts (one guarded, one not).The failure
Preferred fix
Reasoned exception
The shortcut that fails
The shortcut that works
OpenGrep skipped locally
An edge case: a gate that passes locally because its tool is missing, and fails in CI.Developer without the tool
The probe
Green locally
Red in CI
A bad licence arrives
How an unapproved licence is stopped, and how the one exception was made.New dependency
licensee fails
Options
The existing example
Patch a Netty CVE
A failure case from the repository itself: a security bump that is set in two places and not fully applied.The alert
Bump the catalog
What actually happened
Why nothing stops it
The fix
Capabilities
Every capability below is something a module gets by applying a convention plugin, grouped by what it protects.Toolchain
Dependencies
Quality
Security
Coverage
Frontend
CI
Guarantees and limits
The build rules are only as strong as the places they actually run. Pick where the build runs and see which gates apply, then read the thresholds and what is advisory.Which gates run where
A pull request builds only changed modules and what depends on them, unless a root file changed. Main and the nightly run build everything.Defaults and thresholds
Enforced versus advisory
Reference
How to add a module
Three files, plus the module’s own source. Copy the snippet, then follow the rules.Gate list
What each gate checks and where its configuration lives. ‘Fails’ means a finding fails the build.Convention plugin map
Coverage exceptions in the repository (corrected)
The old guide listed eight modules with entries; none of that is true now. Verified by reading every module build file.OpenGrep rules
Licence allow-list
Architecture decisions
Precompiled convention plugins in an included build
Precompiled convention plugins in an included build
Per-class 100% line and branch coverage, with reasoned exceptions
Per-class 100% line and branch coverage, with reasoned exceptions
Gates fail the build; no warnings-only mode
Gates fail the build; no warnings-only mode
OpenGrep with a pinned local ruleset, skipped when absent
OpenGrep with a pinned local ruleset, skipped when absent
Per-artifact copyleft exceptions on a permissive allow-list
Per-artifact copyleft exceptions on a permissive allow-list
A Java-focused quality toolchain; other languages own their pipelines
A Java-focused quality toolchain; other languages own their pipelines
Download Node locally, use system Node in CI
Download Node locally, use system Node in CI