Ken Muse

Distributing Custom .NET Tasks


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.

PropertyValueDescription
TargetFrameworknet8.0A project must have a target framework. Any .NET version will work for this package.
NoBuildtrueThe project does not have any code to compile and does should not be built.
IncludeBuildOutputfalseDo not include any compiled files in the package
IncludeContentInPacktrueInclude files that are not compiled (Content elements in the project) in the package.
NoDefaultExcludesfalsePrevents default exclusion of NuGet package files and files and folders starting with a dot; none of the file names start with a dot.
SuppressDependenciesWhenPackingtrueBy 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.
GeneratePackageOnBuildfalseBy 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.

PropertyDescription
PackageIdUniquely identifies the package
VersionThe version of the package
AuthorsThe name(s) of the package author
DescriptionDescribes the purpose of the package

There are additional optional properties that can be set:

PropertyDescription
DevelopmentDependencySet to true, it prevents the package from being included if the consuming project creates a package.
TitleA friendly name for the package
ProjectUrlA web page providing details about the package
CopyrightCopyright notice for the package
RequiresLicenseAcceptanceIndicates whether the client must prompt the consumer to accept the package license before installing the package.
RepositoryTypeSet to git to provide a link to the source code repository for the package
RepositoryUrlThe URL to the source code repository
RepositoryBranchThe branch associated with the release
RepositoryCommitThe commit associated with the release
PackageLicenseExpressionThe 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.