Building the solution
Before starting, if you don’t know Braden, you should check out his newsletter. He shares insightful articles on computational development in the AEC space. As with the previous article, Braden continues to collaborate on this series, contributing his sharp insights and experience throughout.
Now that we’ve established our monolithic project structure from the first article, let’s focus on implementing the build orchestration. The conditional logic we introduced scales to manage builds across multiple host applications (Revit, Civil3D, Rhino) and their various versions. Our configuration matrix expands the naming patterns from the first article into a comprehensive system: DebugR22, DebugR23, DebugC22, ReleaseR22, DebugGhNet48, DebugGhNet7, ReleaseGhNet7
and so forth. Each configuration triggers specific build logic – for example, DynR22Debug
, targets Dynamo for Revit 2022 and copies assemblies to the appropriate debugging location. As mentioned in the previous article, our build.props
and build.targets
files serve as central control panels. We now expand this concept by adding the IsDynamo
and IsCivil
properties in the build.props
that identifies whether we are building for Dynamo first and then for Civil3d or Revit. These properties work in conjunction with the conditional compilation system we established, automatically determining the target platform and applying the correct framework, references, and output paths.
<Project>
<PropertyGroup>
<IsDynamo>false</IsDynamo>
<IsCivil>false</IsCivil>
</PropertyGroup>
<!-- We use a RegEx to determine the configuration for building on Dynamo and, if applicable, whether it is for Revit or Civil3D. -->
<PropertyGroup>
<!-- Matches: DebugR22, ReleaseC23, DebugDynCore, etc. -->
<IsDynamo Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(Configuration)', '^(Debug|Release)(C|R)\d{2}$')) OR '$(Configuration)' == 'DebugDynCore'">true</IsDynamo>
<!-- Matches: DebugC22, ReleaseC23, etc. (Civil3D configurations) -->
<IsCivil Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(Configuration)', '^(Debug|Release)C\d{2}$'))">true</IsCivil>
</PropertyGroup>
</Project>
Dynamo’s multi-project
Unlike Grasshopper’s single-project approach, Dynamo packages require multiple assemblies working together: ZeroTouchNodes
, Extension
, ExplicitNodes
, and Functions
. The ZeroTouchNodes
project serves as the build orchestrator, triggering compilation of all related projects and collecting their outputs into a unified package structure.
To manage this complexity, we define centralised package locations in build.props
:
<!-- In a future article, we will make a small change to this based on other settings.
But for now, this gives you the ideas. -->
<PropertyGroup>
<PackageDebug>C:\DebugPackages\</PackageDebug>
<PackageRevit>C:\RevitPackages\</PackageRevit>
<PackageCivil>C:\CivilPackages\</PackageCivil>
</PropertyGroup>
Package assembly process
The build process then uses our IsDynamo
and IsCivil
properties to route files to the appropriate package directory, creating a structured collection point for all dependencies before final packaging.
<!-- We condition this target only for Dynamo build -->
<Target Name="CopyingFiles" AfterTargets="CoreBuild" Condition="'$(IsDynamo)' == 'true'">
<!-- Define the package folder and its subdirectories based on build configuration -->
<PropertyGroup>
<!-- Set PackageFolder for Debug builds (e.g., for local testing) -->
<PackageFolder Condition="$(Configuration.Contains('Debug'))">$(PackageDebug)$(PackageName)</PackageFolder>
<!-- Set PackageFolder for Release builds, using Revit or Civil paths based on IsCivil -->
<PackageFolder Condition="'$(IsCivil)' == 'false' AND $(Configuration.Contains('Release'))">$(PackageRevit)$(PackageName)</PackageFolder>
<PackageFolder Condition="'$(IsCivil)' == 'true' AND $(Configuration.Contains('Release'))">$(PackageCivil)$(PackageName)</PackageFolder>
<!-- Define subfolders for binaries, extra files, and DYF files -->
<BinFolder>$(PackageFolder)bin\</BinFolder>
<ExtraFolder>$(PackageFolder)extra\</ExtraFolder>
<DyfFolder>$(PackageFolder)dyf\</DyfFolder>
</PropertyGroup>
<!-- Gather all files to be included in the package -->
<ItemGroup>
<Dlls Include="$(OutputPath)*.dll" />
<Pdbs Include="$(OutputPath)*.pdb" />
<Xmls Include="$(OutputPath)*.xml" />
<Xmls Include="$(ProjectDir)manifests\*.xml" Condition="Exists('$(ProjectDir)manifests')" />
<Pkg Include="$(ProjectDir)manifests\*.json" Condition="Exists('$(ProjectDir)manifests')" />
<ExtraFiles Include="$(ProjectDir)\ExtraFiles\**\*" Condition="Exists('$(ProjectDir)\ExtraFiles')" />
<DyfFiles Include="$(ProjectDir)\DyfFiles\**\*" Condition="Exists('$(ProjectDir)\DyfFiles')" />
</ItemGroup>
<!-- Create subdirectories in the package folder if they don’t already exist -->
<MakeDir Directories="$(ExtraFolder)" Condition="!Exists($(ExtraFolder))" />
<MakeDir Directories="$(DyfFolder)" Condition="!Exists($(DyfFolder))" />
<!-- Copy collected files to their designated package subfolders -->
<Copy SourceFiles="@(Dlls)" DestinationFolder="$(BinFolder)" />
<Copy SourceFiles="@(Pdbs)" DestinationFolder="$(BinFolder)" />
<Copy SourceFiles="@(Xmls)" DestinationFolder="$(BinFolder)" />
<Copy SourceFiles="@(Cache)" DestinationFolder="$(BinFolder)" />
<Copy SourceFiles="@(Pkg)" DestinationFolder="$(PackageFolder)" Condition="@(Pkg->Count()) > 0" />
<Copy SourceFiles="@(ExtraFiles)" DestinationFolder="$(ExtraFolder)" Condition="@(ExtraFiles->Count()) > 0" />
<Copy SourceFiles="@(DyfFiles)" DestinationFolder="$(DyfFolder)" Condition="@(DyfFiles->Count()) > 0" />
</Target>
Dynamo packages require specific metadata files (pkg.json
and DynamoCustomization.xml
) that must be customised for each target platform. We generate these dynamically during the build:
<!-- Build the pkg JSON. You can keep see how the IsCivil property is used to customise logic -->
<PropertyGroup>
<Software>Revit</Software>
<Software Condition="'$(IsCivil)' == 'true'">Civil3D</Software>
</PropertyGroup>
<!-- Yes we change the json and xml file once has been moved in the identified folder -->
<Target Name="WritePkg" AfterTargets="CopyingFiles" Condition="Exists('$(PackageFolder)pkg.json')">
<Exec Command='echo { "license":"MIT", "file_hash":null, "name":"LibraryName", "version":"$(Version)", "description":"Library description", "group":"", "keywords":["Library", "$(Software)"], "dependencies":[], "contents":"A Dynamo library for $(Software) version $(SoftwareVersion).", "engine_version":"$(DynamoVersion)", "engine":"dynamo", "engine_metadata":"", "site_url":"", "repository_url":"", "contains_binaries":true, "node_libraries":["ZeroTouchNodes, Version=$(Version), Culture=neutral, PublicKeyToken=null", "ExplicitNodes, Version=$(Version), Culture=neutral, PublicKeyToken=null"]} > $(PackageFolder)pkg.json' />
</Target>
<!-- Set the DynamoCustomization xml
The changes are to replace the nodes that specifically use the RevitApi or CivilApi and vice versa.
Of course this also happen at code level using preprocessor directives to control conditional compilation-->
<Target Name="WriteDynamoCustom" AfterTargets="WritePkg">
<Exec Condition="'$(IsCivil)' == 'true'" Command="powershell -NoProfile -Command "(Get-Content $(BinFolder)ZeroTouchNodes_DynamoCustomization.xml) | ForEach-Object { $_ -replace 'RevitApi','CivilApi' } | Set-Content $(BinFolder)ZeroTouchNodes_DynamoCustomization.xml"" />
<Exec Condition="'$(IsCivil)' == 'false'" Command="powershell -NoProfile -Command "(Get-Content $(BinFolder)ZeroTouchNodes_DynamoCustomization.xml) | ForEach-Object { $_ -replace 'CivilApi','RevitApi' } | Set-Content $(BinFolder)ZeroTouchNodes_DynamoCustomization.xml"" />
</Target>
This approach automatically generates platform-specific packages from a single codebase, with the build system handling references, metadata, and folder structure based on the target configuration.

