Limitations of Task-Based Build Tools
Join the DZone community and get the full member experience.
Join For FreeMany build tools use a classic task-based approach for organizing the CI/CD pipeline. Among these tools, there are both old (Ant, NAnt) and modern ones (Gradle, Cake, Nuke).
Let's start with the definition of the CI/CD pipeline.
CI/CD Pipeline
By CI/CD pipeline, we will mean a sequence of stages (targets, tasks) describing and forming the process of software build, test, and deployment. Typically it has the form of a graph. Directed acyclic graph structure to be more precise. Look:
It's a simple variation of the CI/CD pipeline. Each blue box is a stage/target/task. As you might have guessed, arrows mean dependencies.
Since I'm better familiar with Cake than with other build tools, I would use it for further explanation. A structural template for the above pipeline may look like this:
x
Task("Clean-Up-Workspace")
.Does(() =>
{
...
});
Task("Restore-Dependencies")
.IsDependentOn("Clean-Up-Workspace")
.Does(() =>
{
...
});
Task("Build")
.IsDependentOn("Restore-Dependencies")
.Does(() =>
{
...
});
Task("Unit-Tests-On-Windows")
.IsDependentOn("Build")
.Does(() =>
{
...
});
Task("Unit-Tests-On-Linux")
.IsDependentOn("Build")
.Does(() =>
{
...
});
Task("Package")
.IsDependentOn("Unit-Tests-On-Windows")
.IsDependentOn("Unit-Tests-On-Linux")
.Does(() =>
{
...
});
For now, it looks quite clear and all dependencies are evident.
Implementation
We have a skeleton for the future build procedure and now it's time to get some muscles. Almost all implementations I've seen use a bunch of global variables in order to share some information between the tasks. For instance, I've taken the default Cake build example and adapted it a bit:
x
#tool nuget:?package=NUnit.ConsoleRunner&version=3.4.0
var target = Argument("target", "Default");
var configuration = Argument("configuration", "Release");
var packageVersion = Argument("version", "1.0.0");
var buildDir = Directory("./src/Example/bin") + Directory(configuration);
var packageNuspec = "./src/Example.nuspec";
var packageOutputDir = "./artifacts";
var solutionPath = "./src/Example.sln";
var testsFailFast = true;
var testsPath = $"./src/**/bin/{configuration}/*.Tests.dll";
Task("Clean-Up-Workspace")
.Does(() =>
{
CleanDirectory(buildDir);
});
Task("Restore-Dependencies")
.IsDependentOn("Clean-Up-Workspace")
.Does(() =>
{
NuGetRestore(solutionPath);
});
Task("Build")
.IsDependentOn("Restore-Dependencies")
.Does(() =>
{
if (IsRunningOnWindows())
{
MSBuild(solutionPath, settings =>
settings.SetConfiguration(configuration));
}
else
{
XBuild(solutionPath, settings =>
settings.SetConfiguration(configuration));
}
});
Task("Unit-Tests-On-Windows")
.IsDependentOn("Build")
.Does(() =>
{
NUnit3(testsPath, new NUnit3Settings {
NoResults = true,
StopOnError = testsFailFast
});
});
Task("Unit-Tests-On-Linux")
.IsDependentOn("Build")
.Does(() =>
{
Information("Not implemented yet.");
});
Task("Package")
.IsDependentOn("Unit-Tests-On-Windows")
.IsDependentOn("Unit-Tests-On-Linux")
.Does(() =>
{
NuGetPack(packageNuspec, new NuGetPackSettings {
Properties = new Dictionary<string, string> {
{ "Configuration", configuration }
},
OutputDirectory = packageOutputDir,
ArgumentCustomization = args => args.Append($"-Version {packageVersion}")
});
});
Let me show what disadvantages of this procedure I see:
- Non-obvious on which parameters each task depends. All tasks are "parameterless". I've quoted the word because, in fact, they take parameters, but in an implicit way of global variables, or even worse - hardcoded values.
- From the first drawback follows the second - all dependencies are called without any parameters and all return values of such tasks are ignored as well.
For me, as a guy with some development background, such an approach to the build procedure implementation looks outdated. The example I've given is a toy, in a real-world scenario we will have dozens of global variables and tasks. It will be extremely unclear and tough to understand why this particular task doesn't work, where used variables are defined, where initialized, and what are their actual values at the moment of the task execution.
Let's correct the original pipeline schema to note that all tasks depend on global variables scope.
How Can We Correct the Situation?
Let's consider the flow of parameters passing from task to task.
Our example is pretty simple and therefore we have only one shared parameter "solutionPath" which is used in both "Restore Dependencies" and "Build" tasks. In the current implementation, this parameter is global.
I assume that a better solution should look like this (it's only pseudocode and will not work):
xxxxxxxxxx
#tool nuget:?package=NUnit.ConsoleRunner&version=3.4.0
var target = Argument("target", "Default");
var configuration = Argument("configuration", "Release");
var packageVersion = Argument("version", "1.0.0");
var buildDir = Directory("./src/Example/bin") + Directory(configuration);
var packageNuspec = "./src/Example.nuspec";
var packageOutputDir = "./artifacts";
var solutionPath = "./src/Example.sln";
var testsFailFast = true;
var testsPath = $"./src/**/bin/{configuration}/*.Tests.dll";
Task("Clean-Up-Workspace")
.Takes(_buildDir: buildDir)
.Does(() =>
{
CleanDirectory(_buildDir);
});
Task("Restore-Dependencies")
.Takes(_solutionPath: solutionPath)
.IsDependentOn("Clean-Up-Workspace")
.Does(() =>
{
NuGetRestore(_solutionPath);
});
Task("Build")
.Takes(_configuration: configuration)
.Takes(_solutionPath: solutionPath)
.IsDependentOnWithArguments("Restore-Dependencies", _solutionPath)
.Does(() =>
{
if (IsRunningOnWindows())
{
MSBuild(_solutionPath, settings =>
settings.SetConfiguration(_configuration));
}
else
{
XBuild(_solutionPath, settings =>
settings.SetConfiguration(_configuration));
}
});
Task("Unit-Tests-On-Windows")
.Takes(_testsFailFast: testsFailFast)
.Takes(_testsPath: testsPath)
.IsDependentOn("Build")
.Does(() =>
{
NUnit3(_testsPath, new NUnit3Settings {
NoResults = true,
StopOnError = _testsFailFast
});
});
Task("Unit-Tests-On-Linux")
.IsDependentOn("Build")
.Does(() =>
{
Information("Not implemented yet.");
});
Task("Package")
.Takes(_configuration: configuration)
.Takes(_packageNuspec: packageNuspec)
.Takes(_packageOutputDir: packageOutputDir)
.Takes(_packageVersion: packageVersion)
.IsDependentOn("Unit-Tests-On-Windows")
.IsDependentOn("Unit-Tests-On-Linux")
.Does(() =>
{
NuGetPack(_packageNuspec, new NuGetPackSettings {
Properties = new Dictionary<string, string> {
{ "Configuration", _configuration }
},
OutputDirectory = _packageOutputDir,
ArgumentCustomization = args => args.Append($"-Version {_packageVersion}")
});
});
In the new approach, I've introduced Takes(...) and IsDependentOnWithArguments(...) methods that are used to inject parameters into task context and pass parameters to dependency respectively.
Benefits:
- clear input parameters for the tasks
- theoretical ability to cover tasks with unit tests
Conclusion
Despite the fact that examples were given in Cake, all said is applicable for other task-based build tools.
In the article, I've shown some limitations imposed by the task-based approach and presented a theoretical way of how to bypass them. Perhaps, you already asked similar questions and have a better solution, then please share this with me if possible.
Opinions expressed by DZone contributors are their own.
Comments