Package ASP.NET Website (not App) in a CI Build

Back in June I blogged about how you can manually create MSDeploy packages for an ASP.NET Website.  The quick summary is Microsoft didn’t provide a way to package Websites via MSBuild like Web Applications because Websites are typically compiled at run time by the ASP.NET process.  (Note – They do allow you to create a package via the right-click publish dialog but that isn’t helpful if you want to automate the packaging/deployment process)  Regardless its still nice to be able to deploy a Website using MSDeploy packages just like a Web Application which was re-enforced by several kind comments I received on that post.

In this blog post I will take that process a step further and show how you can create a project file (.csproj) so you can treat a Website just like a Web App in your CI/CD process.

Package Project File Basics

The basic idea is to create a custom .csproj project file which is just an XML file with MSBuild elements.  As mentioned previously, ASP.NET Websites do not leverage MSBuild and thus do not have a project file by default.  A shell project file is very simple:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="14.0" DefaultTargets="PackageWebsite" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Target Name="PackageWebsite">
		<Exec Command="createPackage.bat" />
  </Target>
</Project>

It starts with an XML declaration and has a root node of Project.  Our Project node also specifies a ToolsVersion and DefaultTargets.  In this case we will set the attribute to a target named “PackageWebsite”, this instructs MSBuild.exe to execute the PackageWebsite target by default.  Within the target we simply execute the createPackage.bat file which we created in the last Websites blog post. 

Then we can run MSBuild.exe and pass the package project file to create the package.

D:\GitHub\WebsiteMSDeployPackage\deployment>"c:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild" Website1_BasicPackageBuild.csproj
Microsoft (R) Build Engine version 14.0.25420.1
Copyright (C) Microsoft Corporation. All rights reserved.

Build started 12/15/2016 11:07:29 AM.
Project "D:\GitHub\WebsiteMSDeployPackage\deployment\Website1_BasicPackageBuild.csproj" on node 1 (default targets).
PackageWebsite:
  Removing directory "package".
  Creating directory "package".
  createPackage.bat
  Info: Adding sitemanifest (sitemanifest).
  Info: Adding IIS Application (D:\GitHub\WebsiteMSDeployPackage\WebSite1)
  Info: Creating application (D:\GitHub\WebsiteMSDeployPackage\WebSite1)
  Info: Adding virtual path (D:\GitHub\WebsiteMSDeployPackage\WebSite1)
  Info: Adding directory (D:\GitHub\WebsiteMSDeployPackage\WebSite1).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\WebSite1\Default.aspx).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\WebSite1\Default.aspx.cs)
  .
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\WebSite1\packages.config)
  .
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\WebSite1\Web.config).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\WebSite1\Web.Debug.config
  ).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\WebSite1\website.publishp
  roj).
  Total changes: 11 (11 added, 0 deleted, 0 updated, 0 parameters changed, 5022
   bytes copied)
Done Building Project "D:\GitHub\WebsiteMSDeployPackage\deployment\Website1_Bas
icPackageBuild.csproj" (default targets).


Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:00.81

If you don’t need to reuse this project file for other websites this may be all you need.  Simple right!

In an effort to create a build process that is more extensible and reusable by others lets go a few steps further.

Parameterization

First off, let’s add parameterization so we can control where the package will deploy.  If you need a refresher on parameterization check out this post – https://www.dotnetcatch.com/2014/09/08/parameterizationpreview-visual-studio-extension/

We will add a parameters.xml file with a few parameter entries to the deployment folder.

<?xml version="1.0" encoding="utf-8"?>
<parameters>
  <parameter name="AppSetting-Key1" description="This is a sample parameterization for an AppSetting." defaultValue="defaultParameterizationValue">
    <parameterEntry kind="XmlFile" scope="\\web\.config" match="//add&#91;@key='Key1'&#93;/@value" />
  </parameter>
  <parameter name="EnvironmentName" defaultValue="DEFAULT">
    <parameterEntry kind="TextFile" scope="\\default\.aspx" match="ENVIRONMENT" />
  </parameter>
  <parameter name="IIS Web Application Name" defaultValue="default.website1.com" tags="IisApp">
    <parameterEntry kind="ProviderPath" scope="IisApp|contentPath|createApp" match=".*" />
  </parameter>
</parameters>

The last entry named “IIS Web Application Name” targets the ProviderPath which allows us to change the target of the deployment.

Next we will add a SetParameters.DEV.xml file to set the appropriate values for the development environment:

<?xml version="1.0" encoding="utf-8" ?>
<parameters>
  <setParameter name="AppSetting-Key1" value="DevValue" />
  <setParameter name="EnvironmentName" value="DEV" />
  <setParameter name="IIS Web Application Name" value="dev.website1.com" />
</parameters>

A Reusable Package Project

