.NET – Copy files to a specified directory after the build

The simplest way to copy files post-build in a .NET project is to use the MSBuild Copy Task in the .csproj file, like this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <Target Name="CopyDLLs" AfterTargets="Build">
    <Message Text="Executing CopyDLLs task" Importance="High" />

    <Copy
      SourceFiles="$(TargetDir)$(ProjectName).dll;$(TargetDir)$(ProjectName).pdb"
      DestinationFolder="C:\Builds$(ProjectName)" />

    <Message Text="Copied build files" Importance="High" />
  </Target>

</Project>
Code language: HTML, XML (xml)

Note: I’m using VS2019.

My project is called NotesAPI. When I build, it logs the following messages:

1>------ Build started: Project: NotesAPI, Configuration: Debug Any CPU ------
1>NotesAPI -> C:\NotesAPI\bin\Debug\netcoreapp3.1\NotesAPI.dll
1>Executing CopyDLLs task
1>Copied build files
========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========Code language: plaintext (plaintext)

It copied the following build files into C:\Build\NotesAPI:

  • NotesAPI.dll
  • NotesAPI.pdb

In this article, I’ll explain the Copy Task syntax used in the example above. Then I’ll show how to put a timestamp in the directory name, and finally I’ll show how to zip the copied directory.

Breaking down the Copy Task syntax

Previously, the way you’d copy build files is by putting command line arguments in a post-build event. Now we have the Copy Task, which makes things a little simpler once you learn the syntax.

Let’s take a look at the Copy Task syntax by writing it from scratch.

Add the Target element

First, we need a Target element to contain the Copy Task:

<Target Name="CopyDLLs" AfterTargets="Build">

</Target>
Code language: HTML, XML (xml)

This Target has two properties:

  • Name: A unique name for the Target. My only advice here is to make sure the name is descriptive.
  • AfterTargets=”Build”: Since we want to copy the build files, we’ll have to do that after the build, hence AfterTargets=”Build”.

The CopyDLLs Target will execute after the project has built.

Add the Copy Task

When adding a Copy Task, at a bare minimum, you need to specify which files to copy and where to copy them to, like this:

<Target Name="CopyDLLs" AfterTargets="Build">

	<Copy
	  SourceFiles="$(TargetDir)$(ProjectName).dll;$(TargetDir)$(ProjectName).pdb"
	  DestinationFolder="C:\Builds$(ProjectName)" />

</Target>

Code language: HTML, XML (xml)

This Copy Task is specifying two properties:

  • SourceFiles: One or more files (separated by a semicolon). You can use the wildcard character (*) too.
  • DestinationFolder: Where to copy the files.

Both of these properties are using MSBuild macros (instead of hardcoded values):

  • $(TargetDir): The build output directory. Ex: C:\NotesAPI\bin\Debug\netcoreapp3.1\
  • $(ProjectName): The name of the project file. Ex: NotesAPI.

Add Message Tasks to log what’s happening during the build

Message Tasks are basically like log messages in the build process. They make it easier to troubleshoot problems.

Here’s how to add Message Tasks to the containing Target:

<Target Name="CopyDLLs" AfterTargets="Build">
	<Message Text="Executing CopyDLLs task" Importance="High" />

	<Copy
	  SourceFiles="$(TargetDir)$(ProjectName).dll;$(TargetDir)$(ProjectName).pdb"
	  DestinationFolder="C:\Builds$(ProjectName)" />

	<Message Text="Copied build files" Importance="High" />
</Target>
Code language: HTML, XML (xml)

Let’s say there’s a problem during the Copy Task. The Message Task logs “Executing CopyDLLs task” right before the error message, which helps us to immediately know the problem happened in the CopyDLLs task:

1>------ Build started: Project: NotesAPI, Configuration: Debug Any CPU ------
1>NotesAPI -> C:\NotesAPI\bin\Debug\netcoreapp3.1\NotesAPI.dll
1>Executing CopyDLLs task
1>C:\NotesAPI\NotesAPI.csproj(10,5): error MSB3030: Could not copy the file "\NotesAPI.dll" because it was not found.
1>Done building project "NotesAPI.csproj" -- FAILED.
========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========
Code language: plaintext (plaintext)

Timestamp the destination directory name

Let’s say every time the build runs, you want to copy files to a directory with a timestamp in the name.

Here’s how to timestamp a Copy Task’s destination directory:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <Target Name="CopyDLLs" AfterTargets="Build">
    <Message Text="Executing CopyDLLs task" Importance="High" />
    
    <PropertyGroup>
      <CopyToDir>C:\Builds$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)</CopyToDir>
    </PropertyGroup>
    
    <Copy
      SourceFiles="$(TargetDir)$(ProjectName).dll;$(TargetDir)$(ProjectName).pdb"
      DestinationFolder="$(CopyToDir)" />

    <Message Text="Copied build files to $(CopyToDir)" Importance="High" />
  </Target>

</Project>
Code language: HTML, XML (xml)

Running the build outputs the following:

1>------ Rebuild All started: Project: NotesAPI, Configuration: Debug Any CPU ------
1>NotesAPI -> C:\NotesAPI\bin\Debug\netcoreapp3.1\NotesAPI.dll
1>Executing CopyDLLs task
1>Copied build files to C:\Builds\NotesAPI_2021-05-20T121046_Debug
========== Rebuild All: 1 succeeded, 0 failed, 0 skipped ==========Code language: plaintext (plaintext)

It created the C:\Builds\NotesAPI_2021-05-20T121046_Debug directory.

Let’s break down the syntax involved here by writing it from scratch.

Add the PropertyGroup element

