Skip to main content
build-logic is the rulebook for the build. It is a small Gradle project inside the repository that holds six shared ‘convention plugins’. A module that applies one line of it gets the same Java version, the same dependency versions, the same code-style and bug checks, the same licence checks and the same test-coverage rules as every other module. Nothing here runs in production; it decides what code is allowed to be merged. This page is for thinking about it: open a component, replay a scenario, then challenge the weak spots.
Open risks, bugs and improvement ideas for this module are on the internal pages (sign-in required): Risks, Bugs and Improvements.

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.
Think of a building code for a housing estate. Every house (module) is designed by a different builder, but each must meet the same code: same electrical standard, same fire doors, same inspection before anyone moves in. The code is written once by the estate authority (build-logic). Inspectors (the quality tools) check each house against it, and the occupancy certificate (a passing ./gradlew check) is what lets a house be handed over (merged).

Where build-logic sits

build-logic never contains product code. It only decides how product code is compiled, checked and measured.

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.
1

Add the module

What happens. The developer adds one include(…) line to the root settings.gradle.kts and creates the module’s build.gradle.kts with a single plugin line, id(“com.infenia.yukta.library-conventions”), plus its own dependencies.Why it works this way. Nothing else is needed. The convention plugin supplies the Java version, the dependency versions, Lombok, the test libraries and every quality gate. The developer also adds a path filter for the module to .github/workflows/ci.yml.Can go wrong. Forgetting the CI path filter: the lint-workflows job fails with ‘Module has no path filter in ci.yml’, so the mistake is caught early. Copying a coverageConfig block from another module ‘just in case’: if its pattern matches no class here, the stale check fails the build.
2

Apply conventions

What happens. Gradle compiles build-logic first, then applies library-conventions to the new module. That pulls in spring-conventions, which pulls in java, quality and jacoco conventions, in that order.Why it works this way. The module now has the Java 25 toolchain, the Spring Boot, Spring AI and Netty BOMs, Spotless, Checkstyle, PMD, SpotBugs, Error Prone, licensee, an opengrep task and JaCoCo rules, without a single line of its own.Can go wrong. If JDK 25 is not installed, Gradle cannot find the toolchain and stops: there is no download resolver configured. A mistake in a convention plugin breaks every module at once.
3

Compile

What happens. javac compiles for Java 25 with -parameters (keeps method parameter names, which Spring and Jackson read at run time). Lombok generates getters and constructors. Error Prone, a plugin inside the compiler, flags known bug patterns.Why it works this way. Generated sources under build/generated are skipped by Error Prone because nobody edits them and they are regenerated each build.Can go wrong. Error Prone runs with default settings, so most of its findings are warnings that do not fail the build. Only its ‘error’ level checks stop compilation.
4

Format and lint

What happens. spotlessCheck verifies Google Java Format and the licence header; Checkstyle, PMD and SpotBugs then read the main and test code. Checkstyle is configured with maxWarnings = 0, so even a warning-level rule fails.Why it works this way. Rule files live in config/ at the repository root, so one edit changes the rule for all modules. Spotless can fix most findings itself: ./gradlew spotlessApply.Can go wrong. A noisy new rule blocks every module at once. A module can switch a task off in its own build file (four modules do for PMD or Checkstyle tasks); nothing in build-logic stops that.
5

Test and coverage

What happens. JUnit 5 runs the tests, in parallel forks, with the JaCoCo agent recording which lines and branches ran. Then jacocoTestCoverageVerification checks every class: 100% of lines and 100% of branches unless the class has a reasoned exception.Why it works this way. Lombok-generated code is tagged so JaCoCo ignores it. A stale exception (a pattern that matches no class) fails the build, so the list cannot rot.Can go wrong. A new class with one untested error branch fails the whole module. If a module had no test data at all, the check is skipped rather than failed.
6

Scan and licences

What happens. OpenGrep scans src/main against four local security rules (for example, no HostAccess.ALL for scripts). licensee checks every dependency’s licence against the allow-list.Why it works this way. The OpenGrep rules are a pinned file in the repository, not downloaded, so a rule cannot change under us. One shared lock makes OpenGrep runs go one module at a time.Can go wrong. If the opengrep program is not installed on the laptop, the task is skipped silently and the developer sees a green build that CI may later fail.
7

CI runs the same