We can replace the create package batch file and simplify the solution by calling MSDeploy directly inside the project file to cut down on the number of files we need.  We can also skip the manifest and just use the iisApp MSDeploy provider which will copy the files and also create the IIS application. 

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="14.0" DefaultTargets="PackageWebsite" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <!-- Get website name from project name. -->
    <WebsiteName>$([System.Text.RegularExpressions.Regex]::Match($(MSBuildProjectName), `^.*?(?=_PackageBuild)`))</WebsiteName>
    <!-- Get website path.  Assume the deployment folder is a sibling of the website folder. -->
    <WebsitePath Condition="'$(WebsitePath)'==''">$([System.IO.Path]::GetDirectoryName($(MSBuildProjectDirectory)))\$(WebsiteName)</WebsitePath>
    <MSDeployExePath Condition="'$(MSDeployExePath)'==''">c:\Program Files\IIS\Microsoft Web Deploy V3\msdeploy.exe</MSDeployExePath>
  </PropertyGroup>
  <Target Name="PackageWebsite">
    <Message Text="Website name = $(WebsiteName)" />
    
    <RemoveDir Directories="package" />
    <MakeDir Directories="package" />    
    <Exec Command="&quot;$(MSDeployExePath)&quot; -verb:sync -source:iisApp=&quot;$(WebsitePath)&quot; -dest:package=&quot;package\$(WebsiteName)_Package.zip&quot; -declareParamFile:&quot;parameters.xml&quot; -verbose"  />
  </Target>
</Project>

The MSDeploy.exe command will set the source to our website code path, set the destination for our package (a subdirectory named “package”) and set our parameterization declaration file.  You will also notice we created some MSBuild properties for several values.  This makes it easier to overwrite values from the commandline if necessary, which makes the packaging process more extensible.

The website name is pulled from the project file name using regex.  For example, a project file named “Website1_PackageBuild.csproj” will result in a website name of “Website1”.  The project file assumes the deployment folder, which holds our parameterization and project files, is a sibling of  the website code folder.

D:\GitHub\WebsiteMSDeployPackage\AdvancedCIdeployment>"c:\Program Files (x86)\M
Build\14.0\Bin\MSBuild" Website1_PackageBuild.csproj 
Microsoft (R) Build Engine version 14.0.25420.1
Copyright (C) Microsoft Corporation. All rights reserved.

Build started 12/15/2016 12:20:51 PM.
Project "D:\GitHub\WebsiteMSDeployPackage\AdvancedCIdeployment\Website1_PackageBuild.csproj" on node 1 (default targets).
PackageWebsite:
  Website name = Website1
  Removing directory "package".
  Creating directory "package".
  "c:\Program Files\IIS\Microsoft Web Deploy V3\msdeploy.exe" -verb:sync -source:iisApp="D:\GitHub\WebsiteMSDeployPackage\Website1" -dest:package="package\Website1_Package.zip" -declareParamFile:"parameters.xml" -verbose
  Info: Adding MSDeploy.iisApp (MSDeploy.iisApp).
  Info: Adding IIS Application (D:\GitHub\WebsiteMSDeployPackage\Website1)
  Info: Creating application (D:\GitHub\WebsiteMSDeployPackage\Website1)
  Info: Adding virtual path (D:\GitHub\WebsiteMSDeployPackage\Website1)
  Info: Adding directory (D:\GitHub\WebsiteMSDeployPackage\Website1).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\Website1\Default.aspx).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\Website1\Default.aspx.cs).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\Website1\packages.config).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\Website1\Web.config).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\Website1\Web.Debug.config).
  Info: Adding file (D:\GitHub\WebsiteMSDeployPackage\Website1\website.publishproj).
  Info: Adding declared parameter 'AppSetting-Key1'.
  Info: Adding declared parameter 'EnvironmentName'.
  Info: Adding declared parameter 'IIS Web Application Name'.
  Total changes: 14 (11 added, 0 deleted, 0 updated, 3 parameters changed, 5022 bytes copied)
Done Building Project "D:\GitHub\WebsiteMSDeployPackage\AdvancedCIdeployment\Website1_PackageBuild.csproj" (default targets).

Build succeeded.
    0 Warning(s)
    0 Error(s)

This results in the package file being generated and placed in the “package” folder.  In order to deploy the file we will also need the parameterization files, so we will also add a Copy element after the MSDeploy execution to also copy those.

    <ItemGroup>
      <paramFiles Include="setParam*.xml" />
    </ItemGroup>
    <Copy SourceFiles="@(paramFiles)" DestinationFolder="package" />

Pre-Compilation

Although Websites are typically compiled by ASP.NET at runtime you can also precompile the site before deployment.  We can add precompilation to our project file by adding a second Exec element to call the ASP.NET compiler:

  <PropertyGroup>
    <PreCompileWebsite Condition="'$(PreCompileWebsite)'==''">True</PreCompileWebsite>
    <PrecompiledWebsitePath Condition="'$(PrecompiledWebsitePath)'==''">$(WebsitePath)_PreCompiled\</PrecompiledWebsitePath>    
    <AspNetCompilerPath Condition="'$(AspNetCompilerPath)'==''">C:\Windows\Microsoft.NET\Framework\v4.0.30319\aspnet_compiler</AspNetCompilerPath>
  </PropertyGroup>
    <RemoveDir Directories="$(PrecompiledWebsitePath)" />
    <MakeDir Directories="$(PrecompiledWebsitePath)" />
    <Exec Condition="'$(PreCompileWebsite)'=='True'" Command="&quot;$(AspNetCompilerPath)&quot; -p &quot;$(WebsitePath)&quot; -v / $(PrecompiledWebsitePath)" />
    <PropertyGroup>
      <WebsitePath  Condition="'$(PreCompileWebsite)'=='True'">$(PrecompiledWebsitePath)</WebsitePath>
    </PropertyGroup>

