In my last post, I described how to easily build a .NET application and publish it as a ZIP package using just the native MSBuild functionality. This is a great way to simplify the CI/CD process, since it eliminates the need for external tools. The solution works well for deploying to AWS Lambda or Azure Functions, but it creates a new issue. Developers need to adopt the code.
There are several ways to include the ZIP packaging task in projects. First, each project could implement the tasks. This is clearly not a good solution since it requires manual effort. The second approach would be to create a project template that teams could use for new projects. This allows projects to start with the code, but it doesn’t work for existing projects. Both approaches also result in duplicate code.
There is another alternative – create a NuGet package to distribute the changes. This post will focus on this option. I’ll show you how to define a project to create a NuGet package that distributes the custom MSBuild task. This will allow developers to adopt the task by simply referencing the package.
Creating the project
The easiest way to create this package is to start with a new .csproj
file. Unlike most projects, this one will not contain any code. In fact, will will rely almost entirely on setting property values. The properties fall into two groups: those that configure the project to create a package and the properties which define its metadata.
Package configuration
These settings are used to configure the project to create a NuGet package.
Property | Value | Description |
---|---|---|
TargetFramework | net8.0 | A project must have a target framework. Any .NET version will work for this package. |
NoBuild | true | The project does not have any code to compile and does should not be built. |
IncludeBuildOutput | false | Do not include any compiled files in the package |
IncludeContentInPack | true | Include files that are not compiled (Content elements in the project) in the package. |
NoDefaultExcludes | false | Prevents default exclusion of NuGet package files and files and folders starting with a dot; none of the file names start with a dot. |
SuppressDependenciesWhenPacking | true | By default, NuGet includes a dependencies tag that declares the package can supports/requires the target framework version. The tasks in this package do not have dependencies on a specific .NET version., so we can suppress this tag. |
GeneratePackageOnBuild | false | By setting this to false, MSBuild will report an error if dotnet build is called. This prevents accidentally creating an empty, compiled assembly. |
Package metadata
The settings are used to define metadata that will be published as part of the package.
Property | Description |
---|---|
PackageId | Uniquely identifies the package |
Version | The version of the package |
Authors | The name(s) of the package author |
Description | Describes the purpose of the package |
There are additional optional properties that can be set:
Property | Description |
---|---|
DevelopmentDependency | Set to true , it prevents the package from being included if the consuming project creates a package. |
Title | A friendly name for the package |
ProjectUrl | A web page providing details about the package |
Copyright | Copyright notice for the package |
RequiresLicenseAcceptance | Indicates whether the client must prompt the consumer to accept the package license before installing the package. |
RepositoryType | Set to git to provide a link to the source code repository for the package |
RepositoryUrl | The URL to the source code repository |
RepositoryBranch | The branch associated with the release |
RepositoryCommit | The commit associated with the release |
PackageLicenseExpression | The SPDX identifier for the package’s license |
Defining the targets
To distribute a package with the build targets, a few things are needed. First, the package must contain a folder called build
. This folder contains the files that will be contributed to each project as build tasks. This is split into two parts: a props
file and a targets
file. To contribute to other projects, the names of those files must match the PackageId. For this example, we’ll create a NuGet package called Muse.ZipPackage
. We’ll create two files in the build
folder, Muse.ZipPackage.props
and Muse.ZipPackage.targets
. It’s worth mentioning that the csproj
file name does not need to match the PackageId.
The project structure will look like this:
1├── ZipPackage.csproj
2└── build
3 ├── Muse.ZipPackage.props
4 └── Muse.ZipPackage.targets
The props
file is used to define properties and settings for the project. When this package is consumed, the values in this file will be dynamically included at before the consumer’s project file. This allows users to override these values in the project file or from the command line. In this case, we’ll define a property called PackageOnPublish
that will be used to determine whether consumer’s should automatically create a ZIP file automatically on publish. If this value is set to false, it will require explicitly calling the Publish
target.
The Muse.ZipPackage.props
file will look like this:
1<Project>
2 <PropertyGroup>
3 <PackageOnPublish Condition="'$(PackageOnPublish)'==''">true</PackageOnPublish>
4 </PropertyGroup>
5</Project>
The targets
file will contain the actual tasks. It can consume any of the properties defined in the props
file, the project, or the command line. In the previous article, I mentioned that you can set either AfterTargets
or DependsOnTargets
to define whether the Package
target is called automatically for every dotnet publish
or whether that target must be explicitly called (dotnet publish /t:Package
).
For this example project, I’ll dynamically define the values for AfterTargets
and DependsOnTargets
based on the value of PackageOnPublish
. By using a Condition
, you can determine whether to assign a value to a property or leave it empty. I can then assign those property values to the AfterTargets
and DependsOnTargets
attributes.
The Muse.ZipPackage.targets
file:
1<Project>
2 <PropertyGroup>
3 <!-- Set if PackageOnPublish is true; otherwise it's empty -->
4 <_AfterTargetsPublish Condition="$(PackageOnPublish)">Publish</_AfterTargetsPublish>
5
6 <!-- Set if PackageOnPublish is NOT true; otherwise it's empty -->
7 <_DependsOnTargetsPublish Condition="!$(PackageOnPublish)">Publish</_DependsOnTargetsPublish>
8 </PropertyGroup>
9 <PropertyGroup>
10 <PackageDir Condition="'$(PackageDir)' == ''">$([System.IO.Path]::Combine($(OutputPath),'package'))/</PackageDir>
11 <PackagePath Condition="'$(PackagePath)' == ''">$([System.IO.Path]::Combine($(PackageDir),'publish.zip'))</PackagePath>
12 </PropertyGroup>
13 <Target Name="Package" AfterTargets="$(_AfterTargetsPublish)" DependsOnTargets="$(_DependsOnTargetsPublish)">
14 <MakeDir Directories="$(PackageDir)" />
15 <ZipDirectory Overwrite="true" SourceDirectory="$(MSBuildProjectDirectory)/$(PublishDir)" DestinationFile="$(PackagePath)" />
16 </Target>
17
18 <Target Name="PackageClean" AfterTargets="Clean">
19 <Delete Files="$(PackagePath)" />
20 </Target>
21</Project>
Including the task
Now that we have the basic project settings, we just need to update the project file to create the package’s build folder and add the files. This is done by adding the following to the .csproj
file:
1<ItemGroup>
2 <Content Include="build/**" PackagePath="build/"/>
3</ItemGroup>
The Include
attribute ensures that all of the files in the local build
folder are included as content. Because IncludeContentInPack
is set to true
, these files will be included in the package. The PackagePath
attribute specifies the folder in the package where the files should be placed. In this case, they are all placed in the build
folder.
The resulting package will contain a build
folder with the Muse.ZipPackage.props
and Muse.ZipPackage.targets
files.
The final version of the project file, Muse.ZipPackage.csproj
will look like this:
1<Project Sdk="Microsoft.NET.Sdk">
2
3 <!-- Configuration settings -->
4 <PropertyGroup>
5 <TargetFramework>net8.0</TargetFramework>
6 <NoBuild>true</NoBuild>
7 <IncludeBuildOutput>false</IncludeBuildOutput>
8 <IncludeContentInPack>true</IncludeContentInPack>
9 <NoDefaultExcludes>true</NoDefaultExcludes>
10 <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
11 <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
12 </PropertyGroup>
13
14 <!-- Package metadata -->
15 <PropertyGroup>
16 <PackageId>Muse.ZipPackage</PackageId>
17 <!-- Use a condition to allow this value to be passed in on the command line -->
18 <Version Condition="'$(Version)' == ''">1.0.0</Version>
19 <Authors>Ken Muse</Authors>
20 <Description>Support automatically creating a ZIP package during publish</Description>
21
22 <!-- Optional metadata -->
23 <Title>ZIP Packaging Target</Title>
24 <ProjectUrl>https://github.com/kenmuse/ZipPackage</ProjectUrl>
25 <Copyright>Copyright ⓒ 2024 Ken Muse</Copyright>
26 <DevelopmentDependency>true</DevelopmentDependency>
27 <RequireLicenseAcceptance>false</RequireLicenseAcceptance>
28 <RepositoryType>git</RepositoryType>
29 <RepositoryUrl>https://github.com/kenmuse/ZipPackage</RepositoryUrl>
30 <PackageLicenseExpression>MIT</PackageLicenseExpression>
31 </PropertyGroup>
32
33 <!-- Define the package contents -->
34 <ItemGroup>
35 <Content Include="build/**" PackagePath="build/"/>
36 </ItemGroup>
37</Project>
Building the package
To create the package, you just need to run dotnet pack
. This will create a package file that can be pushed to any NuGet registry. By then running dotnet add package <package-name>
, developers can add this package to their project. Doing this will automatically incorporate the Package
target into the project. In addition, changes can be quickly and easily distributed. In fact, you can even take advantage of tools like GitHub Dependabot to automatically update the package when new versions are released.
As you can see, this approach is a great way to distribute common tasks (or settings) across multiple .NET projects. It allows developers to easily adopt new functionality without having to manually copy and paste code. It also ensures that the code is consistent and updatable across all projects. It also has an additional benefit: the package contents can be tested to validate its functionality.