What happens. On a pull request CI runs the module and everything that depends on it. If build-logic, config or gradle files changed, it runs every module. It installs OpenGrep at a pinned version and checksum first.Why it works this way. A separate workflow runs ./gradlew licensee and a REUSE header check; another runs Trivy for vulnerabilities and secrets.Can go wrong. The dependency lock check is skipped because no lock files are committed, so CI does not prove that dependency versions are reproducible.
8

Merge

What happens. The single required check, CI Gate, passes only if every job succeeded or was legitimately skipped. The pull request can merge.Why it works this way. One required check in branch protection instead of a list of jobs to maintain.Can go wrong. Skipped counts as success. A job skipped by a wrong path filter would let a change through, which is why the lint job insists every module has a filter.

The build rules in plain words

1

Include

the root build includes build-logic, so its plugins exist before any module is configured.
2

Apply

each module names one convention plugin; the plugin chain switches on everything else.
3

Resolve

dependency versions come from the Spring Boot, Spring AI and Netty BOMs and from the shared catalog.
4

Compile

javac targets Java 25; Lombok and Error Prone run inside the compiler.
5

Check

formatting, style, bug and licence tools read the code and fail on any finding.
6

Measure

tests run and JaCoCo verifies 100% line and branch coverage per class, minus reasoned exceptions.
7

Prove in CI

GitHub Actions repeats it with pinned tools and adds vulnerability and secret scans.

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-only trivy task (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.Final next to the catalog’s netty = 4.2.18.Final. The leftover no longer wins: spring-conventions sets netty.version from 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.
Settings: 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).
Settings: 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 release is also 25. A toolchain means Gradle looks for a matching JDK instead of using whatever runs Gradle. -parameters is 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.
  • -PfailFast stops 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.
Settings: -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 (springBoot 4.1.1), Spring AI (springAi 2.0.0) and Netty (netty 4.2.18.Final). A module can then write implementation(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.Final in 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:dependencyInsight shows 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.
Settings: 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 spotlessApply rewrites 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 = 0 a 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.
Settings: ./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.4 under 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.
Settings: 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 actuator include: "*" 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> - reason on 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.
  • check depends 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.
Settings: ./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 if was 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 as com.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, so check still 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.
Settings: 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 CI is 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.
Settings: 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 runs buildDependents, 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).
Settings: 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.
1

Configure the module

Gradle compiles build-logic, then applies library-conventions to the module. That applies java, quality and jacoco conventions and imports the three BOMs. The module’s own build file is only a few lines.
2

Compile with Error Prone

javac builds for Java 25 with -parameters; Lombok writes boilerplate; Error Prone checks for bug patterns during compilation (default settings; generated code is skipped). Reason for checking inside the compiler: the bug is reported at the exact line, before any other tool runs.
3

Format and style

spotlessCheck compares the files to google-java-format and the licence header. Checkstyle, PMD and SpotBugs then read main and test code. Checkstyle with maxWarnings 0 turns warnings into failures. Any difference fails the build and prints the file.
4

Tests with the coverage agent

JUnit 5 runs tests in up to CPUs/2 JVMs, each with the JaCoCo agent attached. The agent records, per class, every line and branch that ran.
5

Verify coverage

For each class, covered lines / total lines and covered branches / total branches must both be 1.00 unless an exception applies. Example: a class with one uncovered else-branch fails with its name and ratio. Exceptions are checked for staleness first.
6

Scan and licence check

If the opengrep program exists, it scans src/main against the four rules (one module at a time, shared across modules). licensee checks the licences of the dependencies against the allow-list. Both fail the build on a violation.

Add a new module

How little a new module needs, and where the one manual step (CI registration) is caught.
1

Include and apply

The developer adds include(”…”) to settings.gradle.kts and a build file whose first line applies library-conventions. That is the whole setup for the toolchain, BOMs and every gate.
2

Dependencies by name

The module lists libraries such as libs.reactor.core without versions. The BOMs and catalog supply them. If a library is not in either, it must be added to the catalog first, so no module hardcodes a version.
3

First check run

The new module has to satisfy the default: 100% line and branch coverage per class and every static-analysis rule. A new module with generated code (for example MapStruct) will hit the exception path.
4

CI registration

The module needs a path filter in ci.yml and a case in the partial-build step. The lint-workflows job and the partial-build step both fail on a missing entry, so forgetting is caught on the first pull request.
5

Merge and boot wiring

If it is an optional plugin, boot lists it in an optionalPlugins map selected with -Pyukta.plugins. This is outside build-logic but follows the same convention of being declared in one place.

A class misses coverage

