Tommi's Scribbles

Creating a Custom Gradle Wrapper Distribution

Creating a Custom Gradle Wrapper Distribution
  • Published on 2023-09-26

Gradle is a very nifty build tool. A lesser known feature of Gradle is the ability to build your custom Gradle bundle that gets downloaded by the Gradle wrapper when building. I had trouble finding information on how to set this up as most of the material online was scarce and old, so I decided to do a quick write-up on how to create a custom Gradle bundle.

Getting Started

In the intro I mention how material online on how to build your own enhanced Gradle wrapper bundle is old. As a disclaimer, even my write up could be considered old: it was written for Gradle 7.x, and if I remember my GitHub feed right, Gradle is planning on Gradle 9 releases already. So you might have to do some tweaks here and there.

That said, it is time to get started. For this example, I am using AWS CodeBuild as my CD/CI. You should be able to translate the automated parts easily to your system as needed.

Setting up AWS CodeBuild Project File

First off, let's start with the simple stuff: defining file version and some helpful environment variables. We are going to define the Gradle download URL, the hash to verify the file has not been tampered with, and the Gradle filename.

version: 0.2

env:
shell: /bin/sh
variables:
GRADLE_URL: "https://services.gradle.org/distributions/gradle-7.5.1-bin.zip"
GRADLE_SHA: f6b8596b10cce501591e92f229816aa4046424f3b24d771751b06779d58c8ec4
GRADLE_FILENAME: gradle-7.5.1

Next, we can define the pre-build step. This simply downloads and verifies the zip file.

phases:
pre_build:
commands:
- echo Downloading from $GRADLE_URL
- curl $GRADLE_URL -LJO
- echo Veryfying download
- echo "$GRADLE_SHA ${GRADLE_FILENAME}-bin.zip" | sha256sum --check || exit 1
- mv ${GRADLE_FILENAME}-bin.zip dist/${GRADLE_FILENAME}-bin.zip

Next step is adding the build step. Note that I am not a fan of SEMVER. I much prefer year.month.build format for versioning. So replace the variables as best fit for yourself.

build:
commands:
- echo Build started on `date`
- VERSION_DATE=$(date +<>Y.<>m)
- BUILD_VERSION_NUMBER=${VERSION_DATE}.${CODEBUILD_BUILD_NUMBER}
- echo Build version ${BUILD_VERSION_NUMBER}
- ./gradlew :dist:createCustomGradleDistribution

The key part in the above is that we are using the Gradle Wrapper in the repo to execute the custom createCustomGradleDistribution task. Here's the remainder of the build.yml file; pretty much standard what you usually do.

post_build:
commands:
- echo Build completed on `date`
artifacts:
files:
- '**/*'
base-directory: dist/build/distributions
name: gradle-$BUILD_VERSION_NUMBER-bin.zip
enable-symlinks: yes
cache:
paths:
- '/root/.gradle/caches/**/*'
- '/root/.gradle/wrapper/**/*'

Next we get to the fun part: the Gradle side of things. As hinted above, you are using a local Gradle install with the wrapper committed to the repository AWS CodeBuild is building on. I like to use the same Gradle version that I base the custom version on as that means most things likely work between local testing and the build version.

Gradle Settings

I assume you know how to set up your Gradle project, so I will just start with the settings.gradle.kts:

rootProject.name="gradle-distribution"
include("dist")

This implies we will have the project inside a folder called "dist". In my version, I also have an app to include as I use CDK to deploy a stack. But that is not needed per se.

Gradle Project

The build.gradle.kts file inside the dist directory has a bit more going for it.

plugins {
base
}

version = System.getenv("BUILD_VERSION_NUMBER") ?: ("0.1")
val gradleFilename: String =    System.getenv("GRADLE_FILENAME")
?: ("gradle-7.5" + ".1")
val gradleBuildname: String = "gradle-$version-bin"
val gradleZipName: String = "gradle-$version-bin.zip"

tasks.register("extractBaseGradle") {
from(zipTree("$gradleFilename-bin.zip"))
into("build/$gradleBuildname/$gradleBuildname")
eachFile {
relativePath = RelativePath(
true, *relativePath.segments.drop(1)
.toTypedArray())
}
}

