Run an MSBuild target once per project instead of once per target framework

Most online resources will recommend using BeforeTargets or AfterTargets to hook your target into the MSBuild lifecycle Build method.

<Target Name="MyPackage_BeforeBuild"
        BeforeTargets="Build">
    <!-- Do things -->
</Target>

<Target Name="MyPackage_AfterBuild"
        AfterTargets="Build">
    <!-- Do things -->
</Target>

But this breaks down if you add another target framework.

<PropertyGroup>
  <TargetFrameworks>net472;netcoreapp3.1</TargetFrameworks>
</PropertyGroup>

<Target Name="MyPackage_BeforeBuild"
        BeforeTargets="Build">
    <Message Text="Before build: '$(TargetFramework)'" />
</Target>
Before build: 'net472'
Before build: 'netcoreapp3.0'
Before build: ''

Oof. Your target now runs N+1 times (if the project has N target frameworks).

At best, this slows down your build. At worst, it’ll break it due to a race condition (e.g. if each iteration attempts to write to the same file).

Run a target once per project (if multi-targeted)

Most of the time you can replace Build with DispatchToInnerBuilds.

<PropertyGroup>
  <TargetFrameworks>net472;netcoreapp3.1</TargetFrameworks>
</PropertyGroup>

<Target Name="MyPackage_BeforeBuild"
        BeforeTargets="DispatchToInnerBuilds">
    <Message Text="Before build '$(TargetFramework)'" />
</Target>
Before build: ''

Success!

Well, except that DispatchToInnerBuilds only exists for multi-targeted projects, so it will not run in a single-targeted project.

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

<Target Name="MyPackage_BeforeBuild"
        BeforeTargets="DispatchToInnerBuilds">
  <Message Text="Before build: '$(TargetFramework)'" />
</Target>

Run a target once per project

This is the layout of a NuGet package that distributes a target that runs only once per project regardless of whether the project is single- or multi-targeted.

MyPackage.nuspec

...
<files>
  <!-- files in the build/ directory run per target framework -->
  <file src="MyPackage.targets" target="build" />
  
  <!-- files in the buildMultiTargeting/ directory run once 
       per project (regardless of # of target frameworks), 
       but *only* if the project is multi-targeted -->
  <file src="MyPackage.targets" target="buildMultiTargeting" />
  <file src="MyPackage.props" target="buildMultiTargeting" />
</files>

Creates this package layout:

buildMultitargeting/
  MyPackage.props
  MyPackage.targets
build/
  MyPackage.targets (same content as other MyPackage.targets)

buildMultiTargeting/MyPackage.props

<Project>
  <PropertyGroup>
    <!-- this file only executes in the "outer" build of a
         multi-targeted project, so we set this variable to
         keep track of that information --> 
    <IsOuterBuild>true</IsOuterBuild>
  </PropertyGroup>
</Project>

build/MyPackage.targets

<PropertyGroup>
  <IsOuterBuild 
    Condition="'$(IsOuterBuild)' == ''">false</IsOuterBuild>
   
  <!-- Uue DispatchToInnerBuilds if a multi-targetedBuild -->
  <MyBeforeTargets>BuildDependsOn</MyBeforeTargets>
  <MyBeforeTargets 
    Condition="$(IsOuterBuild)">DispatchToInnerBuilds</MyBeforeTargets>
   
   <MyAfterTargets>Build</MyAfterTargets>
   <MyAfterTargets 
     Condition="$(IsOuterBuild)">DispatchToInnerBuilds</MyAfterTargets>

  <!-- to prevent targets from being run extra times,
       enforce that only the outer build of a multi-targeted
       project or a single-targeted build can run -->
  <ShouldRunTarget>false</ShouldRunTarget>
  <ShouldRunTarget 
    Condition="'$(TargetFrameworks)' == '' 
      OR $(IsOuterBuild)'">true</ShouldRunTarget>
</PropertyGroup>

<Target Name="MyBeforeBuild"
        Condition="$(ShouldRunTarget)"
        BeforeTargets="$(MyAfterTargets)">
    <!-- Do thing -->
</Target>

<Target Name="MyAfterBuild"
        Condition="$(ShouldRunTarget)"
        AfterTargets="$(MyAfterTargets)">
    <!-- Do thing -->
</Target>

NOTE: This above snippets were edited for horizontal brevity. In production code, you should always prefix your MSBuild variables with the name of your NuGet package (e.g. ShouldRunTarget -> MyProject_ShouldRunTarget). This is because MSBuild allows overwriting of variable names, so you want to be careful not to pollute the global pool of names.

Updated: