Our project has been making use of Subversion for our source control and JetBrains Team City for our continuous integration / deployment solution for a few years now. I have always been happy with the flexibility that this solution has offered for us and would highly recommend that combination to anyone who is in need of a quality SCM / CI package. In short, it just works.
Having said that, the “powers that be” have determined that we’re going to make the switch to Team Foundation Server. Now I’ve got nothing against TFS. It is a fine product that has some outstanding workflow capabilities with regards to issue tracking, change management, etc, etc, etc. My only issue was with the price of the thing (why I did not implement it in the first place). Well, with our project being absorbed back into “the main office”, I no longer have the luxury of being the “renegade” and doing things my way, so it’s time to integrate what we’ve been doing all long with SVN/TC into TFS.
One of the tasks that we’ve created and utilized in SVN/TC creates a .zip archive of the pre-compiled website and SQL scripts that need to be deployed to the target environment. Now the original task had some hard coded paths and values established in it (since it was only used by my project anyway) so I decided to make it a little more generic and robust so that it could be reused by other teams and projects.
A little thought goes a long way…
MSBuild tasks are automation tools. That is to say, they are designed to automate processes that you would do by hand and run them at the proper time during the act of building your software. As with any automation or integration processes that you develop, a bit of forethought into the tasks that you’re actually trying to accomplish will go a LONG way in the development of your own task. During the development of this task, the first thing I did was to determine what exactly I wanted out of this automated process, and how I could make it generic enough to be reused for any project at any time.
1. The end result of the task should be a .zip file.
Simple enough right. When you’re working with .Zip the de-facto standard in re-usable open source libraries is SharpZipLib. I’ve used it before in the past, I’ll use it on this project, and I’ll use it on future projects. In short, it’s awesome.
2. I need the capability to name my final zip file whatever I want
It wouldn’t be very flexible if the end result of the build task was always “outputFile.zip”. What if I wanted to name my zip file with the build number, changeset number, date stamp or the name of my favorite goldfish? No.. Obviously, I need to include a property with my build task that lets me specify the file name my archive will be at the end of the day.
3. I need to specify where the output file will land
Just like with the file name, I’m going to need the ability to specify where the output archive will end up. By allowing this information, we make the build task more generic and able to write our end archives to a deployment path, long term storage, web server or any other shared location as desired.
4. I want to be able to build my archive however I want!
This particular requirement is a biggie and the reason for the re-write to begin with. The old version of the task took the pre-compiled website and dropped them in a ~\wwwroot folder of the archive and the SQL Scripts into a ~\scripts folder of the archive. While this worked great for our build and deployment project, it didn’t allow for any outside usages. For example, if another project wanted to make use of our build task, but they also wanted to include some files into a ~\documents path of the archive, they would be out of luck. So I need to come up with a way to have the structure of the end archive configurable.
5. I need to specify a “working” folder where my archive is to be assembled
Having used SharpZipLib in the past, I’ve learned the easiest way to work with it (though, not the only way of course) is to point it at a single folder and say “go to town”. That being the case, I decided that as part of the whole archive creation process, the files and folders that were to be added to the archive would first be copied into a “working” folder. This would allow for the simple use of SharpZipLib.
6. I don’t want working process files left hanging around… or do I?
My original thought was to kill the working folder where the archive was assembled once the .zip file was created. But then I thought about it and decided that in doing so, I was dictating how other teams might have to work with the task. So I made the “CleanAfterBuild” property that can be set in configuration to allow for the task to clean up after itself.. or not.
But what about point number 4?
Looking at the MSDN documentation for MSBuild Properties I ran across this little paragraph:
Properties can contain arbitrary XML, which can aid in passing values to tasks or displaying logging information. The following example shows the ConfigTemplate property with a value containing XML and other property references. MSBuild replaces the property references with their respective property values. Property values are interpreted from top to bottom, so in this example, $(MySupportedVersion), $(MyRequiredVersion), and $(MySafeMode) should have already been defined.
<PropertyGroup>
<ConfigTemplate>
<Configuration>
<Startup>
<SupportedRuntime
ImageVersion="$(MySupportedVersion)"
Version="$(MySupportedVersion)"/>
<RequiredRuntime
ImageVersion="$(MyRequiredVersion)
Version="$(MyRequiredVersion)"
SafeMode="$(MySafeMode)"/>
</Startup>
</Configuration>
</ConfigTemplate>
</PropertyGroup>
This lead me to the understanding that based on this knowledge, I could specify the structure of my final archive in XML. So, the end result would look something like this:
<PropertyGroup>
<CreateArchiveTemplate>
<ArchiveDefinition>
<ArchivePackage>
<PackageSource>$(BinariesRoot)\Release\_PublishedWebsites\WebSite</PackageSource>
<PackageTarget>~\wwwroot</PackageTarget>
</ArchivePackage>
<ArchivePackage>
<PackageSource>$(SolutionRoot)\SQLScripts\Implementation</PackageSource>
<PackageTarget>~\Scripts</PackageTarget>
</ArchivePackage>
<ArchivePackage>
<PackageSource>$(SolutionRoot)\Documents</PackageSource>
<PackageTarget>~\docs</PackageTarget>
</ArchivePackage>
</ArchiveDefinition>
</CreateArchiveTemplate>
</PropertyGroup>
Making the task
I like to keep all of my custom MSBuild tasks in their own assembly. That being said, I also like them all to be under the same namespace. So the first thing I do is setup my project properties to look like this:
Then I set up my references:
Since all of the development that we do is targeting the 3.5 framework, I’ve referenced the 3.5 versions of MSBuild as well.
The CreateArchive Class
Starting out with the shell of the class, first thing we do is inherit from Task and define our properties that get exposed to the MSBuild configuration script:
public class CreateArchive : Task
{
[Required]
public string ArchiveName { get; set; }
[Required]
public string OutputPath { get; set; }
[Required]
public string WorkingPath { get; set; }
[Required]
public string Contents { get; set; }
public string CleanAfterBuild { get; set; }
private bool CleanWorking
{
get
{
switch (CleanAfterBuild.ToUpper())
{
case "YES":
case "OK":
case "TRUE":
return true;
default:
return false;
}
}
}
public override bool Execute()
{
throw new NotImplementedException();
}
}
We can make a little helper method to get our working directory based on the property passed in..
private DirectoryInfo GetCleanWorkingDirectory()
{
if (Log.HasLoggedErrors)
return null;
// clean/create our working directory
var diWorking = new DirectoryInfo(WorkingPath);
if (diWorking.Exists)
diWorking.Delete(true);
diWorking.Create();
return diWorking;
}
And another that will parse our XML passed into Contents and build our archive definition:
private void BuildArchiveStructure(FileSystemInfo diArchive)
{
Log.LogMessage(MessageImportance.High, "Building the archive package structure...");
try
{
var xDoc = new XmlDocument();
xDoc.LoadXml(Contents);
var nsmgr = new XmlNamespaceManager(xDoc.NameTable);
var xRoot = xDoc.DocumentElement;
if (xRoot == null)
{
Log.LogError("Null Xml Document Element");
return;
}
if (xRoot.Attributes["xmlns"] != null)
{
var nspace = xRoot.Attributes["xmlns"].Value;
nsmgr.AddNamespace("pkg", nspace);
}
var packages = xRoot.SelectNodes(MetaKeys.ArchivePackage, nsmgr);
if (packages == null)
{
Log.LogError("Could not find any package definitions in the configuration");
return;
}
Log.LogMessage("{0} package definitions to process..", packages.Count);
foreach (XmlNode package in packages)
{
var srcNode = package.SelectSingleNode(MetaKeys.PackageSource, nsmgr);
var tgtNode = package.SelectSingleNode(MetaKeys.PackageTarget, nsmgr);
if (srcNode == null || tgtNode == null)
{
Log.LogError("Could not locate the PackageTarget or PackageSource nodes for the archive definition");
break;
}
var target = tgtNode.InnerText;
if (target.StartsWith(@"~\"))
target = target.Substring(2);
target = Path.Combine(diArchive.FullName, target);
var diSource = new DirectoryInfo(srcNode.InnerText);
var diTarget = new DirectoryInfo(target);
if (!diTarget.Exists) diTarget.Create();
Log.LogMessage("{2} Processing ArchivePackage [{0}] -> [{1}]", diSource.FullName, diTarget.FullName, DateTime.Now.ToShortTimeString());
diSource.CopyAll(diTarget, true);
}
}
catch(Exception ex)
{
Log.LogErrorFromException(ex);
}
}
It should be noted that the “CopyAll” method of the DirectoryInfo object is not part of the core framework. It is rather an extension method that I’ve included in the source archive of this task that you can download below.
The helper method to create is the one that will generate the archive:
private void BuildArchiveFile(FileSystemInfo diArchive)
{
// create our zip archive in the root of the working path
var archiveFile = Path.Combine(OutputPath, ArchiveName);
if (Log.HasLoggedErrors) return;
// make sour our putput path exists
var diOutput = new DirectoryInfo(OutputPath);
if (!diOutput.Exists)
diOutput.Create();
// make the zip file
var fZip = new FastZip { CreateEmptyDirectories = true };
fZip.CreateZip(archiveFile, diArchive.FullName, true, "");
}
Finally we can build out our Execute() method:
public override bool Execute()
{
Log.LogMessage(MessageImportance.High, "Starting CreateArchive Custom Task [{0}]", DateTime.Now.ToShortTimeString());
Log.LogMessage("Using Configuration:");
Log.LogMessage("{0}", Contents);
try
{
var diWorking = GetCleanWorkingDirectory();
if (diWorking == null)
return false;
// build our working archive structure
BuildArchiveStructure(diWorking);
// build our archive file and move it to where it belongs
BuildArchiveFile(diWorking);
// clean our working directory if needed
if (CleanWorking) diWorking.Delete(true);
}
catch (Exception ex)
{
Log.LogErrorFromException(ex);
}
Log.LogMessage("{0} Finished CreateArchive Custom Task...", DateTime.Now.ToShortTimeString());
return !Log.HasLoggedErrors;
}
Download the Binaries, Source and Help Documentation