JAVA / PLATFORM, BUILDS AND TESTING
Gradle builds and the wrapper habit
Pin and run a Gradle build through the wrapper: read gradle-wrapper.properties, upgrade with the wrapper task, and explain why tasks report UP-TO-DATE.
What you will learn
- Pin the Gradle version in gradle-wrapper.properties and commit all four wrapper files
- Use ./gradlew everywhere so local builds and CI run the identical Gradle version
- Upgrade with ./gradlew wrapper --gradle-version X.Y, never by editing distributionUrl
- Read UP-TO-DATE as: declared inputs and outputs unchanged since the last run
Understanding Gradle builds and the wrapper habit
A Gradle build is a graph of tasks, not a script that runs top to bottom. The build script is code that Gradle evaluates in a configuration phase to register tasks and the dependencies between them; only then does the execution phase run the subset needed for what you asked for, which is why ./gradlew test also runs compileJava and processResources without you naming them. Each task declares its inputs and outputs, and Gradle records a fingerprint of them after every run. That is why a second ./gradlew build prints UP-TO-DATE for most tasks, and also why a task that reads a file it never declared as an input can quietly hand back stale results.
The wrapper is four committed files: gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.jar and gradle/wrapper/gradle-wrapper.properties. The scripts are small launchers that start org.gradle.wrapper.GradleWrapperMain from that jar; the jar reads the properties file (an ordinary java.util.Properties file, which is why the colon in distributionUrl is written as https\://), downloads the exact distribution named there under GRADLE_USER_HOME/wrapper/dists, unpacks it once, and hands the build over to it. After the first run it is just a local process start, and nobody on the team needs Gradle installed at all. The build tool version is now data in the repository, so a branch from two years ago still builds with the Gradle it was written for.
The catch is that the properties file only has authority when the build goes through the wrapper: typing gradle build runs whatever distribution is on PATH and never looks at gradle-wrapper.properties. That is where the habit comes from, ./gradlew in the README, in CI, in shell scripts and in IDE run configurations, and it is why upgrades are done by asking the wrapper to rewrite itself with ./gradlew wrapper --gradle-version 8.10 instead of hand-editing the URL. That task rewrites the properties and the scripts together, regenerates the jar on a second run performed by the newly pinned version, and can record distributionSha256Sum so a tampered or truncated download fails loudly rather than executing.
import java.io.IOException;
import java.io.StringReader;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class WrapperPin {
// What the wrapper task writes into
// gradle/wrapper/gradle-wrapper.properties, plus the checksum pin.
// ':' separates keys from values in a .properties file, so the one
// inside the URL has to be escaped as "\:".
static final String WRAPPER_PROPERTIES = """
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
""";
public static void main(String[] args) throws IOException {
Properties pinned = new Properties();
pinned.load(new StringReader(WRAPPER_PROPERTIES));
String url = pinned.getProperty("distributionUrl");
System.out.println("distributionUrl : " + url);
Matcher m = Pattern.compile("gradle-([0-9.]+)-(bin|all)\\.zip").matcher(url);
if (!m.find()) {
throw new IllegalStateException("not a Gradle distribution URL: " + url);
}
System.out.println("gradle version : " + m.group(1));
System.out.println("distribution : " + m.group(2) + " (no docs, no sources)");
String sum = pinned.getProperty("distributionSha256Sum");
System.out.println("download check : "
+ (sum == null ? "none" : "sha256 over " + sum.length() + " hex chars"));
System.out.println("unpacked under : " + pinned.getProperty("distributionBase")
+ "/" + pinned.getProperty("distributionPath"));
}
}The Gradle version belongs to the project rather than to the machine: ./gradlew bootstraps exactly the distribution pinned in gradle-wrapper.properties.
Worked examples
Version strings do not sort like versions
Shows why a minimum-wrapper-version guard written with String.compareTo accepts an older Gradle.
public class WrapperVersionGuard {
// Compare dotted versions the way a human reads them: segment by segment.
static int compare(String a, String b) {
String[] left = a.split("\\.");
String[] right = b.split("\\.");
for (int i = 0; i < Math.max(left.length, right.length); i++) {
int l = i < left.length ? Integer.parseInt(left[i]) : 0;
int r = i < right.length ? Integer.parseInt(right[i]) : 0;
if (l != r) {
return Integer.compare(l, r);
}
}
return 0;
}
public static void main(String[] args) {
String required = "8.10";
for (String wrapper : new String[] {"8.7", "8.10", "8.14"}) {
System.out.printf("wrapper %-5s text:%-5b numeric:%b%n",
wrapper, wrapper.compareTo(required) >= 0, compare(wrapper, required) >= 0);
}
System.out.println();
System.out.println("8.7 vs 8.10 compared as text : " + "8.7".compareTo("8.10"));
System.out.println("8.7 vs 8.10 compared as version : " + compare("8.7", "8.10"));
}
}Example explained
Line 1compareTo stops at the third character, where '7' sorts after '1', so it returns 6 and claims 8.7 is newer than 8.10.
Line 2compare() splits on the dot and parses each segment as an int, so 7 < 10 and missing segments count as 0, making 8.10 equal to 8.10.0.
Line 3The text column is exactly the bug a hand-written wrapper check ships: a build requiring Gradle 8.10 would happily run on the pinned 8.7.
Line 4Integer.compare avoids the overflow trap of returning l - r for large segment numbers.
Why a second build prints UP-TO-DATE
Models Gradle's up-to-date check as a comparison between the fingerprint stored after the last run and the current one.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public class UpToDateCheck {
// Stand-in for what Gradle stores after a task finishes:
// one content hash per declared input file.
static Map<String, String> fingerprint(String... pairs) {
Map<String, String> m = new LinkedHashMap<>();
for (int i = 0; i < pairs.length; i += 2) {
m.put(pairs[i], pairs[i + 1]);
}
return m;
}
static void run(String task, Map<String, String> lastRun, Map<String, String> current) {
if (Objects.equals(lastRun, current)) {
System.out.println("> Task :" + task + " UP-TO-DATE");
return;
}
System.out.println("> Task :" + task);
lastRun.clear();
lastRun.putAll(current);
}
public static void main(String[] args) {
Map<String, String> lastRun = fingerprint(); // never built
Map<String, String> inputs = fingerprint("Main.java", "a1b2",
"Util.java", "c3d4");
run("compileJava", lastRun, inputs); // first build, work to do
run("compileJava", lastRun, inputs); // nothing changed
inputs.put("Util.java", "9f9f"); // one source file edited
run("compileJava", lastRun, inputs); // fingerprint differs
run("compileJava", lastRun, inputs);
}
}Example explained
Line 1lastRun starts empty, which is why the first call has work to do: an unbuilt task can never be up to date.
Line 2The second call finds the two maps equal, so nothing executes and the console line gains the UP-TO-DATE marker.
Line 3Replacing one hash invalidates the whole task rather than one file: Gradle's up-to-date decision is per task, so compileJava re-runs as a unit.
Line 4No timestamps are consulted; equality of content hashes decides, which is why touching a file without changing it does not force Gradle to rebuild.
Important notes
Committing gradlew without its executable bit, common when the commit comes from Windows, makes Linux CI report 'Permission denied'; fix it with git update-index --chmod=+x gradlew.
The sha256 value in the example is illustrative. Take the real one from Gradle's published checksums for that exact distribution file, and have CI validate the committed wrapper jar.
Common mistakes
Typing gradle build because it is shorter: the build runs on whatever version is installed, so a change that passes locally on Gradle 9 can fail in CI on the pinned 8.4.
Putting gradle-wrapper.jar in .gitignore because a binary in the repository looks wrong: a fresh clone dies with 'Could not find or load main class org.gradle.wrapper.GradleWrapperMain' and the build only works for people who already installed Gradle.
Editing the version inside distributionUrl by hand while distributionSha256Sum still holds the old hash: every build stops with a distribution checksum mismatch before any task runs.
Try it yourself
Change, predict, then run
Extend the UpToDateCheck model so the fingerprint also holds outputs, for example build/classes/Main.class, and show that deleting that output from the current snapshot makes the task run again while an otherwise unchanged rebuild still prints UP-TO-DATE.
Open the Java workspaceCheck your understanding
A project pins gradle-8.4-bin.zip in gradle-wrapper.properties. A developer with Gradle 9.0 installed runs 'gradle build' while CI runs './gradlew build'. What is the situation?
- Both runs use Gradle 8.4, because gradle-wrapper.properties configures every Gradle invocation made inside the project directory.
- The local run uses 9.0 and CI uses 8.4, so the same commit can build differently in the two places.
- The local run fails immediately, because Gradle refuses to build a project whose wrapper pins a different version.
- Both runs use 9.0, because an installed distribution takes precedence over a pinned one.
Show answer
Only the wrapper bootstrap reads gradle-wrapper.properties: gradlew starts GradleWrapperMain from gradle-wrapper.jar, which fetches the pinned distribution and delegates to it. A gradle binary on PATH never opens that file, so the first option is the tempting mistake, treating the properties file as project-wide configuration instead of input to the wrapper alone. Nothing fails locally either; the mismatch surfaces later as removed APIs, changed defaults or new deprecation warnings in CI.