Writing Gradle Plugins

07 December 2016 ~ blog groovy gradle

In my last post, Gradle: A Gentle Introduction, I discussed the basics of Gradle and how to get up and running quickly. Now, I am going to dive into the deeper part of the pool and talk about how to write your own Gradle plugins.

First, we need a project to work with. Let’s say that we want to add a custom banner to our build output - who doesn’t love banners? Something like:

  _______ _            ____        _ _     _
 |__   __| |          |  _ \      (_) |   | |
    | |  | |__   ___  | |_) |_   _ _| | __| |
    | |  | '_ \ / _ \ |  _ <| | | | | |/ _` |
    | |  | | | |  __/ | |_) | |_| | | | (_| |_ _ _
    |_|  |_| |_|\___| |____/ \__,_|_|_|\__,_(_|_|_)

We need to create a directory named banner-build and then create a build.gradle file in it with the following starting content:

build.gradle
plugins {
    id 'groovy'
}

We just need a basic starting point. Run ./gradle wrapper --gradle-version=3.2 to generate the wrapper and we are ready to start (we can run /gradlew from here on out).

Now, in order to write out a banner we need to create a custom task that will render it for us:

task banner {
    doFirst {
        if( !project.hasProperty('noBanner') ){
            file('banner.txt').eachLine { line->
                logger.lifecycle line
            }
        }
    }
}

gradle.startParameter.taskNames = [':banner'] + gradle.startParameter.taskNames

This task will add our action to the top of the execution list (the startPrameter modification makes it always run) so that if the noBanner property is not specified, our banner will be loaded from the specified file and displayed to the output log.

We will read our banner from a file, banner.txt in the root of the project - so we will need to create that with the banner content from above. Then, when you run ./gradlew build you will see something like the following:

> ./gradlew build
:banner
  _______ _            ____        _ _     _
 |__   __| |          |  _ \      (_) |   | |
    | |  | |__   ___  | |_) |_   _ _| | __| |
    | |  | '_ \ / _ \ |  _ <| | | | | |/ _` |
    | |  | | | |  __/ | |_) | |_| | | | (_| |_ _ _
    |_|  |_| |_|\___| |____/ \__,_|_|_|\__,_(_|_|_)
:compileJava UP-TO-DATE
:compileGroovy UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:jar
:assemble
:compileTestJava UP-TO-DATE
:compileTestGroovy UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:test UP-TO-DATE
:check UP-TO-DATE
:build

BUILD SUCCESSFUL

Total time: 0.559 secs

Notice also, that we can turn off the banner, passing the -PnoBanner option on the command line or as a property in your gradle.properties file, if you have one - if you run under one of those conditions, the banner will not be printed.

At this point, we have accomplished our original goal and we can go on with our lives…​ until the next project comes along and you need the same sort of functionality. You could just copy and paste this code into your project, but you don’t do that…​ right? That’s where plugins come into play; they allow us to share functionality across different project builds.

To create the plugin, first we need a separate Gradle project for it; create a directory (outside of the one for our demo project), called banner-plugin and add a build.gradle file to it with:

banner-plugin/build.gradle
plugins {
    id 'groovy'
    id 'java-gradle-plugin'
}

version = "0.1.0"
group = "com.stehno.gradle"

sourceCompatibility = 8
targetCompatibility = 8

repositories {
    jcenter()
}

dependencies {
    compile gradleApi()
    compile localGroovy()

    testCompile('org.spockframework:spock-core:1.0-groovy-2.4') {
        exclude module: 'groovy-all'
    }
}

and run gradle wrapper --gradle-version=3.2 in it to generate our wrapper. The build file for a plugin project is a standard Gradle build file, but with the java-gradle-plugin plugin to provide extra tools needed for plugins, as well as dependencies for the Gradle API and it’s associated Groovy distribution. With plugins, the project name is used as part of the unique plugin ID, so it’s generally a good practice to be explicit about the project name using a settings.gradle file:

banner-plugin/settings.gradle
rootProject.name = 'banner-plugin'

The last piece of plugin-specific configuration is the plugin properties file, which is a file in the resources/META-INF/gradle-plugins directory named <group>.<name>.properties, for this example:

banner-plugin/src/main/resources/META-INF/gradle-plugins/com.stehno.gradle.banner-plugin.properties
implementation-class=com.stehno.gradle.banner.BannerPlugin

Now we can create the basic skeleton for our plugin, which is an implementation of the Gradle Plugin<Project> interface:

banner-plugin/src/main/groovy/com/stehno/gradle/banner/BannerPlugin.groovy
package com.stehno.gradle.banner

import org.gradle.api.Plugin
import org.gradle.api.Project

class BannerPlugin implements Plugin<Project> {

    @Override void apply(final Project project) {
        // your config here...
    }
}

This is the main entry point for our plugin. When it is "applied" to the project, the apply(Project) method will be called. If we do a clean build of the project at this point, it will pass, but it does nothing. We need to transfer our functionality (the banner task) from our original build.gradle file to the plugin. Let’s create the plugin task skeleton:

banner-plugin/src/main/groovy/com/stehno/gradle/banner/BannerTask.groovy
package com.stehno.gradle.banner

import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction

class BannerTask extends DefaultTask {

}

and give it something to do:

@TaskAction
void displayBanner(){
    logger.lifecycle 'Doing something!'
}

Once we have a task, we need to wire it into the plugin so that it is applied to the project. Change the apply(Project) method of our BannerPlugin class to the following:

@Override void apply(final Project project) {
    project.task 'banner', type:BannerTask

    project.gradle.startParameter.taskNames = [':banner'] + project.gradle.startParameter.taskNames
}

This will apply our new task and then cause it to be called whenever the build is run. Now, how do we check our progress? We could build the plugin and deploy it to our original project but that would be quite a lot of round-trip time every time we wanted to test a change, but there is no need for that, Gradle provides a rich test framework which works well with Spock. Let’s create a Spock test for our task:

banner-plugin/src/test/groovy/com/stehno/gradle/banner/BannerTaskSpec.groovy
package com.stehno.gradle.banner

import spock.lang.Specification
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.BuildTask
import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome

class BannerTaskSpec extends Specification {

    @Rule TemporaryFolder projectRoot = new TemporaryFolder()

    def 'simple run'(){
        given:
        File buildFile = projectRoot.newFile('build.gradle')
        buildFile.text = '''
            plugins {
                id 'groovy'
                id 'com.stehno.gradle.banner-plugin'
            }
        '''.stripIndent()

        projectRoot.newFile('banner.txt').text = 'Awesome Banner!'

        when:
        BuildResult result = GradleRunner.create()
            .withPluginClasspath()
            .withProjectDir(projectRoot.root)
            .withArguments('clean build'.split(' '))
            .build()

        then:
        println result.output
    }
}

It’s a bit of code, but it’s not too bad once you dig in. We have a standard Spock test, with a TemporaryFolder rule - this will be our test project directory. Then, we create a build.gradle file for our test with our plugin and the groovy plugin, similar to what our original Gradle file looked like. Next, we use the GradleRunner to create and configure a Gradle environment using our file, which is then executed as a build. The results are then printed out to the command line. If you run ./gradlew test on the project now and view the test output (in the report standard out), you will see:

:banner
Doing something!
:clean UP-TO-DATE
:compileJava UP-TO-DATE
:compileGroovy UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:jar
:assemble
:compileTestJava UP-TO-DATE
:compileTestGroovy UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:test UP-TO-DATE
:check UP-TO-DATE
:build

BUILD SUCCESSFUL

Total time: 2.019 secs

where we can see our output and we have a way to quickly test our new task. So, moving onward, we need to add the real functionality to our task. Update the displayBanner() method to:

@TaskAction
void displayBanner(){
    if( !project.hasProperty('noBanner') ){
        project.file('banner.txt').eachLine { line->
            logger.lifecycle line
        }
    }
}

Notice that we prefixed project. before the file() call since we are no longer directly in the "project" scope, but other than that this code was copied right from our original build file. If you run the test, you see our message in the test output:

:banner
Awesome banner!
:clean UP-TO-DATE
:compileJava UP-TO-DATE
:compileGroovy UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:jar
:assemble
:compileTestJava UP-TO-DATE
:compileTestGroovy UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:test UP-TO-DATE
:check UP-TO-DATE
:build

BUILD SUCCESSFUL

Total time: 2.019 secs

Our test is good, but it doesn’t really verify anything, it just prints out the build output. Let’s make it verify that the build passed and that our expected message is in the output - the then: block becomes:

then:
result.tasks.every { BuildTask task ->
    task.outcome == TaskOutcome.SUCCESS || task.outcome == TaskOutcome.UP_TO_DATE
}

result.output.contains('Awesome Banner!')

The test will no longer generate the build output to the command line, but we are actually verifying the expected behavior.

We can test the noBanner property support as well, but we should also refactor the test a bit so that shared code is reused - now our test looks like:

banner-plugin/src/test/groovy/com/stehno/gradle/banner/BannerTaskSpec.groovy
package com.stehno.gradle.banner

import spock.lang.Specification
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.BuildTask
import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome

class BannerTaskSpec extends Specification {

    @Rule TemporaryFolder projectRoot = new TemporaryFolder()

    private File buildFile

    def setup(){
        buildFile = projectRoot.newFile('build.gradle')
        buildFile.text = '''
            plugins {
                id 'groovy'
                id 'com.stehno.gradle.banner-plugin'
            }
        '''.stripIndent()

        projectRoot.newFile('banner.txt').text = 'Awesome Banner!'
    }

    def 'simple run'(){
        when:
        BuildResult result = GradleRunner.create()
            .withPluginClasspath()
            .withProjectDir(projectRoot.root)
            .withArguments('clean build'.split(' '))
            .build()

        then:
        println result.output
        buildPassed result

        result.output.contains('Awesome Banner!')
    }

    def 'simple run with status hidden'(){
        when:
        BuildResult result = GradleRunner.create()
            .withPluginClasspath()
            .withProjectDir(projectRoot.root)
            .withArguments('clean build -PnoBanner'.split(' '))
            .build()

        then:
        buildPassed result

        !result.output.contains('Awesome Banner!')
    }

    private boolean buildPassed(final BuildResult result){
        result.tasks.every { BuildTask task ->
            task.outcome == TaskOutcome.SUCCESS || task.outcome == TaskOutcome.UP_TO_DATE
        }
    }
}

Mostly I just extracted the setup code and the buildPassed check, then added a test for the noBanner property support.

Wouldn’t it be nice to make the banner file location configurable? Gradle plugins have a "extension" construct that allows for rich configuration of plugins by adding functionality to the Gradle DSL. For our plugin, we would like to support something like the following:

banner {
    enabled = true
    location = file('banner.txt')
}

which would be used to toggle the banner display on and off and also provide a means of configuring the banner file location. This structure and both of its properties are optional, but allow additional configuration. Adding them to the plugin is fairly simple. The extension itself is just a POGO class, which for our case would be:

banner-plugin/src/main/groovy/com/stehno/gradle/banner/BannerExtension.groovy
package com.stehno.gradle.banner

class BannerExtension {

    boolean enabled = true
    File location
}

To register the extension with the plugin, you add the following to the first line of the BannerPlugin apply(Project) method:

project.extensions.create('banner', BannerExtension)

The last part of adding the extension support is to have the task actually make use of it. The displayBanner method of the task will look like the following when we are done:

@TaskAction
void displayBanner(){
    BannerExtension extension = project.extensions.getByType(BannerExtension)

    boolean enabled = project.hasProperty('bannerEnabled') ? project.property('bannerEnabled').equalsIgnoreCase('true') : extension.enabled

    File bannerFile = project.hasProperty('bannerFile') ? new File(project.property('bannerFile')) : (extension.location ?: project.file('banner.txt'))


    if( enabled ){
        bannerFile.eachLine { line->
            logger.lifecycle line
        }
    }
}

I modified the noBanner property and converted it to a flag so that now you would pass in -PbannerEnabled=false to disable it. I also added a means of configuring the banner file from the command line or via the extension, with the default still being banner.txt. The CLI and settings properties will override the extension values if they are present. We need to modify the 'simple run with status hidden' test to handle the new parameter:

def 'simple run with status hidden'(){
    when:
    BuildResult result = GradleRunner.create()
        .withPluginClasspath()
        .withProjectDir(projectRoot.root)
        .withArguments('clean build -PbannerEnabled=false'.split(' '))
        .build()

    then:
    buildPassed result

    !result.output.contains('Awesome Banner!')
}

Now, if you run the tests, everything still passes - so the defaults work as expected. Let’s add some tests using the extension to override the file location.

def 'extension run'(){
    setup:
    buildFile.text = '''
        plugins {
            id 'groovy'
            id 'com.stehno.gradle.banner-plugin'
        }

        banner {
            location = file('other-banner.txt')
        }
    '''.stripIndent()

    when:
    BuildResult result = GradleRunner.create()
        .withPluginClasspath()
        .withProjectDir(projectRoot.root)
        .withArguments('clean build'.split(' '))
        .build()

    then:
    buildPassed result

    result.output.contains('Awesome-er Banner!')
}

In this test we have to override the default build file we created in setup. I also added the creation of the other banner file in the setup method:

projectRoot.newFile('other-banner.txt').text = 'Awesome-er Banner!'

Now, run the tests again and see that our extension works as expected.

With our newly minted Gradle plugin we should be able to use it in our original project as a local test before deployment. An easy way to do this is to publish it to your local maven repository and then configure the other project to use it. In the plugin project, add id 'maven-publish' to the plugins block, which will allow us to publish to the local maven repo. Then run ./gradlew publishToMavenLocal, which does what it says.

In the original external build.gradle file we need to add bootstrapping code to bring in the local plugin and also remove the old banner task. The updated build.gradle file will look like this:

build-banner/build.gradle
buildscript {
    repositories {
        mavenLocal()
    }
    dependencies {
        classpath "com.stehno.gradle:banner-plugin:0.1.0"
    }
}

plugins {
    id 'groovy'
}

apply plugin: "com.stehno.gradle.banner-plugin"

Notice that we are pulling the plugin from the local maven repository. If you run the build now, you get your expected banner:

> ./gradlew build
:banner
  _______ _            ____        _ _     _
 |__   __| |          |  _ \      (_) |   | |
    | |  | |__   ___  | |_) |_   _ _| | __| |
    | |  | '_ \ / _ \ |  _ <| | | | | |/ _` |
    | |  | | | |  __/ | |_) | |_| | | | (_| |_ _ _
    |_|  |_| |_|\___| |____/ \__,_|_|_|\__,_(_|_|_)
:build

BUILD SUCCESSFUL

Total time: 0.543 secs

However, we should be able to use a different banner file. Create another banner file as flag.txt (with whatever you want in it) and configure the build to use it by adding:

banner {
    location = file('flag.txt')
}

to the bottom of the build file. Now, with my new version, I get:

> ./gradlew build
:banner
This is GRADLE!!!
:build

BUILD SUCCESSFUL

Total time: 0.474 secs

We can also disable the banner via config, set enabled = false in the extension code, and it will not appear. But, you can force it on the command line by adding -PbannerEnabled=true.

From here, you can distribute your plugin to friends and coworkers as long as you have some shared repository that you can point them to, but what if you came up with something cool enough to share to a larger audience? For that you want to publish to the http://plugins.gradle.com repo, which is what is used by the plugins block of the build.gradle file. I won’t go too far down that path in this post, but basically you will need to add the id 'com.gradle.plugin-publish' version '0.9.4' plugin to the plugin project, which will handle the actual publishing for you once you configure it for your project. In our case this would be something like:

pluginBundle {
    website = 'http://yourdomain.com/banner-plugin'
    vcsUrl = 'https://github.com/cjstehno/banner-plugin'
    description = 'Gradle plugin to add a fancy banner to your build log.'
    tags = ['gradle', 'groovy']

    plugins {
        webpreviewPlugin {
            id = 'com.stehno.gradle.banner-plugin'
            displayName = 'Gradle Build Banner Plugin'
        }
    }
}

Once you have that in place and have signed up with the plugins portal (free) you run ./gradlew publishPlugins and if all goes well, you have a publicly available plugin.

This tutorial has really only scratched the surface of plugin development, there is a lot more there to work with and most of it is well documented in the Gradle User Guide or through some Google searches. It’s a powerful framework and well worth spending the time to learn if you are working in Gradle.


Creative Commons License CoffeaElectronica.com content is copyright © 2016 Christopher J. Stehno and available under a Creative Commons Attribution-ShareAlike 4.0 International License.