Dependency hell
Dependency hell is often a problem in Dynamo, which grows exponentially when you start using other libraries that bring their dependencies. You will inevitably encounter trouble with dependencies used in multiple packages that request different versions of the same library, such as Newtonsoft.Json
, which is notorious for causing conflicts between Dynamo packages and Revit add-ins.
ILRepack solves this by merging all dependencies into a single, self-contained assembly. As an open-source alternative to Microsoft’s ILMerge, ILRepack is actively maintained, works with .NET Core and .NET Standard, and provides better customisation options for our multi-platform build strategy.
We integrated ILRepack after adding telemetry capture functionality to our libraries (a more detailed explanation will be provided in a future article), which introduced dependency clashes in both Dynamo and Grasshopper when multiple packages used conflicting versions of the same dependencies.
ILRepack configuration
Following our established pattern of centralized settings, we create an ILRepack.targets
file alongside our other build files:
<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<LibraryPath>$(PackageFolder)</LibraryPath>
<!-- Dynamo-specific condition: include both package bin folder and Dynamo DLL paths -->
<LibraryPath Condition="'$(IsDynamo)' == 'true'">$(PackageFolder)bin\;@(DynamoDlls->'%(RelativeDir)')</LibraryPath>
</PropertyGroup>
<!-- ILRepack target that executes after determining which assemblies to merge -->
<Target Name="ILRepacker" AfterTargets="DetermineILRepackInputsOutputs">
<ILRepack
Parallel="true" <!-- Use parallel processing for faster merging -->
Internalize="true" <!-- Make merged types internal to prevent conflicts -->
DebugInfo="true" <!-- Include debug symbols in merged assembly -->
InputAssemblies="$(OutputAssembly);@(MergeAssemblies)" <!-- Primary + dependencies -->
LibraryPath="$(LibraryPath)" <!-- Paths to search for reference assemblies -->
TargetKind="Dll" <!-- Output type: library -->
TargetPlatformDirectory="$(FrameworkDir)$(FrameworkVersion)" <!-- .NET framework path -->
OutputFile="$(OutputAssembly)" <!-- Overwrite original assembly with merged version -->
Union="false" <!-- Don't merge types with identical names -->
CopyAttributes="false" <!-- Prevent marshaling attribute conflicts -->
AllowMultiple="false" <!-- Don't allow multiple assembly-level attributes -->
Wildcards="false" /> <!-- Don't expand wildcards in assembly names -->
</Target>
</Project>
Critical Setting: CopyAttributes="false"
prevents conflicts with System.Runtime.InteropServices
marshalling attributes (MarshalAsAttribute, DllImportAttribute
) that are common in Dynamo packages targeting Autodesk applications. By disabling attribute copying, we ensure that only the primary assembly’s marshalling specifications are preserved, preventing attribute conflicts that could compromise the package’s ability to properly interface with Revit’s or Civil3D’s native APIs.
Integration with build process
ILRepack integrates with our existing CopyingFiles
target from the previous section. After files are copied to the package structure, we determine which assemblies to merge:
<!-- Define Dynamo core assemblies that need special handling -->
<ItemGroup>
<!-- DynamoServices.dll path - used for library resolution during merge -->
<DynamoDlls Include="$(PkgDynamoVisualProgramming_DynamoServices)\lib\netstandard2.0\DynamoServices.dll" />
</ItemGroup>
<!-- Target: Determine which assemblies to merge and which to exclude -->
<Target Name="DetermineILRepackInputsOutputs" AfterTargets="MoveFiles">
<Copy SourceFiles="@(MsalDlls)" DestinationFolder="$(PackageFolder)bin" SkipUnchangedFiles="true" />
<PropertyGroup>
<OutputAssembly>$(AssemblyName).dll</OutputAssembly>
</PropertyGroup>
<!-- Define global assemblies that should always be merged -->
<ItemGroup>
<GlobalMergeAssemblies Include="$(PackageFolder)bin\Newtonsoft.Json.dll" />
<!-- Add other common dependencies here -->
<!-- <GlobalMergeAssemblies Include="$(PackageFolder)bin\YourTelemetryLibrary.dll" /> -->
</ItemGroup>
<!-- Define assemblies to exclude from merging -->
<ItemGroup>
<ExcludeMergeAssemblies Include="$(OutputAssembly)" />
<!-- Exclude other application assemblies -->
</ItemGroup>
<!-- Define specific assemblies to include in the merge -->
<ItemGroup>
<IncludeMergeAssemblies Include="$(PackageFolder)bin\Extension.dll" />
<IncludeMergeAssemblies Include="@(GlobalMergeAssemblies)" />
</ItemGroup>
<ItemGroup>
<MergeAssemblies Include="@(IncludeMergeAssemblies)" Exclude="@(ExcludeMergeAssemblies)" />
</ItemGroup>
</Target>
<!-- Target: Clean up merged assemblies after ILRepack completes -->
<Target Name="DeleteMergedAssemblies" AfterTargets="ILRepacker">
<Delete Files="@(MergeAssemblies)" />
</Target>
Important Note: In the first article, we showed how to reference Dynamo packages in build.targets
. However, when we want to make specific DLLs discoverable for the merge process, we need to know their exact paths. For this reason, the DynamoServices
DLL reference includes the additional property GeneratePathProperty="true"
, which creates an MSBuild property containing the package’s installation path:
<PackageReference Include="DynamoVisualProgramming.DynamoServices"
Version="$(DynamoVersion).*"
GeneratePathProperty="true">
</PackageReference>
This generates the $(PkgDynamoVisualProgramming_DynamoServices)
property that we use in the item group above.
For Grasshopper: You need to adjust the types of DLLs being merged (Grasshopper assemblies instead of Dynamo assemblies), but the process and code structure remain the same.
The Result
Now we have our Dynamo or Grasshopper library that will not clash with any other DLLs because ILRepack has created a unique, self-contained assembly by merging all the necessary dependencies. This eliminates version conflicts and ensures predictable behaviour across different environments.
Final deployment: As a next step, you can add another target to move the final package to the desired location where Dynamo and Grasshopper load their libraries, or point the tools directly to the directory where ILRepack performed the merge operation. For development scenarios, you may want to copy the merged package to your local packages folder for testing purposes.
This concludes the second part of our series! Now that the project has been appropriately configured and the build process established, we’re ready to move on to the next crucial aspect: comprehensive testing strategies for multi-platform computational design libraries.