Think of properties like variables in code. You can add your own and name it anything and then refer to it in other places in the code.

When you add your own property, it has to be contained within a PropertyGroup element. So add a PropertyGroup element and add a new property called CopyToDir:

<Target Name="CopyDLLs" AfterTargets="Build">
	<Message Text="Executing CopyDLLs task" Importance="High" />

	<PropertyGroup>
	  <CopyToDir></CopyToDir>
	</PropertyGroup>

	<Copy
	  SourceFiles="$(TargetDir)$(ProjectName).dll;$(TargetDir)$(ProjectName).pdb"
	  DestinationFolder="C:\Builds$(ProjectName)" />

	<Message Text="Copied build files" Importance="High" />
</Target>
Code language: HTML, XML (xml)

Calculate the directory name with a timestamp

Now we have the <CopyToDir> property and need to provide a value for it. In this case, we want to specify a timestamped directory.

Here’s how:

<PropertyGroup>
  <CopyToDir>C:\Builds$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)</CopyToDir>
</PropertyGroup>
Code language: HTML, XML (xml)

This looks like a very complicated string. It’s using a combination of string literals, MSBuild macros, and even calling a method.

Let’s break it down.

  • MSBuild macros:

C:\Builds\$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)

$(ProjectName) is resolved to the name of the project. In this case, the project name is NotesAPI.

$(Configuration) is resolved to the build config. In this case, I did a Debug build, so this resolves to Debug.

  • Calling a method:

C:\Builds\$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)

This is equivalent to calling:

System.DateTime.UtcNow.ToString("yyyy-MM-ddThhmmss")
Code language: C# (cs)

Which gets the current datetime and outputs it, ex: 2021-05-20T121046.

Putting this all together, the property value dynamically resolves to: C:\Builds\NotesAPI_2021-05-20T121046_Debug.

Refer to the property in the Copy and Message Tasks

Now for the most important part – using the property. To use the CopyToDir property’s value, use $(CopyToDir), like this:

<Target Name="CopyDLLs" AfterTargets="Build">
	<Message Text="Executing CopyDLLs task" Importance="High" />

	<PropertyGroup>
	  <CopyToDir>C:\Builds$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)</CopyToDir>
	</PropertyGroup>

	<Copy
	  SourceFiles="$(TargetDir)$(ProjectName).dll;$(TargetDir)$(ProjectName).pdb"
	  DestinationFolder="$(CopyToDir)" />

	<Message Text="Copied build files to $(CopyToDir)" Importance="High" />
</Target>
Code language: HTML, XML (xml)

When the tasks run, $(CopyToDir) will be replaced with its dynamic value (ex: C:\Builds\NotesAPI_2021-05-20T121046_Debug).

Zip the destination directory

Let’s say after you copy the files, you want to zip up the destination directory. You can use the ZipDirectory Task like this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <Target Name="CopyDLLs" AfterTargets="Build">
    <Message Text="Executing CopyDLLs task" Importance="High" />

    <PropertyGroup>
      <CopyToDir>C:\Builds$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)</CopyToDir>
    </PropertyGroup>

    <Copy
      SourceFiles="$(TargetDir)$(ProjectName).dll;$(TargetDir)$(ProjectName).pdb"
      DestinationFolder="$(CopyToDir)" />

    <Message Text="Copied build files to $(CopyToDir). Now zipping it up." Importance="High" />

    <ZipDirectory SourceDirectory="$(CopyToDir)" DestinationFile="$(CopyToDir).zip" />

    <Message Text="CopyDLLs task completed" Importance="High" />
  </Target>

</Project>
Code language: HTML, XML (xml)

Running the build outputs the following:

1>------ Rebuild All started: Project: NotesAPI, Configuration: Debug Any CPU ------
1>NotesAPI -> C:\NotesAPI\bin\Debug\netcoreapp3.1\NotesAPI.dll
1>Executing CopyDLLs task
1>Copied build files to C:\Builds\NotesAPI_2021-05-21T120836_Debug. Now zipping it up.
1>Zipping directory "C:\Builds\NotesAPI_2021-05-21T120836_Debug" to "C:\Builds\NotesAPI_2021-05-21T120836_Debug.zip".
1>CopyDLLs task completed
========== Rebuild All: 1 succeeded, 0 failed, 0 skipped ==========

Code language: plaintext (plaintext)

Note: The ZipDirectory Task itself output that friendly message, explaining exactly what it zipped and where it put the zipped file.

The ZipDirectory Task syntax is relatively simple:

  • SourceDirectory: What to zip.
  • DestinationFile: Where to put the zip file.

In both of these properties, notice that it’s referring to the CopyToDir property. The same property was used in the Copy Task. It’s a good idea to use your own property like this instead of hardcoding duplicate values.

ZipDirectory fails if there’s a newline in the directory name

When you define your own properties, keep the values on a single line. Otherwise ZipDirectory will fail with the following error:

Error MSB4018 The “ZipDirectory” task failed unexpectedly.
System.ArgumentException: Illegal characters in path.

For example, you’d run into this error if you defined the CopyToDir property like this:

<PropertyGroup>
  <CopyToDir>
	C:\Builds$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)
  </CopyToDir>
</PropertyGroup>
Code language: HTML, XML (xml)

Notice that the value defined in the property is actually on a newline. That newline is part of the string, and ZipDirectory can’t handle it.

Instead, always put the property value on a single line, like this:

<PropertyGroup>
  <CopyToDir>C:\Builds$(ProjectName)_$([System.DateTime]::UtcNow.ToString(yyyy-MM-ddThhmmss))_$(Configuration)</CopyToDir>
</PropertyGroup>
Code language: HTML, XML (xml)

Comments are closed.