Gracefully handle detached HEAD in branch version check

Previously, the `CheckExpectedBranchVersionPlugin` would crash the Gradle
configuration phase if the project was in a detached HEAD state or not
in a Git repository, e.g., downloaded as a ZIP.

This commit refactors the plugin to be lazy and adopts several Gradle best
practices:

- Prevents build crashes on Git failures by gracefully catching non-zero
  exit codes, e.g., when checked out in a detached HEAD state.
- Moves the branch validation out of the task's main execution action
  and into an `onlyIf` predicate, allowing Gradle to skip the task
  entirely instead of executing an early return. This makes the skip
  outcome and reason visible in a Build Scan, rather than making it
  appear as if it executed.
- Defers the Git `exec` call to the execution phase using a lazy provider.
- Makes the task configuration cache compatible by avoiding illegal
  `Project` access inside the execution-time `onlyIf` closure.
- Improves user-facing logs and adds actionable bypass instructions when
  the project version doesn't match the branch version.

Signed-off-by: Eric Haag <ehaag@gradle.com>
This commit is contained in:
Eric Haag 2026-03-23 11:07:19 -05:00 committed by Rob Winch
parent 2fda37de53
commit 91199e7202

View File

@ -21,8 +21,12 @@ import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.logging.Logger;
import org.gradle.api.plugins.JavaBasePlugin;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider;
import org.gradle.api.provider.ProviderFactory;
import org.gradle.api.specs.Spec;
import org.gradle.api.tasks.CacheableTask;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.OutputFile;
@ -30,6 +34,7 @@ import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.TaskExecutionException;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.api.tasks.VerificationException;
import org.gradle.process.ExecOutput;
import java.io.IOException;
import java.nio.file.Files;
@ -44,21 +49,53 @@ public class CheckExpectedBranchVersionPlugin implements Plugin<Project> {
TaskProvider<CheckExpectedBranchVersionTask> checkExpectedBranchVersionTask = project.getTasks().register("checkExpectedBranchVersion", CheckExpectedBranchVersionTask.class, (task) -> {
task.setGroup("Build");
task.setDescription("Check if the project version matches the branch version");
task.onlyIf("skipCheckExpectedBranchVersion property is false or not present", CheckExpectedBranchVersionPlugin::skipPropertyFalseOrNotPresent);
task.onlyIf("Property 'skipCheckExpectedBranchVersion' is false or not present", skipPropertyFalseOrNotPresent(project.getProviders()));
task.onlyIf("Branch name matches expected version pattern *.x", CheckExpectedBranchVersionPlugin::isVersionBranch);
task.getVersion().convention(project.provider(() -> project.getVersion().toString()));
task.getBranchName().convention(project.getProviders().exec((execSpec) -> execSpec.setCommandLine("git", "symbolic-ref", "--short", "HEAD")).getStandardOutput().getAsText());
task.getBranchName().convention(getBranchName(project.getProviders(), project.getLogger()));
task.getOutputFile().convention(project.getLayout().getBuildDirectory().file("check-expected-branch-version"));
});
project.getTasks().named(JavaBasePlugin.CHECK_TASK_NAME, checkTask -> checkTask.dependsOn(checkExpectedBranchVersionTask));
}
private static boolean skipPropertyFalseOrNotPresent(Task task) {
return task.getProject()
.getProviders()
private static Spec<Task> skipPropertyFalseOrNotPresent(ProviderFactory providers) {
Provider<Boolean> skipPropertyFalseOrNotPresent = providers
.gradleProperty("skipCheckExpectedBranchVersion")
.orElse("false")
.map("false"::equalsIgnoreCase)
.get();
.map("false"::equalsIgnoreCase);
return (task) -> skipPropertyFalseOrNotPresent.get();
}
private static boolean isVersionBranch(Task task) {
return isVersionBranch((CheckExpectedBranchVersionTask) task);
}
private static boolean isVersionBranch(CheckExpectedBranchVersionTask task) {
String branchName = task.getBranchName().getOrNull();
if (branchName == null) {
return false;
}
return branchName.matches("^[0-9]+\\.[0-9]+\\.x$");
}
private static Provider<String> getBranchName(ProviderFactory providers, Logger logger) {
ExecOutput execOutput = providers.exec((execSpec) -> {
execSpec.setCommandLine("git", "symbolic-ref", "--short", "HEAD");
execSpec.setIgnoreExitValue(true);
});
return providers.provider(() -> {
int exitValue = execOutput.getResult().get().getExitValue();
if (exitValue != 0) {
logger.warn("Unable to determine branch name. Received exit code '{}' from `git`.", exitValue);
logger.warn(execOutput.getStandardError().getAsText().getOrNull());
return null;
}
String branchName = execOutput.getStandardOutput().getAsText().get().trim();
logger.info("Git branch name is '{}'.", branchName);
return branchName;
});
}
@CacheableTask
@ -77,15 +114,10 @@ public class CheckExpectedBranchVersionPlugin implements Plugin<Project> {
public void run() {
String version = getVersion().get();
String branchVersion = getBranchName().map(String::trim).get();
if (!branchVersion.matches("^[0-9]+\\.[0-9]+\\.x$")) {
String msg = String.format("Branch version [%s] does not match *.x, ignoring", branchVersion);
getLogger().warn(msg);
writeExpectedVersionOutput(msg);
return;
}
if (!versionsMatch(version, branchVersion)) {
String msg = String.format("Project version [%s] does not match branch version [%s]. " +
"Please verify that the branch contains the right version.", version, branchVersion);
"Please verify that the branch contains the right version. " +
"To bypass this check, run the build with -PskipCheckExpectedBranchVersion.", version, branchVersion);
writeExpectedVersionOutput(msg);
throw new VerificationException(msg);
}