Skip to content

Deploying .NET COM objects with MSBuild

At work I’ve been working with a very old classic ASP website, running on a very old hardware. After a few weeks of hacking vbscript, I’m
sorely missing C#, and especially unit tests. I feel so exposed when there’s no tests proving my code does what I think it does. For reasons I’ll not go into, deploying aspx pages is not an option, so I spent some time exploring creating COM objects using .NET, and consuming them from vbscript using Server.CreateObject. This seems like it will work really well for my purpose. This morning I sorted out deployment, which didn’t have any easy google answers.

My goals is to deploy via msbuild with a few commands:

  • MSBuild.exe /t:DeployCOM /p:env=test to deploy to my test site
  • MSBuild.exe /t:DeployCOM /p:env=prod to deploy to production

TL;DR: see deploy-com-dll.targets

Requirements

Before getting into XML, to deploy a COM DLL, there are a few things to do:

  • unregister the old version of the DLL. I’m trying to avoid DLL Hell by only having one version of my DLL installed on the server, and explicitly not maintaining backward compatibility
  • restart IIS; if my COM object has been loaded in IIS, then the DLL file on disk is locked
  • copy new DLL to the server
  • register the new DLL

MSBuild design

MSBuild conveniently includes the RegisterAssembly and UnregisterAssembly tasks to handle the COM registration, but unfortunately these don’t work with remote machines, so we have to do things by hand.

MSBuild implementation

The XML is available as deploy-com-dll.targets.

First up, our conditional PropertyGroups:

<Propertygroup Condition="'$(Env)' == 'test'">
  <!-- UNC path where we publish code-->
  <PublishFolder>\\dev-server\Sites\my-classic-asp-site</PublishFolder>
  <!-- the remote path to where our code is deployed -->
  <LocalPublishFolder>D:\WebRoot\my-classic-asp-site</LocalPublishFolder>
  <!-- the computer name for psexec -->
  <Computer>\\dev-server</Computer>
</PropertyGroup>

<PropertyGroup Condition="'$(Env)' == 'prod'">
  <PublishFolder>\\prod-server\WebRoot\my-classic-asp-site</PublishFolder>
  <LocalPublishFolder>D:\sites\my-classic-asp-site</LocalPublishFolder>
  <Computer>\\prod-server</Computer>
</PropertyGroup>

Pretty straightfoward, tells us where to copy our files to, and gives the remote path so when we call regasm we can tell it where to look.

Next, our unconditional PropertyGroup:

<PropertyGroup>
  <!-- where to copy our COM assembly -->
  <COMPublishFolder>$(PublishFolder)\COM</COMPublishFolder>
  <!-- remote path to our where we published our COM assembly -->
  <COMFolder>$(LocalPublishFolder)\COM</COMFolder>
  <!-- remote path to our COM assembly -->
  <COMAssembly>$(COMFolder)\My.Interop.dll</COMAssembly>
  <!-- remote path to our typelib -->
  <COMTypeLib>$(COMFolder)\My.Interop.tlb</COMTypeLib>
  <!-- local path to psexec-->
  <PsExec>$(MSBuildProjectDirectory)\tools\PsExec.exe</PsExec>
  <!-- remote path to regasm.exe -->
  <RegAsm>C:\WINDOWS\Microsoft.NET\Framework\v4.0.30319\RegAsm.exe</RegAsm>
  <!-- remote path to iisreset.exe -->
  <iisreset>c:\WINDOWS\system32\iisreset.exe</iisreset>
</PropertyGroup>

Again, pretty straightforward. A lot of derived properties, specifying where to publish COM code, more remote paths so psexec can find the regasm and iisreset executables and our COM assembly.

Lastly, our Target:

<Target Name="DeployCOM">
  <!-- local path our compiled, strongly-named COM assembly and it's dependencies -->
  <ItemGroup>
    <FilesToCopy Include="$(MSBuildProjectDirectory)\src\My.Interop\bin\Release\*.*"/>
  </ItemGroup>
  <MakeDir Directories="$(COMPublishFolder)"/>
  <!-- unregister the old version of the DDL -->
  <Exec Command="$(PsExecBinary) $(Computer) $(RegAsm) /u /codebase /tlb:$(COMTypeLib) $(COMAssembly)"
        IgnoreExitCode="true"/>
  <!-- restart IIS -->
  <Exec Command="$(PsExecBinary) $(Computer) $(iisreset) /restart" IgnoreExitCode="true"/>
  <!-- copy new DLL to the server -->
  <Copy SourceFiles="@(FilesToCopy)"
        DestinationFiles="@(FilesToCopy->'$(COMPublishFolder)\%(RecursiveDir)%(Filename)%(Extension)')"
        SkipUnchangedFiles="true"
        />
  <!-- register the new DDL -->
  <Exec Command="$(PsExecBinary) $(Computer) $(RegAsm) /codebase /tlb:$(COMTypeLib) $(COMAssembly)"/>
</Target>

There are some comments inline there breaking out the steps. A few important notes:

  • we’re using the /codebase flag to regasm; this means we are NOT going to be installing our .NET in the global assembly cache, so the COM system needs to know to look in our COMFolder to resolve .NET references. Without this, we would need to also call gacutil to install our .NET in the system, and any third-party libraries we’ve referenced
  • the COM assembly needs to be strongly named, and therefore all it’s dependencies need to be strongly named. Compiling and signing the assembly is out of scope here, but there are plenty of good tutorials out there.
  • in a few places we ignore the exit code of the exec call; on first deployment those tasks might fail
  • this is a really heavy handed approach with IIS. Using more of the versioning features in COM and .NET assembly will let you install multiple versions of everything so you can publish a new version, then update your vbscript code to use that new version. In my case, I have huge maintenance windows every night to do iis restarts, but that option is not available to everyone.

Summary

It’s ugly, but it works and is a hell of a lot prettier than writing vbscript, and I can write tests and get some confidence that my code works before it’s in production.