tasks.register("addCommonProperties") {
dependsOn("extractBaseGradle")
from("src/gradle.properties")
into("build/$gradleBuildname/$gradleBuildname/gradle.properties")
}

tasks.register("addCustomizations") {
dependsOn("addCommonProperties")
from("src/init.d")
into("build/$gradleBuildname/$gradleBuildname/init.d")
}

tasks.register("createCustomGradleDistribution") {
dependsOn("addCustomizations")
description =
"Builds custom Gradle distribution and bundles initialization scripts."

archiveFileName.set(gradleZipName)
from("build/$gradleBuildname") {
exclude("**/$gradleFilename")
}
destinationDirectory.set(layout.buildDirectory.dir("distributions"))
}

The code might look daunting, but it is pretty straight forward. We just define a bunch of tasks that extract the Gradle zip file, adds in customizations, and then zips the modifications to a new custom distribution. The config utilizes variables passed in by AWS CodeBuild, or if ran locally for testing purposes, some hardcoded values are used instead.

For the customizations, under the dist directory, we have a directory called src. We also have a directory called init.d under the src, with a single file, init.gradle.kts inside there. This directory structure mirrors the extracted Gradle directory layout and puts the customizations in locations where Gradle, when run, will find the custom items.

Let's start with a gradle.properties file put right inside the dist/src.

org.gradle.caching=true
org.gradle.parallel=true
org.gradle.project.profile=

These just set some flags for the project I like to have set. You can see the Gradle documentation for things you can configure in that file.

Things get more interesting with the dist/src/init.d/init.gradle.kts file. First let us import some things.

import org.codehaus.groovy.runtime.ProcessGroovyMethods
import org.gradle.kotlin.dsl.apply
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask

We import the ProcessGroovyMethods and apply to get a nice way to read output we get from AWS CLI. Let's continue with the initscript:

initscript {
val ENTERPRISE_REPOSITORY_URL =
"https://AWSALIAS-AWSACCOUNTNUMBER.d.codeartifact.us-east-1.amazonaws.com/maven/archives/"
fun String.execute(): Process =
org.codehaus.groovy.runtime.ProcessGroovyMethods.execute(this)
fun Process.text(): String =
org.codehaus.groovy.runtime.ProcessGroovyMethods.getText(this)

val profile = when(System.getenv("BUILD_VERSION_NUMBER")) {
null -> " --profile myprofile"
else -> ""
}

val codeartifactToken =
("aws codeartifact get-authorization-token --domain AWSALIAS --domain-owner AWSACCOUNTNUMBER --query authorizationToken --output text" + profile).execute()
.text()

repositories {
mavenLocal()
maven {
name = "STANDARD_ENTERPRISE_REPO"
url = uri(ENTERPRISE_REPOSITORY_URL)
credentials {
username = "aws"
password = codeartifactToken
}
}
}
dependencies {
classpath("org.codehaus.groovy:groovy-all:3.0.12")
classpath(kotlin("stdlib"))
classpath("com.github.ben-manes:gradle-versions-plugin:0.42.0")
classpath("se.patrikerdes:gradle-use-latest-versions-plugin:0.2.18")
}
}

What we do here is we setup dependencies and define that we use our custom AWS CodeArtifact repository for Maven libraries. Rest of the code is just to support signing in. If you want to mimic this code, replace AWSALIAS and AWSACCOUNTNUMBER as needed.

We continue by applying some custom plugins:

apply()
apply()
apply()
apply()
apply()

We also need to define the plugins. Let's start with the one that applies some Java attributes I want all my Gradle projects to automatically have:

class EnterpriseJavaSettingsPlugin : Plugin {
override fun apply(gradle: Gradle) {
gradle.projectsLoaded {
gradle.getRootProject().tasks.withType {
manifest {
attributes(
mapOf(
"Implementation-Title" to project.name,
"Implementation-Version" to project.version))
}
}

gradle.getRootProject().tasks.withType {
options.encoding = "UTF-8"
sourceCompatibility = "17"
targetCompatibility = "17"
}
}
}
}

