Correctly setting indirect references in MSBUILD

822 Views Asked by At

I have always let the build team handle the build definitions. Due to some constraints, I am having to bite this off right now and don't have much of a clue as to how MSBUILD treats the XML build definition. Some insight/help would be appreciated.

After doing research, I have discovered this is a common problem with very few documented solutions. In a complex app (we have over 50 ".csproj" projects all working together as a single app) you will find that top level projects (web app, web api, win services, etc) have a reference to mid-tier projects (utilities, infrastructure, core, logging, etc) which in turn have references to 3rd party DLLs. During a full build, these 3rd party references never make it to the BIN folder.

So, without further ado, let's take a crack at making this build definition work. My recursion attempt came from this article: Recursively Copying Indirect Project Dependencies in MSBuild

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0" DefaultTargets="Build">
    <PropertyGroup>
        <VsVersion>12.0</VsVersion>
        <VsVersion Condition="'$(VS110COMNTOOLS)' != ''">11.0</VsVersion>
        <VsVersion Condition="'$(VS120COMNTOOLS)' != ''">12.0</VsVersion>
        <VsVersion Condition="'$(VS140COMNTOOLS)' != ''">14.0</VsVersion>
        <VisualStudioVersion>$(VsVersion)</VisualStudioVersion>
        <SourceDir Condition="'$(SourceDir)' == ''">..</SourceDir>
        <IncludeTest Condition="'$(IncludeTest)' == ''">True</IncludeTest>
        <DeployDatabases Condition="'$(DeployDatabases)' == ''">False</DeployDatabases>
        <RecreateDatabases Condition="'$(RecreateDatabases)' == ''">False</RecreateDatabases>
    </PropertyGroup>
    <ItemGroup Label="Business">
        <BusinessProjects Include="$(SourceDir)\Data Access\**\*.*proj;$(SourceDir)\Business\**\*.*proj;$(SourceDir)\Test\*BootStrapper\*.*proj" />
    </ItemGroup>
    <ItemGroup Label="Analytics">
        <AnalyticsProjects Include="$(SourceDir)\Analytics\**\*.*proj" />
    </ItemGroup>
    <ItemGroup Label="UI">
        <UIProjects Include="$(SourceDir)\UI\**\*.*proj" Exclude="$(SourceDir)\UI\Mobile\**\*.*proj" />
    </ItemGroup>
    <ItemGroup Label="Service">
        <ServiceProjects Include="$(SourceDir)\Service\**\*.*proj" />
    </ItemGroup>
    <ItemGroup Label="Utilities">
        <UtilityProjects Include="$(SourceDir)\Utilities\**\*.*proj" />
    </ItemGroup>
    <ItemGroup Label="Seed">
        <SeedProjects Include="$(SourceDir)\Test\*Seed*\**\*.*proj" />
    </ItemGroup>
    <ItemGroup Label="Test">
        <TestProjects Include="$(SourceDir)\Test\**\*.*proj" Exclude="$(SourceDir)\Test\Automation\**\*.*proj;$(SourceDir)\Test\*Seed*\**\*.*proj;$(SourceDir)\Test\*Test.Common\*.*proj;$(SourceDir)\Test\*BootStrapper\*.*proj" />
    </ItemGroup>
    <ItemGroup Label="ScormPlayer">
        <ScormPlayerProjects Include="$(SourceDir)\ScormPlayer\**\*.*proj" />
    </ItemGroup>
    <ItemGroup>
        <AllDatabasesProject Include=".\All Databases.proj" />
    </ItemGroup>
    <ItemGroup>
        <SharedBinariesOutput Include="$(SourceDir)\SharedBinaries\**\*.*" Exclude="$(SourceDir)\SharedBinaries\Infrastructure\**\*.*;$(SourceDir)\SharedBinaries\Education\**\*.*;$(SourceDir)\SharedBinaries\PublishUtilities\**\*.*;$(SourceDir)\SharedBinaries\ThirdParty\**\*.*" />
    </ItemGroup>
    <Target Name="MyPreBuild">
        <Message Text="VsVersion=$(VsVersion); VisualStudioVersion=$(VisualStudioVersion); VS100COMNTOOLS=$(VS100COMNTOOLS); VS110COMNTOOLS=$(VS110COMNTOOLS); VS120COMNTOOLS=$(VS120COMNTOOLS); VS140COMNTOOLS=$(VS140COMNTOOLS)" />
    </Target>
    <Target Name="Rebuild" DependsOnTargets="MyPreBuild">
        <MSBuild Targets="Rebuild" Projects="@(BusinessProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Rebuild" Projects="@(AnalyticsProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Rebuild" Projects="@(UIProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Rebuild" Projects="@(ScormPlayerProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Rebuild" Projects="@(ServiceProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Rebuild" Projects="@(UtilityProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Condition="'$(IncludeTest)' == 'True'" Targets="Rebuild" Projects="@(TestProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Rebuild" Projects="@(SeedProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
    </Target>
    <Target Name="Clean" DependsOnTargets="MyPreBuild">
        <MSBuild Targets="Clean" Projects="@(SeedProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Condition="'$(IncludeTest)' == 'True'" Targets="Clean" Projects="@(TestProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Clean" Projects="@(UtilityProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Clean" Projects="@(ServiceProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Clean" Projects="@(ScormPlayerProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Clean" Projects="@(UIProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Clean" Projects="@(AnalyticsProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Clean" Projects="@(BusinessProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <Delete Files="@(SharedBinariesOutput)" />
    </Target>
    <Target Name="Build" DependsOnTargets="MyPreBuild">
        <MSBuild Targets="Build" Projects="@(BusinessProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Build" Projects="@(AnalyticsProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Build" Projects="@(UIProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Build" Projects="@(ScormPlayerProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Build" Projects="@(ServiceProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Build" Projects="@(UtilityProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Condition="'$(IncludeTest)' == 'True'" Targets="Build" Projects="@(TestProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
        <MSBuild Targets="Build" Projects="@(SeedProjects)" Properties="VisualStudioVersion=$(VisualStudioVersion)" />
    </Target>
    <Target Condition="'$(IncludeTest)' == 'True'" Name="CopyAssemblies" DependsOnTargets="MyPreBuild">
        <PropertyGroup>
            <LastAssemblyVersion Condition="'$(LastAssemblyVersion)' == ''"></LastAssemblyVersion>
            <AssemblyDropLocation Condition="Exists($(DropLocationRoot))">$(DropLocationRoot)\..\Database\$(LastAssemblyVersion)</AssemblyDropLocation>
            <AssemblyDropLocation Condition="!Exists($(DropLocationRoot))">$(OutDir)\..\Database</AssemblyDropLocation>
        </PropertyGroup>
        <ItemGroup Condition="Exists($(AssemblyDropLocation))">
            <AssemblySourceFiles Include="$(AssemblyDropLocation)\**\*.*" />
            <AssemblySourceFiles Remove="$(AssemblyDropLocation)\logs\**\*.*" />
        </ItemGroup>
        <Copy Condition="Exists($(AssemblyDropLocation))" OverwriteReadOnlyFiles="true" SkipUnchangedFiles="true" SourceFiles="@(AssemblySourceFiles)" DestinationFiles="@(AssemblySourceFiles -&gt; '$(OutDir)%(RecursiveDir)%(Filename)%(Extension)')" />
    </Target>

    <!--KEITHB: TRY AT INCLUDING DLLs FOR PACKAGING -->
    <Target Name="AfterBuild" DependsOnTargets="CopyAssemblies">
        <!-- Here's the call to the custom task to get the list of dependencies -->
        <ScanIndirectDependencies StartFolder="$(SourceDir)\UI\" StartProjectReferences="@(UIProjects)" Configuration="$(Configuration)">
            <Output TaskParameter="IndirectDependencies" ItemName="IndirectDependenciesToCopy" />
        </ScanIndirectDependencies>

        <!-- Only copy the file in if we won't stomp something already there -->
        <Copy SourceFiles="%(IndirectDependenciesToCopy.FullPath)" DestinationFolder="$(OutputPath)" Condition="!Exists('$(OutputPath)\%(IndirectDependenciesToCopy.Filename)%(IndirectDependenciesToCopy.Extension)')" />
    </Target>

    <!-- THE CUSTOM TASK! -->
    <UsingTask TaskName="ScanIndirectDependencies" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v12.0.dll">
        <ParameterGroup>
            <StartFolder Required="true" />
            <StartProjectReferences ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="true" />
            <Configuration Required="true" />
            <IndirectDependencies ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true" />
        </ParameterGroup>
        <Task>
            <Reference Include="System.Xml" />
            <Using Namespace="Microsoft.Build.Framework" />
            <Using Namespace="Microsoft.Build.Utilities" />
            <Using Namespace="System" />
            <Using Namespace="System.Collections.Generic" />
            <Using Namespace="System.IO" />
            <Using Namespace="System.Linq" />
            <Using Namespace="System.Xml" />
            <Code Type="Fragment" Language="cs">
                <![CDATA[
var projectReferences = new List<string>();
var toScan = new List<string>(StartProjectReferences.Select(p => Path.GetFullPath(Path.Combine(StartFolder, p.ItemSpec))));
var indirectDependencies = new List<string>();

bool rescan;
do{
  rescan = false;
  foreach(var projectReference in toScan.ToArray())
  {
    if(projectReferences.Contains(projectReference))
    {
      toScan.Remove(projectReference);
      continue;
    }

    Log.LogMessage(MessageImportance.Low, "Scanning project reference for other project references: {0}", projectReference);

    var doc = new XmlDocument();
    doc.Load(projectReference);
    var nsmgr = new XmlNamespaceManager(doc.NameTable);
    nsmgr.AddNamespace("msb", "http://schemas.microsoft.com/developer/msbuild/2003");
    var projectDirectory = Path.GetDirectoryName(projectReference);

    // Find all project references we haven't already seen
    var newReferences = doc
          .SelectNodes("/msb:Project/msb:ItemGroup/msb:ProjectReference/@Include", nsmgr)
          .Cast<XmlAttribute>()
          .Select(a => Path.GetFullPath(Path.Combine(projectDirectory, a.Value)));

    if(newReferences.Count() > 0)
    {
      Log.LogMessage(MessageImportance.Low, "Found new referenced projects: {0}", String.Join(", ", newReferences));
    }

    toScan.Remove(projectReference);
    projectReferences.Add(projectReference);

    // Add any new references to the list to scan and mark the flag
    // so we run through the scanning loop again.
    toScan.AddRange(newReferences);
    rescan = true;

    // Include the assembly that the project reference generates.
    var outputLocation = Path.Combine(Path.Combine(projectDirectory, "bin"), Configuration);
    var localAsm = Path.GetFullPath(Path.Combine(outputLocation, doc.SelectSingleNode("/msb:Project/msb:PropertyGroup/msb:AssemblyName", nsmgr).InnerText + ".dll"));
    if(!indirectDependencies.Contains(localAsm) && File.Exists(localAsm))
    {
      Log.LogMessage(MessageImportance.Low, "Added project assembly: {0}", localAsm);
      indirectDependencies.Add(localAsm);
    }

    // Include third-party assemblies referenced by file location.
    var externalReferences = doc
          .SelectNodes("/msb:Project/msb:ItemGroup/msb:Reference/msb:HintPath", nsmgr)
          .Cast<XmlElement>()
          .Select(a => Path.GetFullPath(Path.Combine(projectDirectory, a.InnerText.Trim())))
          .Where(e => !indirectDependencies.Contains(e));

    Log.LogMessage(MessageImportance.Low, "Found new indirect references: {0}", String.Join(", ", externalReferences));
    indirectDependencies.AddRange(externalReferences);
  }
} while(rescan);

// Expand to include pdb and xml.
var xml = indirectDependencies.Select(f => Path.Combine(Path.GetDirectoryName(f), Path.GetFileNameWithoutExtension(f) + ".xml")).Where(f => File.Exists(f)).ToArray();
var pdb = indirectDependencies.Select(f => Path.Combine(Path.GetDirectoryName(f), Path.GetFileNameWithoutExtension(f) + ".pdb")).Where(f => File.Exists(f)).ToArray();
indirectDependencies.AddRange(xml);
indirectDependencies.AddRange(pdb);
Log.LogMessage("Located indirect references:\n{0}", String.Join(Environment.NewLine, indirectDependencies));

// Finally, assign the output parameter.
IndirectDependencies = indirectDependencies.Select(i => new TaskItem(i)).ToArray();
      ]]>
            </Code>
        </Task>
    </UsingTask>
</Project>

For each of the 8 builds I would like to recursively find the indirectly referenced DLLs. I can get this to work for one project but my brain just completely burped on getting this to work across all 8 correctly. Like, where the heck is $(OutputPath) set? How do I replicate my attempt across all 8 projects correctly?

TIA

1

There are 1 best solutions below

0
On

This is too long to add as further comment, but I quickly threw something together which recursively (by scanning ProjectReferences) finds references which have set CopyLocal to True. I assume this is what you're after - but I'm not sure: it's rather simple in comparision with your attempt. It also shows at what level it's recursing so it's easy to figure out if it's doing the correct thing.

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Target Name="RecurseReferences " DependsOnTargets="AssignProjectConfiguration">
    <Message Text="$(Parent)::$(MsbuildProjectName) references @(_ProjectReferenceWithConfiguration)" Condition="@(_ProjectReferenceWithConfiguration) != ''"/>
    <MSBuild Projects="@(_ProjectReferenceWithConfiguration)" Targets="CopyReferences"
             Properties="Parent=$(Parent)::$(MsbuildProjectName)"/>
  </Target>
  <Target Name="CopyReferences" DependsOnTargets="ResolveAssemblyReferences;RecurseReferences ">
    <Message Text="$(Parent)::$(MsbuildProjectName) depends on @(ReferenceCopyLocalPaths)" Condition="@(ReferenceCopyLocalPaths) != ''"/>
  </Target>
</Project>

Add a Copy task which copies the ReferenceCopyLocalPaths dlls wherever you want them, e.g. by passing the toplevel's project OutputPath down the line, and you're good to go. Save to e.g. recursecopy then invoke like this:

msbuild my.csproj /t:RecurseReferences /p:CustomAfterMicrosoftCSharpTargets=recursecopy.targets

Sample output for one toplevel project with recursive project references which in turn have 'hard' dependencies:

::ConsoleApp4 references ..\ClassLibrary4\ClassLibrary4.csproj
::ConsoleApp4::ClassLibrary4 references ..\ClassLibrary1\ClassLibrary1.csproj;..\ClassLibrary6\ClassLibrary6.csproj
::ConsoleApp4::ClassLibrary4::ClassLibrary1 references ..\ClassLibrary3\ClassLibrary3.csproj
::ConsoleApp4::ClassLibrary4::ClassLibrary1::ClassLibrary3 depends on C:\temp\Newtonsoft.Json.dll
::ConsoleApp4::ClassLibrary4::ClassLibrary1 depends on C:\temp\RecursiveRefs\bin\Debug\ClassLibrary35.dll
::ConsoleApp4::ClassLibrary4::ClassLibrary6 depends on C:\temp\RecursiveRefs\bin\Debug\ClassLibrary2.dll
::ConsoleApp4::ClassLibrary4 depends on C:\temp\RecursiveRefs\ClassLibrary5\bin\Debug\ClassLibrary5.dll