Advanced MSBuild: The Right Way™ of writing .props files

TL;DR: I often see people having trouble with their .props and .targets files. The files don’t work as expected in the first place or are brittle. Following a couple of simple rules will give you robust files to import that will just work.

Rule 1: Do not use SolutionDir

<!--
  <repo_root>
  |__ src
      |__ ImportingProject
          - ImportingProject.csproj
  - Common.props
  - MySolution.sln

  Content of ImportingProject.csproj:
-->

<!-- Bad: using $(SolutionDir) -->
<Import Project="$(SolutionDir)/Common.props" />

<!-- Good: using relative paths instead -->
<Import Project="../../Common.props" />

This is especially important when migrating from old .NET-Framework-style projects that were managed mainly by Visual Studio.
The “solution” is ultimately a Visual Studio concept, even though the dotnet CLI supports solution files.
When you use another IDE or none at all, there is no “solution”.

The primary build target is the project file.
Projects stand for themselves, i. e. each project should be buildable by itself.
Your build server should also primarily build projects, not solutions.

In those use-cases, the MSBuild variable SolutionDir will not be set.
All occurrences of $(SolutionDir) in your project files will evaluate as empty string.

As a quick workaround, you can add -p:SolutionDir=<repo_root> to your CLI build command.
But in the long term, you need to replace all occurrences of $(SolutionDir) with something else.
Most of the time $(SolutionDir) is used in paths.
The path can usually be declared as a relative path instead.
Make sure to respect rule 2 when replacing $(SolutionDir)-based paths with relative paths in .props and .targets files (or other imported files).

Rule 2: Use the imported file itself as the basis of relative paths

<!--
  <repo_root>
  |__ src
      |__ ImportingProject
          - ImportingProject.csproj
  - Common.props
  - GlobalSuppressions.cs

  Content of Common.props:
-->

<!-- Bad: using relative paths that resolve per importing project -->
<Compile Include="../../GlobalSuppressions.cs" />

<!-- Good: using paths based on the location of the props file itself -->
<Compile Include="$(MSBuildThisFileDirectory)GlobalSuppressions.cs" />

In general, it is a good idea to base relative paths in imported .props/.targets files on the location of the .props/.targets file itself.
The path will then be independent of the path of the project into which it is imported.

That goes for both explicitly imported files and implicitly imported files such as Directory.Build.props and the like.

When you use a relative path like ../GlobalSppressions.cs instead, the path will resolve to a path relative to the csproj into which it was imported.
This is brittle and stops working as soon as the importing projects are at different depths of the folder hierarchy.

Note that those well-known MSBuild location properties such as MSBuildThisFileDirectory usually already contain a trailing slash.

Rule 3: Prepare import hierarchies

<!-- from the documentation. Ok, but has to be adapted for each implicitly imported file and
     breaks when the imported file is removed. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<!-- Better, because it is the same for every file and does not break on hierarchy changes. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove($(MSBuildThisFile), '$(MSBuildThisFileDirectory)../'))"
        Condition="$([MSBuild]::GetPathOfFileAbove($(MSBuildThisFile), '$(MSBuildThisFileDirectory)../')) != ''" />

In contrast to other mechanisms for defining global defaults (like .editorconfig), the implicitly imported Directory.Build.props, Directory.Packages.props and friends do not by default form a hierarchy.
Only the file closest to the project file will be implicitly imported.
This is well-documented along with a way to explicitly import the next file up the folder hierarchy.

Nonetheless, with the above code snippet I propose a unified and more robust way of importing the next file up that works for all implicitly imported files without changing a single character.
It also continues to work when the file next up the folder hierarchy is removed and re-added from time to time during development.

1 Comment

Comments are closed