Ken Muse

Creating a Zip Package in .NET


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.