Static code analysis is an incredible technique that makes your code base easier to maintain. But if you have multiple services in different repositories (possibly developed by separate teams), how can you make everybody follow the stated code style? A good approach is encapsulating all rules within a single plugin that'll perform the required validations on each project build automatically.
So, in this article I'm showing you:
- How you can create the Gradle plugin with custom PMD and Checkstyle rules.
- How to publish it to plugins.gradle.org.
- How to automate the releasing process with GitHub Actions.
You can check out the code examples in this repository.
PMD, Checkstyle, and difficulties with polyrepos
PMD and Checkstyle are static analysis tools that check your code on each project build. Gradle allows to apply them easily.
plugins {
id 'java'
id 'pmd'
id 'checkstyle'
}
And now you can tune each plugin the way you want to.
checkstyle {
toolVersion = '10.5.0'
ignoreFailures = false
maxWarnings = 0
configFile = file(pathToCheckstyleConfig)
}
pmd {
consoleOutput = true
toolVersion = '6.52.0'
ignoreFailures = false
ruleSetFiles = file(pathToPmdConfig)
}
If your entire project (or even company) is the monorepository, then this setup is absolutely fine. You just need to put these configurations in the root build.gradle
file to apply those plugins for every existing module. But what if your choice is polyrepository? What if you want to share the same code style within all the projects in the company that developers are working on (and all the ones the programmers will create in the future)? Well, you can tell them to simply copy and paste the plugins’ configuration. Anyway, that’s an error-prone approach. There is always a probability that somebody does misconfiguration.
As a matter of fact, we need to reuse somehow the defined code style configuration in every viable project. The answer is simple. We need a custom Gradle plugin to encapsulate PMD and Checkstyle rules.
Custom Gradle plugin
Build configuration
Look at the build.gradle
declaration below. It's the basic setup of the Gradle plugin project.
plugins {
id 'java-gradle-plugin'
id 'com.gradle.plugin-publish' version '1.1.0'
}
group = 'io.github.simonharmonicminor.code.style'
sourceCompatibility = '8'
repositories {
mavenCentral()
}
ext {
set('lombokVersion', '1.18.24')
}
dependencies {
compileOnly "org.projectlombok:lombok:${lombokVersion}"
annotationProcessor "org.projectlombok:lombok:${lombokVersion}"
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2'
}
gradlePlugin {
website = 'https://github.com/SimonHarmonicMinor/gradle-code-style-plugin-example'
vcsUrl = 'https://github.com/SimonHarmonicMinor/gradle-code-style-plugin-example'
plugins {
gradleCodeStylePluginExample {
id = 'io.github.simonharmonicminor.code.style'
displayName = 'Gradle Plugin Code Style Example'
description = 'Predefined Checkstyle and PMD rules'
implementationClass = 'io.github.simonharmonicminor.code.style.CodingRulesGradlePluginPlugin'
tags.set(['codestyle', 'checkstyle', 'pmd'])
}
}
}
tasks.named('test') {
useJUnitPlatform()
}
Now let’s deconstruct the configuration step by step, starting with plugins
block. Look at the code snippet below.
plugins {
id 'java-gradle-plugin'
id 'com.gradle.plugin-publish' version '1.1.0'
}
The java-gradle-plugin
command enables the tasks for the regular Gradle plugin project. Whilst the com.gradle.plugin-publish
one allows to pack and publish the plugin to https://plugins.gradle.org/.
I'm showing you the whole publishing process lately.
Then comes the basic project configuration.
group = 'io.github.simonharmonicminor.code.style'
sourceCompatibility = '8'
repositories {
mavenCentral()
}
The group
defines groupId
in favor of Apache Maven naming conventions. The sourceCompatibility
is the version of the target Java binaries. Though Java 8 is outdated now, I recommend you to build your Gradle plugins with the earliest JDK version that developers use in your company. Otherwise, you’ll block them the way to follow your code style guidelines.
Then comes the dependencies
scope.
ext {
set('lombokVersion', '1.18.24')
}
dependencies {
compileOnly "org.projectlombok:lombok:${lombokVersion}"
annotationProcessor "org.projectlombok:lombok:${lombokVersion}"
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2'
}
Nothing special here. So, let's move on to the publishing configuration.
gradlePlugin {
website = 'https://github.com/SimonHarmonicMinor/gradle-code-style-plugin-example'
vcsUrl = 'https://github.com/SimonHarmonicMinor/gradle-code-style-plugin-example'
plugins {
gradleCodeStylePluginExample {
id = 'io.github.simonharmonicminor.code.style'
displayName = 'Gradle Plugin Code Style Example'
description = 'Predefined Checkstyle and PMD rules'
implementationClass = 'io.github.simonharmonicminor.code.style.CodingRulesGradlePluginPlugin'
tags.set(['codestyle', 'checkstyle', 'pmd'])
}
}
}
The website
and vcsUrl
should point out to the public Git repository with the source code of the plugin. And the plugins
block defines each implementation of Plugin
interface in your project. Finally, the tags
are just hashtags to search your plugin in the registry.
When you publish your Gradle plugin to https://plugins.gradle.org/, the package name is crucial. The code of your plugin should be available on GitHub. If it’s not open source, you may have issues with publishing it. Then you can declare your package name as
io.github.your_github_login.any.package.you.like
. But if you wish to use some other name likecom.mycompany.my.plugin
, then make sure you owe the domainmycompany.com
. Otherwise, the Gradle engineers may reject the publishing.Note that Gradle prohibits
plugin
andgradle
as tags values. Such builds fail duringgradle publishPlugins
task execution.
And finally comes the JUnit 5 configuration.
tasks.named('test') {
useJUnitPlatform()
}
The plugin code
I want to show you the whole plugin code. And when I'm going to explain each individual detail to you. Look at the code snippet below.
public class CodingRulesGradlePluginPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPluginManager().apply("checkstyle");
project.getExtensions().configure(CheckstyleExtension.class, checkstyleExtension -> {
checkstyleExtension.setToolVersion("10.5.0");
checkstyleExtension.setIgnoreFailures(false);
checkstyleExtension.setMaxWarnings(0);
checkstyleExtension.setConfigFile(
FileUtil.copyContentToTempFile("style/checkstyle.xml", ".checkstyle.xml")
);
});
project.getPluginManager().apply("pmd");
project.getExtensions().configure(PmdExtension.class, pmdExtension -> {
pmdExtension.setConsoleOutput(true);
pmdExtension.setToolVersion("6.52.0");
pmdExtension.setIgnoreFailures(false);
pmdExtension.setRuleSets(emptyList());
pmdExtension.setRuleSetFiles(project.files(
FileUtil.copyContentToTempFile("style/pmd.xml", ".pmd.xml")
));
});
final SortedSet<String> checkstyleTaskNames = project.getTasks()
.withType(Checkstyle.class)
.getNames();
final SortedSet<String> pmdTaskNames = project.getTasks()
.withType(Pmd.class)
.getNames();
project.task(
"runStaticAnalysis",
task -> task.setDependsOn(
Stream.concat(
checkstyleTaskNames.stream(),
pmdTaskNames.stream()
).collect(Collectors.toList())
)
);
}
}
The most obvious and important detail is that each plugin task has to implement the Gradle Plugin
interface.
import org.gradle.api.Plugin;
import org.gradle.api.Project;
public class CodingRulesGradlePluginPlugin implements Plugin<Project> {
@Override
public void apply(Project project) { ... }
}
Then I'm configuring the Checkstyle task. I simply apply the checkstyle
plugin, retrieve the CheckstyleConfiguration
and override the properties I want to. Look at the code block below.
project.getPluginManager().apply("checkstyle");
project.getExtensions().configure(CheckstyleExtension.class, checkstyleExtension -> {
checkstyleExtension.setToolVersion("10.5.0");
checkstyleExtension.setIgnoreFailures(false);
checkstyleExtension.setMaxWarnings(0);
checkstyleExtension.setConfigFile(
FileUtil.copyContentToTempFile("style/checkstyle.xml", ".checkstyle.xml")
);
});
The FileUtil.copyContentToTempFile
function requires some explanation. I put the Checkstyle configuration into the src/main/resources/style/checkstyle.xml
file. However, if you point it straightly, then people’ll get bizarre error messages on applying your Gradle in their projects. There are some workarounds but the easiest way is to copy the content to the temporary file.
Look at the PMD configuration below. It’s similar to the Checkstyle one.
project.getPluginManager().apply("pmd");
project.getExtensions().configure(PmdExtension.class, pmdExtension -> {
pmdExtension.setConsoleOutput(true);
pmdExtension.setToolVersion("6.52.0");
pmdExtension.setIgnoreFailures(false);
pmdExtension.setRuleSets(emptyList());
pmdExtension.setRuleSetFiles(project.files(
FileUtil.copyContentToTempFile("style/pmd.xml", ".pmd.xml")
));
});
We’re ready now. We can apply it in a real project. Though there is a slight improvement as well. Look at the code snippet below.
final SortedSet<String> checkstyleTaskNames = project.getTasks()
.withType(Checkstyle.class)
.getNames();
final SortedSet<String> pmdTaskNames = project.getTasks()
.withType(Pmd.class)
.getNames();
project.task(
"runStaticAnalysis",
task -> task.setDependsOn(
Stream.concat(
checkstyleTaskNames.stream(),
pmdTaskNames.stream()
).collect(Collectors.toList())
)
);
The runStaticAnalysis
task triggers all Checkstyle and PMD tasks to run sequentially. It comes in handy when you want to verify your entire project before creating a pull request. If you added the runStaticAnalysis
task directly in the build.gradle
, it would look like this:
task runStaticAnalysis {
dependsOn checkstyleMain, checkstyleTest, pmdMain, pmdTest
}
Testing
What about testing? It's better to track errors during the build but not when developers have already applied your plugin in their projects. Though Gradle provides Gradle TestKit for functional testing, the case I'm showing you is rather simple and unit tests are sufficient.
Again, I'm showing the whole code piece at once and when I'll point out important details.
class CodingRulesGradlePluginPluginTest {
@Test
void shouldApplyPluginSuccessfully() {
final Project project = ProjectBuilder.builder().build();
project.getPluginManager().apply("java");
assertDoesNotThrow(
() -> new CodingRulesGradlePluginPlugin().apply(project)
);
final Task task = project.getTasks().getByName("runStaticAnalysis");
assertNotNull(task, "runStaticAnalysis task should be registered");
final Set<String> codeStyleTasks =
Stream.of("checkstyleMain", "checkstyleTest", "pmdTest", "pmdMain").collect(toSet());
assertTrue(
task.getDependsOn().containsAll(codeStyleTasks),
format(
"Task runStaticAnalysis should contain '%s' tasks, but actually: %s",
codeStyleTasks,
task.getDependsOn()
)
);
}
}
Firstly comes test Gradle project instantiation. Look at the code snippet below.
import org.gradle.testfixtures.ProjectBuilder;
import org.gradle.api.Project;
final Project project = ProjectBuilder.builder().build();
project.getPluginManager().apply("java");
Gradle provides some fixtures for unit testing. The ProjectBuilder
creates an API compatible implementation of the Project
interface. So, you can safely pass it to the YourPluginClass.apply
method.
Before invoking the business logic, we also manually apply the java
plugin. Our plugin targets the Java applications. So, it’s natural to pass the Java configured Project
implementation.
Then we simply invoke the custom plugin method and pass the configured Project
implementation.
assertDoesNotThrow(
() -> new CodingRulesGradlePluginPlugin().apply(project)
);
Afterwards, comes assertions. We need to make sure that the runStaticAnalysis
task registered successfully.
final Task task = project.getTasks().getByName("runStaticAnalysis");
assertNotNull(task, "runStaticAnalysis task should be registered");
If it’s present, we validate the task depending on existing Checkstyle and PMD tasks.
final Set<String> codeStyleTasks =
Stream.of("checkstyleMain", "checkstyleTest", "pmdTest", "pmdMain").collect(toSet());
assertTrue(
task.getDependsOn().containsAll(codeStyleTasks),
format(
"Task runStaticAnalysis should contain '%s' tasks, but actually: %s",
codeStyleTasks,
task.getDependsOn()
)
);
That's the minimum case we should test before pushing the plugin to the https://plugins.gradle.org/.
Releasing the plugin with GitHub Actions
When you register a new account on https://plugins.gradle.org/, go to your page and open the API Keys
tab. You should generate new keys. There will be two of them.
gradle.publish.key=...
gradle.publish.secret=...
Afterwards, open the Settings
of your repository and go to the Secrets and Variables -> Actions
item. You have to store the obtained keys as your repository secrets.
And finally comes the GitHub Actions build configuration.
I placed mine in
.github/workflow/build.yml
file.
Look at the whole setup below. Then I'm telling you the meanings of particular blocks.
name: Java CI with Gradle
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 8
uses: actions/setup-java@v3
with:
java-version: '8'
distribution: 'temurin'
- name: Build with Gradle
uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
with:
arguments: build
publish:
needs:
- build
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Auto Increment Semver Action
uses: MCKanpolat/auto-semver-action@1.0.5
id: versioning
with:
releaseType: minor
incrementPerCommit: false
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Next Release Number
run: echo ${{ steps.versioning.outputs.version }}
- uses: actions/checkout@v3
- name: Set up JDK 8
uses: actions/setup-java@v3
with:
java-version: '8'
distribution: 'temurin'
- name: Publish Gradle plugin
uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
with:
arguments: build publishPlugins -Pgradle.publish.key=${{ secrets.GRADLE_PUBLISH_KEY }} -Pgradle.publish.secret=${{ secrets.GRADLE_PUBLISH_SECRET }} -Pversion=${{ steps.versioning.outputs.version }}
The top file declaration states the rules of pipeline triggering.
name: Java CI with Gradle
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
The pipeline runs on each pull request to master
branch and every building of master
branch itself.
The build consists of two jobs. The first one is trivial. It just runs the Gradle build
task. Look at the configuration below.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 8
uses: actions/setup-java@v3
with:
java-version: '8'
distribution: 'temurin'
- name: Build with Gradle
uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
with:
arguments: build
And then comes the publishing itself. It also contains several steps. The first increments the version automatically and saves it to the environment variable. It’s pretty convenient, because Gradle plugins cannot be published as snapshots.
publish:
needs:
- build
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Auto Increment Semver Action
uses: MCKanpolat/auto-semver-action@1.0.5
id: versioning
with:
releaseType: minor
incrementPerCommit: false
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Next Release Number
run: echo ${{ steps.versioning.outputs.version }}
The
if: github.ref == ‘refs/heads/master’
tells GitHub Actions worker to run the pipeline only ifmaster
branch is being built. Therefore, GitHub Actions won’t trigger thepublish
process during the pull request build.
And now we need to publish the packed plugin itself. Look at the code snippet below.
- uses: actions/checkout@v3
- name: Set up JDK 8
uses: actions/setup-java@v3
with:
java-version: '8'
distribution: 'temurin'
- name: Publish Gradle plugin
uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
with:
arguments: build publishPlugins -Pgradle.publish.key=${{ secrets.GRADLE_PUBLISH_KEY }} -Pgradle.publish.secret=${{ secrets.GRADLE_PUBLISH_SECRET }} -Pversion=${{ steps.versioning.outputs.version }}
As you can see, GitHub Actions passes the gradle.publish.key
and the gradle.publish.secret
properties through the secrets and the new project version as the environment variable.
Conclusion
As you can see, automating code style rules checking is not that complicated in Gradle. By the way, you can apply the plugin described in the project by including id 'io.github.simonharmonicminor.code.style' version '0.1.0'
.
If you have any questions or suggestions, leave your comments down below. Thanks for reading!
Top comments (1)