Customizing .NET Builds
In this series of articles I will discuss the various approaches to customizing builds in .NET. There are several different approaches with advantages and disadvantages. We will discuss each in turn.
To start the series we will discuss the approaches available. Future posts will go into each approach in detail.
This post is based upon using Azure DevOps for builds. However the approaches should mostly translate to other build systems as well.
Factors to Consider
When deciding which approach to use you need to consider several factors that may impact the decision.
How many projects need the functionality? For single projects it is generally better to keep the customizations close to the project. Furthermore the customizations do not need to be generally reusable. If the customization is needed across all projects in a solution then a solution-level customization would be better. If the customization is needed across solutions then one of the more advanced approaches is going to be needed.
When does the customization need to run? In some cases a customization needs to run on every build whether locally or via a build server. Other customizations may only need to run on the build server. Depending upon when it needs to occur can have a serious effect on the approach.
It is possible to add conditional logic to enable a customization to distinguish between local or server builds but this would be extra work. In the case of Azure DevOps one of the predefined build variables could be checked. We have used
TF_BUILD to distinguish local from server builds with great success.
How flexible does it need to be? If the functionality is generic, like copying files, then making it reusable can save a lot of time in other projects. However if the functionality is very specific then trying to reuse it elsewhere does not add much value.
To enhance the flexibility most customizations will require inputs such as task parameters or build properties. These would need to be documented for anyone using the customization.
How often and hard is it to make changes? If the functionality needs to be adjusted in the future what kind of an impact will it have? Some approaches lend themselves to easier maintenance.
Maintainability also impacts the risk of changes to a customization. The easier an approach is to maintain the more likely (in most cases) it is to break existing builds when changes do occur.
Maintainability also tends to come into play when debugging the approach. More maintainable approaches tend to be easier to debug, but not always.
Project files are the easiest and simplest approach. In this approach the project that needs the customization is modified. A project file is just an MSBuild file and therefore can use any MSBuild task. If the functionality is not already available then an inline task can be created.
Because the customizations are specific to the project only the modified project is impacted. If several projects need the same customization then the changes need to be made in each project. This will replicate build logic which can make maintaining the customizations harder in the future.
The project file is run at every build (local or on a build server). Therefore the customization should be something that makes sense in both situations. Conditional logic could be used to detect a local build if needed.
Because the customization could run in either local or server builds it must ensure that it relies on build variables or properties for any inputs. Otherwise builds may succeed locally but fail on the server.
Because the customization is specific to the project there is really no need for flexibility or parameters beyond the differences between the local and server build environments.
Again because the customization is specific to the project there is little maintenance as only a single file is impacted. However if the customization is needed in other places later then another approach should be used. The existing project would need to be updated to use the new approach.
This approach is best used for customizations that are specific to the project. Examples would include copying external dependencies into the project output directory or running a post-build task to register the output.
This approach can also be used when testing out the impact of a customization in one of the other approaches. As the easiest approach it has a faster turnaround when developing customizations. While there is still a chance the server build will fail, most coding issues will be identified and fixed locally first.
A project file is an example of an MSBuild .targets file. A
.targets file (and the related
.props files) is a series of properties and targets that MSBuild runs. Properties are used to configure targets and include things like product names, source files to compile and temporary values needed during the build. Targets are commands such as copying files, compiling code and calculating properties. Targets are run in an well-defined order that has extension points where other targets can be configured to run before or after.
.targets files can be installed as part of build extensions or as part of NuGet packages. The compilers and many other build features are implemented this way. The project file will import the core
.targets files for the compiler, frameworks and NuGet packages it references. These can reference other
.targets files to produce a web of files needed to build the solution. Installing
.targets files using NuGet eliminates the need for installing SDKs or having build tools on a build server and makes repeatable builds easier.
A project (or an entire solution) as of VS 2017 can also have it owns custom
.props files. To avoid having to modify the project files to use it MSBuild looks for a
directory.build.props) file in the project directory and then works its way back until it finds one or runs out of file system. The purpose of these files is to allow solution-level build actions to be run without having to modify each project. A great use for this is keeping your solution-level properties like product and version information together.
One issue with
.targets files is that you need to understand how MSBuild works, when targets are called and how to use MSBuild expressions to get them to behave properly. This tends to be an error prone process. You also need to consider clean builds vs incremental builds. Finally MSBuild does not have an extensible build model in many cases without reverting to external code so this may limit what you can do.
.targets files will have the broadest range since they can be included in any project file. This is ideal for sharing build behavior in related projects. Solution-specific customizations using
directory.build.targets is also possible. In this case the range is just the solution it is defined for. Overall
.targets files can range from a single solution or project to any project using a NuGet package.
In most cases
.targets files are part of every build. This helps ensure consistent builds in local and server environments. A
.targets file could use conditional logic to enable or disable functionality if needed.
One downside to using
.targets files in Visual Studio is that the file is "run" after the solution is open. Depending upon how a target is configured it may run outside the normal build. This can cause problems if the target has a side effect such as creating a file. For example if a target file generates a file during the build then shortly after opening the solution the file(s) will be generated even if the build is never run. Care must be taken to ensure
.targets files don’t have side effects outside the build.
Since everything is handled using
.targets files they are very flexible. However most of the functionality has been around a while so there are some "modern" features that are missing like being able to easily strip strings or parse paths. Unfortunately MSBuild does not allow using arbitrary .NET code to evaluate expressions or handle conditions. Because of this targets tend to have a lot of extraneous conditions and properties in them. A common approach is to have a series of targets that calculate properties that are then used in a later target as part of the build process.
In addition to the limitations of expressions MSBuild also evaluates properties and targets separately. This can cause problems in more complex targets such that a good understanding of how MSBuild processes the
.targets file is required.
.targets files can do most anything. If a task does not exist yet then an inline task can be created instead. While increasing the side of the
.targets file it does give access to .NET code to do more complex logic.
Because NuGet packages can add
.targets files it is an easy way to share functionality across projects in the same or different solutions.
Maintainability depends upon whether the
directory.build.targets file or a file from NuGet is involved. For a solution-level
.targets file there is little maintenance difference between this approach and the project file approach other than this impacts the entire solution. For NuGet packages (or any
.targets file installed via tools) maintenance would involve updating the package in NuGet and then having the solution update to the newer version. Because there is no guarantee that a solution is consistently using the same package the build would need to account for projects that may have a mix. Depending upon the work this may be easy or hard.
Because NuGet can be used a
.targets file allows the functionality to be shared across projects and solutions without the normal issues involved in replicating code. However the file must be built to handle the various types of projects it could be used in. More complex tasks would need to be flexible in the data they accept.
Creating a custom
.targets file tends to be trial and error. However the file can start out as part of the project and MSBuild run locally until the behavior is correct making debugging a little faster. But if Visual Studio is being used then modifying a
.targets file while the solution is open may or may not behave correctly.
Of all the approaches target files are probably the most flexible while still being easily maintainable. Indeed it appears that Microsoft is moving almost everything into NuGet packages with
.targets file. It will not be surprising if in the near future Visual Studio only ships the files needed for the UI while the build tools, frameworks and other features are provided using NuGet and
.targets files. We have already seen this occur with the compilers, .NET Core files, unit tests and web publishing.
.targets file requires some effort but it is still faster than using some of the more advanced approaches. Of all the approaches this one is probably the best choice in most cases.
The next approach is to use build scripts. Build scripts are very specific to the build system being used. In some cases it is the command or PowerShell scripts that start the build and ultimately call MSBuild. In other cases it could be a Azure DevOps pipeline or Chef recipes. Needless to say the abilities and complexity are completely tied to the build system being used.
In the case of PowerShell scripts you can do just about anything allowing for maximum flexibility. However build scripts do not normally have direct access to the underlying build system so a lot of utility scripts and environment variables tend to be used to get the two systems talking.
For build systems like Azure DevOps there is already a lot of build functionality built in but you can still use PowerShell or other languages to script out things that aren’t.
Build scripts generally bring up the discussion of configuration as code. This is the concept that build scripts are part of your code base just like the source code and database schema. Therefore the build scripts should be versioned alongside everything else. There are plusses and minuses to this approach. In the case of Azure DevOps you can do either approach. Build definitions are the traditional approach but YAML is the configuration as code option. Unfortunately at this time YAML has some serious limitations that make it difficult to get working right. The documentation is catching up but this approach is still really new and therefore should be planned accordingly. I will discuss YAML when we get to build scripts later in the series.
Build scripts normally are tied to the solution they are building. This allows for solution-specific changes that won’t impact other builds. If there are some common build tasks needed across solutions then they would need to be extracted out into a shared location that all builds could use. For a build server this may not be a big issue but for local development it could be.
Build scripts can be used locally or on the build server. Ideally locally things just build in Visual Studio so the build scripts aren’t needed. However some people like to use the build scripts locally as well.
If build scripts will be used locally (either normally or for testing) then extra work is needed to ensure that all the build server settings are available when running locally. Build scripts will need to determine whether this is a local or server build if some functionality shouldn’t be run locally.
In most cases build scripts can do anything that any other scripting language can do. This gives them maximum flexibility. However they are generally run in the context of a build agent and therefore the host environment may be difficult to figure out in terms of disk storage, available tools, etc. This means build scripts tend to have to "install" the tools they need before they use them. This can slow down the build process if there are a lot of tools needed.
Build scripts tend to be more easily maintainable then other approaches provided they do not make too many assumptions about the build environment. Since build scripts are specific to the solution they run against there is generally low risk around making changes. If the build script can be run locally then testing is generally very easy.
If there is shared functionality needed by multiple solutions then moving the functionality into reusable tasks in a shared location is generally the best option. Depending upon the build environment this may be easy or hard. Shared functionality is harder to maintain but allows build scripts to remain lean.
Build scripts are generally the best approach to solution-specific build requirements that are only needed on the build server (e.g. publishing to a shared folder). Build scripts can be easily combined with other approaches allowing for a mixed set of features. For example a
.targets file could be used to run build functionality in local and server builds while the build script can be used to adjust the parameters or behavior of the
The choice of build tools can have a dramatic impact on whether this approach is viable or not. Azure DevOps build definitions tend to be easier to write because of the GUI but harder to maintain since the only way to test is to run the build. YAML on the other hand can be broken out into separate functionality and tested in isolation. But to test the actual YAML requires running the build on the server. YAML, at this time, generally reports errors when you try to run it and the errors can be hard to track down. Examples of errors include tabs where spaces are expected, indentation that is not correct and scripts that are too long.
This is the most complex approach but also the most powerful. A build task is a .NET assembly containing reusable functionality. The build task can do anything that .NET can. Parameters are used to both validate and execute the task. Tasks can be as simple as displaying variables or as complex as compiling code.
Because build tasks are so powerful they have to be installed (generally via an extension) onto the build server. This will impact maintainability and may not be feasible in some cases.
Once a build task is installed it can be used by MSBuild anywhere that the "regular" tasks are usable. Builds must explicitly call the task before it will do anything so tasks tend to be feature focused.
Build tasks must be called before they can be used but can otherwise be used by any project in any solution. Good tasks tend to be flexible enough to be used in a variety of different builds. Multiple tasks can be installed with a single build extension as well.
Build tasks can be run either locally or on the server. If run locally they first need to be installed on the machine. For local development each user would need to install the task. This impacts the "ramp up" time of a new developer (or setting up a new machine) but generally isn’t too bad.
For server builds the task needs to be installed on the server. This requires sufficient privileges which may impact the feasibility. If the task is used both locally and on the server then the versions must be kept in sync.
Build tasks have access to all of .NET so they are the most flexible approach available. But often this power isn’t needed. Build tasks tend to be best suited for complex tasks that are generally reusable such as integrating with other systems or doing complex build logic that needs to be encapsulated.
Once a build task is installed it can be used anywhere. This makes it harder to maintain over time as changes to a build task could break existing builds. If "Configuration as Code" is being used this may break builds unexpectedly.
Since build tasks need to be installed it takes longer to update them. Depending upon the build system solutions may need to "opt in" to the newer version. Multiple versions of the task may be in use so dependent code may need to (temporarily) support both the older and newer versions.
Debugging the .NET code is generally pretty easy since unit tests can be written. But ultimately it has be installed to verify it works correctly. Problems integrating with the build system can be more difficult to track down requiring extra logging in the task and potentially several build/install cycles.
MSBuild does support inline tasks. One way to minimize the cycle time would be to use an inline task in a simple build to get the functionality working. Then move the code into the build task to do final validation.
For most functionality build tasks are probably overkill. Using build scripts and
.targets files allows mostly the same functionality with a better deployment model.
Integration with a back end system like issue tracking or release deployments would probably be a better choice for build tasks. While these types of integrations could be done using the other approaches they tend to require more code and don’t change that often. Integrations tend to require more changes than just builds so having to install extra build tasks isn’t that big of a deal in most cases.
The next step in this series is to look at each of these approaches in practice. For purposes of this series we will expand the post I made a while back about creating build tasks. In that post we created a simple build task to show variables. In this series we will instead implement a simple versioning process that can be run at build time. We will use each of the approaches discussed to see how it might work. Ultimately builds tend to use a combination of approaches depending upon where and when things need to run.