How the coverage rule fails, the honest ways out, and the two shortcuts (one guarded, one not).
1

The failure

A developer adds a defensive branch and no test reaches it. jacocoTestCoverageVerification reports the class and the ratio, for example BRANCH 0.83 against the minimum 1.00, and the build fails.
2

Preferred fix

Write the missing test or remove the unreachable branch. This is the expected route, because exception sprawl was an earlier problem that the current policy was written to stop (improvements note L3).
3

Reasoned exception

If the branch is truly unreachable (generated code, compiler-made branches), add coverageConfig { exception(pattern, reason, mapOf(“BRANCH” to 0.9)) }. Below 0.80 a reason is mandatory; the helper always stores one.
4

The shortcut that fails

Setting jacocoTestCoverageVerification.enabled = false throws at configuration time: ‘must not be disabled’. Leaving an exception for a class that is later deleted or renamed fails the stale check on the next build.
5

The shortcut that works

Disabling PMD, Checkstyle or SpotBugs tasks has no guard. Four modules do so today for test or main sources, each with a ‘temporarily disabled’ comment. Likewise a module with no test execution data skips coverage verification.

OpenGrep skipped locally

An edge case: a gate that passes locally because its tool is missing, and fails in CI.
1

Developer without the tool

A developer has not installed the opengrep program. They write new StandardEvaluationContext(…) in a plugin and run ./gradlew check.
2

The probe

The task starts the program with —version. The process cannot start, an IOException is caught, and the task is marked skipped. No message is printed.
3

Green locally

All other gates pass; the build is green. The developer pushes.
4

Red in CI

CI installed OpenGrep v1.25.0 and checked its SHA-256 before the build. The scan finds the banned pattern and —error fails the build, so the pull request cannot merge. The fix: restructure, or add ’// nosem: yukta-spel-standard-evaluation-context - reason’.

A bad licence arrives

How an unapproved licence is stopped, and how the one exception was made.
1

New dependency

A developer adds a library that is licensed under LGPL (or declares no licence). The Maven metadata names the licence.
2

licensee fails

licensee compares the licence with the allow-list and fails with the artifact and licence. CI runs the same task in the license-compliance workflow, and a Trivy licence scan runs beside it.
3

Options

Pick another library; or get legal approval and add an allowDependency(group, artifact, version) { because(…) } entry. The exception names one artifact and one version, so it cannot cover anything else.
4

The existing example

nashorn-core 15.4 (GPL-2.0 with Classpath Exception) is allowed this way. It arrives with Handlebars in internal-core, and the reason text in the build file still blames GraalVM JS. Sign-off for static linking into the native image is still pending.

Patch a Netty CVE

A failure case from the repository itself: a security bump that is set in two places and not fully applied.
1

The alert

A Netty vulnerability is announced. Spring Boot’s own BOM pins an older Netty, so a normal Boot upgrade is not enough or not yet available.
2

Bump the catalog

The developer sets netty = 4.2.18.Final in the catalog. spring-conventions imports netty-bom after the Boot BOM so the newer version should win. Dependabot already proposes such bumps (the 4.2.18 bump came from a grouped Dependabot PR).
3

What actually happened

Until PR #373 the properties file won: gradle.properties still says netty.version=4.2.16.Final, and the boot jar built after the bump contained 4.2.16 Netty jars. spring-conventions now assigns netty.version from the catalog, and boot resolves 4.2.18.Final.
4

Why nothing stops it

Still no test or gate asserts the resolved Netty version, and no lock files record it. Trivy would only notice on main or schedule runs.
5

The fix

Delete the leftover gradle.properties line so the catalog is the only visible source, and add a check on the resolved version. Then commit lock files so any drift becomes a diff.

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

