If you’ve ever done much work with .NET deploying to Azure App Services, Azure Functions, or AWS Lambda, then you’re probably familiar with creating a ZIP package for deployment. It has the advantage of providing a mechanism for deploying code atomically. Rather than updating individual files and folders, a single ZIP file can be uploaded and mounted as a folder in the target environment. This is a common task, but one that I often see implemented in a way that is more complicated than it needs to be. In this post, I’ll show you how to create a ZIP package in .NET using built-in functionality from MSBuild and how to simplify the process to a single call to dotnet publish
.
The default approach
If you’re using a CI/CD system, you’re probably used a standardized set of tasks for .NET. These tasks generally result in calling a series of standard commands:
dotnet restore
dotnet build --configuration Release
dotnet publish --configuration Release --output $STAGING_FOLDER
This recipe downloads all of the dependencies, build the project, and then gather all of the dependencies for deployment.
The next step is to compress the files. This is normally implemented one of two ways:
zip -r package.zip .
(must be run in the$STAGING_FOLDER
directory)Compress-Archive -Path "$env:STAGING_FOLDER/*" -DestinationPath package.zip
These approaches work, but they originate from the early days of .NET Core. While the approach provides a verbose output, it’s not the most efficient way to build the code or create the ZIP package. Instead, the entire publishing process can be simplified to just:
1dotnet publish --configuration Release --output $STAGING_FOLDER
Under the covers, dotnet publish
calls dotnet build
(and that calls dotnet restore
). With modern .NET, this single command can actually perform all of the tasks!
That still leaves the project’s build process with a dependency on an external tool for creating the ZIP project.
Or does it?
Creating a custom target
It turns out that MSBuild, the engine that supports these commands, has a built-in task called
ZipDirectory
. This provides the missing functionality. To make this work, the .csproj
project will need a custom Target
.
In this example, we’ll call the target Package
. We’ll also configure it with DependsOnTarget
to ensure that when it is invoked, it also calls the Publish
target automatically:
1 <Target Name="Package" DependsOnTargets="Publish">
2 </Target>
Next, we will create two variables within the Target
:
PackageDir
- the directory where the ZIP file will be created (default:/bin/$Configuration/$TargetFramework/package/
)PackagePath
- the full path to the ZIP file (default:$PackageDir/publish.zip
)
Creating these variables allows us to specify the path to the ZIP file or the folder that will contain it on the command line if necessary. If not specified, it should provide default values:
1 <PropertyGroup>
2 <PackageDir Condition="'$(PackageDir)' == ''">$([System.IO.Path]::Combine($(OutputPath),'package'))/</PackageDir>
3 <PackagePath Condition="'$(PackagePath)' == ''">$([System.IO.Path]::Combine($(PackageDir),'publish.zip'))</PackagePath>
4 </PropertyGroup>
To make this process work, ensure the PackageDir
directory always exists:
1 <MakeDir Directories="$(PackageDir)" />
And finally, ZIP the published files (stored in PublishDir
) and write them to PackagePath
:
1 <ZipDirectory Overwrite="true" SourceDirectory="$(MSBuildProjectDirectory)/$(PublishDir)" DestinationFile="$(PackagePath)" />
With this in place, you can now publish and package the project by referencing the Package
target (using /t:
). If you want to customize the directory that contains the package, you can specify the PackageDir
property. For example:
1dotnet publish --configuration Release /t:Package /p:PackageDir=./package/
Making it automatic
The DependsOnTargets
attribute ensures that the Publish
target is called before the Package
target is executed. This is useful for creating a ZIP file only when the target is explicitly called. If you want to make this automatic so that every dotnet publish
creates a ZIP file, you just need to replace this attribute with AfterTargets="Publish"
. Using this attribute causes the Package
target to be invoked after Publish
is run:
1dotnet publish --configuration Release /p:PackageDir=./package/
Cleaning up
Visual Studio and MSBuild both provide support for automatically cleaning up any generated or compiled files. It’s important to make sure that any customizations to the process participates in this cleanup. This just requires adding one more Target
with the attribute AfterTargets="Clean"
. Within that Target
, the Delete
task can be used to remove the ZIP file:
1<Target Name="PackageClean" AfterTargets="Clean">
2 <Delete Files="$(PackagePath)" />
3</Target>
Putting it all together
Combining these steps together, we just need to add these targets to the .csproj
file:
1<PropertyGroup>
2 <PackageDir Condition="'$(PackageDir)' == ''">$([System.IO.Path]::Combine($(OutputPath),'package'))/</PackageDir>
3 <PackagePath Condition="'$(PackagePath)' == ''">$([System.IO.Path]::Combine($(PackageDir),'publish.zip'))</PackagePath>
4</PropertyGroup>
5<Target Name="Package" DependsOnTargets="Publish">
6 <MakeDir Directories="$(PackageDir)" />
7 <ZipDirectory Overwrite="true" SourceDirectory="$(MSBuildProjectDirectory)/$(PublishDir)" DestinationFile="$(PackagePath)" />
8</Target>
9<Target Name="PackageClean" AfterTargets="Clean">
10 <Delete Files="$(PackagePath)" />
11</Target>
With this in place, you can now automatically create a fully packaged ZIP file without requiring any external tools. This simplifies the entire process of build, package, publish, and compress to a single call to dotnet publish
.