The next plugin just prints a simple notice. This helps also verify we are indeed successful and use our custom Gradle.

class EnterpriseNoticePlugin : Plugin {
override fun apply(gradle: Gradle) {
println("Running My Internal Custom Gradle.")
}
}

The next plugin forces using only our AWS CodeArtifact repository for fetching any external packages. Again, replace AWSALIAS and AWSACCOUNTNUMBER as needed.

class EnterpriseRepositoryPlugin : Plugin {
fun String.execute(): Process =
ProcessGroovyMethods.execute(this)

fun Process.text(): String =
ProcessGroovyMethods.getText(this)

companion object {
const val ENTERPRISE_REPOSITORY_URL =
"https://AWSALIAS-AWSACCOUNTNUMBER.d.codeartifact.us-east-1.amazonaws.com/maven/archives/"
}

override fun apply(gradle: Gradle) {
// TODO: Read profile value from env
val profile = when(System.getenv("BUILD_VERSION_NUMBER")) {
null -> " --profile myprofile"
else -> ""
}
val codeartifactToken =
("aws codeartifact get-authorization-token --domain AWSALIAS --domain-owner AWSACCOUNTNUMBER --query authorizationToken --output text" + profile).execute()
.text()
gradle.allprojects {
repositories {                                // Remove all repositories not pointing to the enterprise repository url
all {
if (this !is MavenArtifactRepository || url.toString() != ENTERPRISE_REPOSITORY_URL) {
project.logger.lifecycle(
"Repository ${(this as? MavenArtifactRepository)?.url ?: name} removed. Only $ENTERPRISE_REPOSITORY_URL is allowed")
remove(this)
}
}                                // add the enterprise repository
add(maven {
name = "STANDARD_ENTERPRISE_REPO"
url = uri(ENTERPRISE_REPOSITORY_URL)
credentials {
username = "aws"
password = codeartifactToken
}
})
}
}
}
}

Next, a plugin for setting the Gradle project version number automatically to either the ENV variable provided by AWS CodeBuild, or to local when testing locally.

class EnterpriseVersionPlugin : Plugin {
override fun apply(gradle: Gradle) {
gradle.projectsLoaded {
gradle.getRootProject().version =
System.getenv("BUILD_VERSION_NUMBER")
?: ("local")
}
}
}

And then finally, we define the plugin we needed the dependencies in the start for. We set up the automatic latest version updating provided by the awesome plugin by Patrik. The plugin greatly simplifies dependency management.

class EnterpriseUpdatePlugin : Plugin {
fun String.isNonStable(): Boolean {
val stableKeyword = listOf(
"RELEASE", "FINAL", "GA").any {
toUpperCase().contains(
it)
}
val regex = "^[0-9,.v-]+(-r)?$".toRegex()
val isStable = stableKeyword || regex.matches(this)
return isStable.not()
}

override fun apply(gradle: Gradle) {
gradle.allprojects {
apply()
apply()

configurations {
register("bom")
register("upToDate")
register("exceedLatest")
register("platform")
register("upgradesFound")
register("upgradesFound2")
register("unresolvable")
register("unresolvable2")
}

tasks.withType {
resolutionStrategy {
componentSelection {
all {
if (candidate.version.isNonStable() && !currentVersion.isNonStable()) {
reject("Release candidate")
}
}
}
}
checkForGradleUpdate = true
outputFormatter = "json"
outputDir = "build/dependencyUpdates"
reportfileName = "report"
}
}
}
}

And that is it. If you want, you can test things locally by downloading a Gradle version and placing the verified zip to the dist directory, and executing the same command the build file has: ./gradlew :dist:createCustomGradleDistribution.

Next Steps

I recognize the write-up leaves some gaps on the CD/CI side. I did not touch on creating your AWS CodePipeline, setting up AWS CodeArtifact repository for the Maven artifacts, or even the full setup of AWS CodeBuild project apart from the buildspec.yml file.

However, I do believe I showed how you can build a custom Gradle Bundle. I am sure you can figure out the rest, or any possible modifications needed due to Gradle moving forward at the speed of light. Or an elephant.