Context. 27 modules must share the same toolchain and gates. Copying build code would drift, and rules that sit in a root script cannot be versioned or tested as a unit.Decision. Put the rules in build-logic, an included build that compiles six precompiled Kotlin-DSL plugins. Modules apply them by id.Consequences. One change updates every module. A mistake breaks every module at once, and the build-logic compile happens before everything else.Alternatives considered. buildSrc: simpler, but any change invalidates caches for the whole build. allprojects/subprojects blocks in the root script: easy, but modules cannot opt out cleanly and the logic is hard to organise. Copy-paste: no coupling, but guaranteed drift.Revisit when. build-logic changes dominate CI time, or modules need real variants that the layered plugins cannot express.
Context. Plugin versions and library versions were declared in several places before; Dependabot needs a single file to update.Decision. build-logic/settings.gradle.kts loads ../gradle/libs.versions.toml; modules use the same file.Consequences. One bump updates both. Anything outside the catalog breaks the single-source promise; the Netty line left in gradle.properties is such a leftover.Alternatives considered. Versions inline in plugin and module files: scattered. A separate catalog for build-logic: two files to keep in step.Revisit when. The catalog grows too large to review, or build-logic needs versions that must differ from the modules’.
Context. Coverage thresholds that are lowered quietly turn into exception sprawl; a build-wide average hides one untested class behind many tested ones.Decision. Apply LINE 1.00 and BRANCH 1.00 to each class. Allow narrow exceptions with a reason (required below 0.80), reject stale patterns and refuse to let verification be disabled.Consequences. Strong pressure to test everything and a visible list of what is not. Friction for generated code and reactive error paths. History: 100% was introduced in March 2026, lowered to 90/85 on 2026-10-10, and restored to 100% on 2026-10-11 together with the removal of most exceptions.Alternatives considered. Module-wide percentage: easier, but allows an untested class. 90/85 per class: fewer exceptions, weaker floor. Mutation testing: better test quality signal, much slower.Revisit when. Exceptions grow beyond a handful, tests become coverage-driven rather than behaviour-driven, or the team wants to change the number deliberately.
Context. Warnings that do not fail are ignored over time, and a successful build should mean the code meets every rule.Decision. Checkstyle maxWarnings 0, PMD and SpotBugs not ignoring failures, OpenGrep —error, licensee failing on unknown licences.Consequences. Rules are trusted, but a new rule or tool upgrade can block everyone at once. There is no gradual rollout path.Alternatives considered. Report-only for a trial period: smoother, but findings pile up. Baseline files: allow existing violations but block new ones; more tooling.Revisit when. A tool upgrade repeatedly causes mass failures, or the team needs to adopt a new rule gradually.
Context. Registry rules were downloaded at build time, which is network-dependent and changes under us. The scanner is not a required local tool.Decision. Keep four rules in config/opengrep/.semgrep.yml, run with —error, skip silently if the program is missing, and install a pinned, checksum-verified version in CI.Consequences. Reproducible and offline, but few rules, and a gate that locally can pass without running.Alternatives considered. Registry packs (broader, but non-deterministic and needs network). Make the tool mandatory (stricter, harder onboarding). CodeQL only (already runs in CI, but not the same rules or local feedback).Revisit when. A missed finding reaches main because OpenGrep was skipped, or the team wants broader rule coverage.
Context. The product is shipped as a GraalVM native image, which links dependencies statically. Broad LGPL, GPL, CDDL and MPL allowances could create obligations unnoticed.Decision. Allow a list of permissive licences and a few exact URLs; allow a copyleft artifact only by group, name and version with a written reason.Consequences. Every copyleft dependency is a conscious decision, and a version bump re-opens it. Needs legal review, still pending for nashorn-core.Alternatives considered. A broad allow-list: fewer failures, hidden risk. A deny-list: new licences pass unnoticed. Trivy licence scanning only: report-level, not a gate (it also runs in CI).Revisit when. Legal sign-off changes, the native image stops shipping the artifact, or the number of exceptions makes the list unmanageable.
Context. The repository contains Java (Gradle), Go (cli) and TypeScript (ui). Each ecosystem has its own best tools.Decision. The conventions cover Java only. cli delegates to Makefile targets that pin gofmt, golangci-lint, gosec and govulncheck; ui runs pnpm scripts for header checks and build.Consequences. Best tool per language, but no single quality bar, no shared coverage view and different failure styles. UI unit tests are not yet wired into check.Alternatives considered. One cross-language scanner for everything (shallower per language). A separate repository per language (loses the single check).Revisit when. The UI or CLI grows to the point where its checks need the same gates, or a cross-language policy such as licence rules for npm and Go dependencies is required.
Context. The UI needs Node and pnpm. Developers should not need a particular install; CI runners already provide tools through setup steps.Decision. node-conventions pins versions in the catalog and sets download = (CI is not set). npm and yarn tasks are disabled.Consequences. Local builds are reproducible. CI builds use whichever Node 24 minor the setup action provides.Alternatives considered. Always download: same everywhere, slower CI. Always use system tools: simplest, but local drift. A container build: closest to production, heavier.Revisit when. A CI-only UI failure traces to a Node minor, or the UI build moves to a separate pipeline.

Glossary