As you can see we are providing the path to the website code and a destination for the pre-compiled output, if the PreCompileWebsite property is set to “True”.  Then we replace the WebsitePath with the precompiled output path so MSDeploy will package the pre-compiled output instead of the source code.

Package Deployment

The last piece of this solution is to provide a deploy command similiar to the command provided by WebDeploy for web applications.  In fact, we can just take that one and tweak it a bit to fit our needs.  Most of it is valuable but we will remove the IIS Express and default SetParameters logic.  We will also tweak the msdeploy.exe call to use token for the package name:

@rem ---------------------------------------------------------------------------------
@rem Execute msdeploy.exe command line
@rem ---------------------------------------------------------------------------------
echo. Start executing msdeploy.exe
echo -------------------------------------------------------
echo. "%MSDeployPath%\msdeploy.exe" -source:package=%_ArgSourcePackage%' -dest:%_Destination% -verb:sync %_MsDeployAdditionalFlags%
"%MSDeployPath%\msdeploy.exe" -source:package='%_ArgSourcePackage%' -dest:%_Destination% -verb:sync %_MsDeployAdditionalFlags%
goto :eof

Then in the project file add logic to replace the token with our website name:


    <PropertyGroup>
      <DeployCommandPath>package\$(WebsiteName).deploy.cmd</DeployCommandPath>
    </PropertyGroup>
    <Copy SourceFiles="deploy.cmd" DestinationFiles="$(DeployCommandPath)" />
    <TokenReplace Path="$(DeployCommandPath)" Token="&#91;WEBSITE_NAME&#93;" Replacement="$(WebsiteName)" />
  </Target>
  
  <UsingTask TaskName="TokenReplace" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v12.0.dll">
    <ParameterGroup>
      <Path ParameterType="System.String" Required="true" />
      <Token ParameterType="System.String" Required="true" />
      <Replacement ParameterType="System.String" Required="true" />
    </ParameterGroup>
    <Task>
      <Code Type="Fragment" Language="cs">
        <!&#91;CDATA&#91;  
string content = File.ReadAllText(Path);  
content = content.Replace(Token, Replacement);  
File.WriteAllText(Path, content);  
  
&#93;&#93;>
      </Code>
    </Task>
  </UsingTask>

Here we copy the sample deploy.cmd to the package directory, rename the file using the website name and then replace the token with our package name.  We use an inline MSBuild task definition to provide the token replacement logic in C#. 

Now we have all the files we need:

image

You can run the Website1.deploy.cmd with the appropriate setParameters.*.xml filecommand from the package folder (the deploy command assumes the package .zip file is in the same directory) to deploy the package:

D:\GitHub\WebsiteMSDeployPackage\AdvancedCIdeployment\package>website1.deploy.cmd /Y /M:localhost -setParamFile:SetParameters.DEV.xml
The system cannot find the batch label specified - CheckParameterFile
 Start executing msdeploy.exe
-------------------------------------------------------
 "C:\Program Files (x86)\IIS\Microsoft Web Deploy V3\\msdeploy.exe" -source:package='D:\GitHub\WebsiteMSDeployPackage\AdvancedCIdeployment\package\Website1.zip' -dest:auto,computerName='localhost',includeAcls='False' -verb:sync -disableLink:AppPoolExtension -disableLink:ContentExtension -disableLink:CertificateExtension   -setParamFile:SetParameters.DEV.xml
Info: Using ID '4f6e9afd-4ff7-475a-8e30-a64ef96da62b' for connections to the remote server.
Info: Using ID 'bc041c61-beda-4d9f-9516-159591ceea3d' for connections to the remote server.
Info: Adding file (dev.website1.com\Default.aspx).
Info: Adding file (dev.website1.com\Default.aspx.cs).
Info: Adding file (dev.website1.com\packages.config).
Info: Adding file (dev.website1.com\Web.config).
Info: Adding file (dev.website1.com\Web.Debug.config).
Info: Adding file (dev.website1.com\website.publishproj).
Total changes: 6 (6 added, 0 deleted, 0 updated, 0 parameters changed, 5005 bytes copied)

Checkout the WebsiteMSDeployPackage repo on Github for the full source of this solution and to try it yourself.  If this post was helpful or you have further questions please drop me a note in the comments below or on social media to let me know.

Happy Deploying!

2 thoughts on “Package ASP.NET Website (not App) in a CI Build”

  1. HI,

    I am following those steps above. But at the last step, the site is deploying for the original website path. How can I get to deploy for right path?

    Regards

Leave a Reply