Recently I've assigned myself ;) a task to clean up build process for one of the projects I participate in. Our previous build script had some issues - it was tied to our build server, tried to do everything (from checking out code from source control to creating installers) and none of its parts could be reused in our other projects. We wanted a script that can be easily invoked on any machine (simple check out source then build on a clean machine should work), can be easily customized and (at least partially) reused by our future projects.
The key to our new solution was the fact that since VS2005, the csproj files are in fact MSBuild scripts. They can be built on a machine without the IDE by invoking MSBuild. Furthermore, the csprojs are very short scripts - they define only a set of properties (like configuration or paths) and items (like files to compile or references to other projects) and then refer to default scripts shipped with MSBuild to do the real job (compilation, XML documentation files generation, etc). These MSBuild default scripts are highly customizable - they allow adding custom behavior at any stage of the build process.
I thought that instead of writing a new "external" build script (which would then invoke child MSBuild process on the csproj or sln files) I can extend these default scripts to make our behaviors integral part of the build process. This would guarantee us that no matter how the software is built (from within IDE or by CI server) our custom actions are executed, policies enforced, etc. Also sharing customizations between project seemed to be simpler.
This text assumes that the reader has minimal knowledge of MSBuild, i.e. its syntax and concepts of targets, tasks, properties and items.
The first step was to create custom targets file that would contain all the custom functionality that we wanted to be shared by all our C# projects. This includes targets for version number generation and AssemblyInfo.cs manipulation.
I started with the version determining target. Version number consists of four numbers, which are: major, minor, build and revision. In our approach, the first three numbers are embedded into the script and are adjusted manually by us after every major or minor release. The last one (revision) is determined automatically and is set to the number of the last change in the source control. (This will not work good if we want to build some previous version of the application, but we don't worry about this now.) This feature (source control integration) is disabled by default (in which case revision defaults to 0) to speed up the build at developer machines.
After version number is known, the target that generates AssemblyInfo.cs can be built. We use AssemblyInfo task from the MSBuild Community Tasks Project. It creates a new file with the details that are specified as task parameters. These include various version numbers, copyright notice, producer, etc.
However, I don't like the idea of such automated modifications being made to files that are under source control. The solution to this problem is simple - remove AssemblyInfo.cs from the source control and C# project. Instead, add the following lines to the script:
This ensures that the file is passed to the compiler. On the other hand, when Visual Studio populates the solution explorer, by default it ignores all files that are not included directly in the csproj file (this does not affect the compiler - only what is visible in the solution explorer). Thanks to this, IDE will not complain that it cannot find the AssemblyInfo.cs in a freshly checked out source.
At this point we are ready to inject our custom build targets into the build process. This can be achieved by overriding certain targets or changing property values. I chose to do the latter, as target overriding does not provide such flexibility we wanted. Default MSBuild projects expose properties like BuildDependsOn, CleanDependsOn, etc. These contains names of targets that are executed to build or clean the project. We changed the properties by putting:
into the shared script. This causes that every time the project is built, our targets are called before actual compilation starts.
What is left now is to reference the shared script (include it) by our C# projects. This unfortunately requires opening csproj files in an external editor (like Notepad). We need to add the following line into every our csproj:
somewhere after the line:
which completes the first stage of our build process improvement task. Our targets are now executed every time the MSBuild is invoked on the project. From now on we can easily add new tasks as doing this requires us only changing this single, shared build file.
This is of course not the whole story - we did not stop changes after adding versioning targets. But I will leave this for the next part of the article.