Mert Özen Further With Every Line
Backend .NET SDK, CLI and MSBuild — #08

Understanding MSBuild: Properties, Items, Targets, Tasks, Custom Targets and Debugging Builds with Binlogs

Mert Özen Nov 15, 2026 10 min 9 views
Understanding MSBuild: Properties, Items, Targets, Tasks, Custom Targets and Debugging Builds with Binlogs

MSBuild processes a project in two phases: evaluation reads properties and items, execution runs targets, and values created in targets are invisible during evaluation. We add targets to Bookstore that write commit and local-change information into the assembly and add the Angular client to publish incrementally with Inputs/Outputs, and we use a binlog to find where a property changed, with real output.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, MSBuild 18.9); the Angular client was built with Node.js 24 and Angular 22.2 (TypeScript 6.0).

Guess First

The bookstore-admin tool of Bookstore, the online bookstore used as the shared example across this site's series, now prints which commit it was built from:

Version:     1.0.0+63bef849f427cfce32eda3cc55640dc0e9d9d857
Source:      63bef849f427cfce32eda3cc55640dc0e9d9d857
Runtime:     .NET 10.0.15
Environment: (not set)
API:         http://localhost:5080

The commit hash comes from the SourceRevisionId property in MSBuild. Let's ask for its value the way we learned in post 2:

dotnet msbuild tools/Bookstore.Admin -getProperty:SourceRevisionId

The answer is an empty line. Why is a value that ends up in the exe missing when we query the project file? The reason is that MSBuild works in two phases: some properties are not created while the files are read but while the targets run. In this post we explain MSBuild's properties, items, targets and tasks by adding two custom targets to Bookstore: one writes commit information into the assembly, the other publishes the Angular client together with the API. Then we use a binlog to answer the question "where did this value come from?"

Two Phases: Evaluation and Execution

MSBuild processes a project in two steps. In the evaluation phase it reads the project file and every file it imports, in the order from post 3; it evaluates all PropertyGroup and ItemGroup elements, expands globs and computes conditions. No command runs in this phase. In the execution phase it runs the requested targets in dependency order; the tasks inside the targets write files, call the compiler and produce new properties and items.

SourceRevisionId is read from git while the SDK's InitializeSourceControlInformation target runs, so it is created during execution. -getProperty on its own gives you the evaluation result; together with a target it gives you the value after the targets have finished:

dotnet msbuild tools/Bookstore.Admin -t:Build -getProperty:SourceRevisionId
63bef849f427cfce32eda3cc55640dc0e9d9d857

The practical consequence: a condition you write for evaluation can't see a value created during execution. <PropertyGroup Condition="'$(SourceRevisionId)' != ''"> at the top of a project file is always false. If you need such a value, the code that uses it has to live inside a target too.

Properties, Items and Metadata

A property is a single string ($(Name)); an item is a list that carries metadata (@(Name)). We defined Bookstore's Angular sources as an item list:

<PropertyGroup>
  <ClientRoot>$(MSBuildThisFileDirectory)..\..\web\bookstore-web\</ClientRoot>
  <ClientDist>$(ClientRoot)dist\bookstore-web\browser\</ClientDist>
</PropertyGroup>

<ItemGroup>
  <ClientSource Include="$(ClientRoot)src\**;$(ClientRoot)public\**" />
  <ClientSource Include="$(ClientRoot)angular.json;$(ClientRoot)package-lock.json;$(ClientRoot)tsconfig*.json" />
</ItemGroup>

Every file added by a glob comes with well-known metadata that MSBuild adds on its own: %(Filename), %(Extension), %(FullPath), %(RecursiveDir) for the part matched by the glob's **, and the file's modification time. Items can be turned into another shape with a transform: @(ClientFile->'%(Filename)%(Extension)', ', ') turns the list into a comma-separated string of file names. When a task, or an ItemGroup inside a target, refers to metadata with %(...), it is processed separately for each element of the list; this is called batching. We use it below when we build the ResolvedFileToPublish list.

Targets and Tasks

A target is a named group of tasks that run in order. You hook a target into the build in three ways: DependsOnTargets runs other targets first, while BeforeTargets and AfterTargets place the target before or after an existing SDK target. A task is the .NET class that does the actual work: Exec runs a command, Message writes to the log, Copy and RemoveDir handle files. A task's output is captured into a property or item with the Output element.

Writing Commit Information into the Assembly

The plan was a target that writes the git commit hash into the assembly during the build. Before writing it, we checked what the SDK already does and saw the Version line from the opening: since .NET 8, Source Link is part of the SDK and appends the commit hash to AssemblyInformationalVersion after a +. Microsoft lists this among the .NET 8 breaking changes; if you don't want it, set IncludeSourceRevisionInInformationalVersion to false. So no target is needed for the hash.

There is one thing the SDK doesn't tell you: was the exe built with uncommitted changes? When you are tracking down a bug, the answer to "is this exe exactly that commit?" matters. For this we added a target to Directory.Build.targets:

<!-- Writes the commit and whether the working tree had local changes into assembly metadata. -->
<Target Name="AddSourceStatusMetadata"
        BeforeTargets="GetAssemblyAttributes"
        DependsOnTargets="InitializeSourceControlInformation"
        Condition="'$(GenerateAssemblyInfo)' != 'false'">
  <!-- git status prints one line per changed file; no output means a clean working tree. -->
  <Exec Command="git status --porcelain"
        WorkingDirectory="$(MSBuildProjectDirectory)"
        ConsoleToMSBuild="true"
        StandardOutputImportance="low"
        IgnoreExitCode="true">
    <Output TaskParameter="ConsoleOutput" ItemName="_GitStatusLine" />
    <Output TaskParameter="ExitCode" PropertyName="_GitExitCode" />
  </Exec>

  <PropertyGroup>
    <_WorkingTreeDirty>false</_WorkingTreeDirty>
    <_WorkingTreeDirty Condition="'@(_GitStatusLine)' != ''">true</_WorkingTreeDirty>
  </PropertyGroup>

  <ItemGroup Condition="'$(_GitExitCode)' == '0'">
    <AssemblyMetadata Include="SourceRevision" Value="$(SourceRevisionId)" />
    <AssemblyMetadata Include="WorkingTreeDirty" Value="$(_WorkingTreeDirty)" />
  </ItemGroup>
</Target>

Let's read the parts in order. DependsOnTargets runs the SDK target that reads git information first, so $(SourceRevisionId) has a value. BeforeTargets="GetAssemblyAttributes" puts us in exactly the right place: in that target the SDK turns AssemblyMetadata items into [assembly: AssemblyMetadata(...)] attributes. Exec's ConsoleToMSBuild turns each output line of the command into an item. IgnoreExitCode and the _GitExitCode condition skip the metadata without breaking the build when the repo is built without git, for example from a source archive. Names that start with an underscore are an MSBuild convention for "private to this file".

To see the items after the targets ran, we used dotnet msbuild tools/Bookstore.Admin -t:GetAssemblyAttributes -getItem:AssemblyAttribute. We saw that the SDK itself uses the same item: the IsAotCompatible we turned on in post 6 adds IsTrimmable and IsAotCompatible metadata to the assembly. bookstore-admin info reads this metadata through reflection. Building with an uncommitted change gave:

Version:     1.0.0+63bef849f427cfce32eda3cc55640dc0e9d9d857
Source:      63bef849f427cfce32eda3cc55640dc0e9d9d857 + local changes

The choice of hook matters. Doing the same with AfterTargets="Build" would not work; by then the assembly has already been compiled. .NET 11's UseOptimizedPublish setting from post 6 also skips the full Build target in a Native AOT publish; a target hooked into the compile chain like this one keeps running there.

Hooking the Angular Build into Publish

Bookstore's Angular client runs with ng serve and a proxy during development. In production we want it to ship as a single package with the API: dotnet publish src/Bookstore.Api should also run the Angular build and put the output in wwwroot. For this we added three targets to Bookstore.Api.csproj:

<!-- npm ci only when package-lock.json is newer than the copy npm keeps in node_modules. -->
<Target Name="RestoreClient"
        Inputs="$(ClientRoot)package-lock.json"
        Outputs="$(ClientRoot)node_modules\.package-lock.json">
  <Exec Command="npm ci" WorkingDirectory="$(ClientRoot)" />
</Target>

<!-- ng build only when a source file is newer than the built index.html. -->
<Target Name="BuildClient"
        DependsOnTargets="RestoreClient"
        Inputs="@(ClientSource)"
        Outputs="$(ClientDist)index.html">
  <Exec Command="npm run build" WorkingDirectory="$(ClientRoot)" />
</Target>

<!-- Separate from BuildClient so the files are added even when BuildClient is skipped. -->
<Target Name="PublishClient"
        DependsOnTargets="BuildClient"
        BeforeTargets="ComputeResolvedFilesToPublishList"
        Condition="'$(SkipClientBuild)' != 'true'">
  <ItemGroup>
    <ClientFile Include="$(ClientDist)**" />
    <ResolvedFileToPublish Include="@(ClientFile)"
                           RelativePath="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
                           CopyToPublishDirectory="PreserveNewest" />
  </ItemGroup>
  <Message Importance="high" Text="Client files: @(ClientFile->'%(RecursiveDir)%(Filename)%(Extension)', ', ')" />
</Target>

ComputeResolvedFilesToPublishList is the SDK target that works out the list of files going to the publish folder. We add the Angular files to the list just before it; the RelativePath metadata says where each file goes in the publish folder, and %(RecursiveDir) keeps the subfolders. This target only runs on publish; dotnet build doesn't touch npm. A pipeline that builds Angular in a separate CI job can turn this step off with -p:SkipClientBuild=true.

dotnet publish src/Bookstore.Api
  Bookstore.Api -> C:\src\Bookstore\artifacts\bin\Bookstore.Api\release\Bookstore.Api.dll
  added 293 packages, and audited 294 packages in 3s
  ...
  > bookstore-web@0.0.0 build
  > ng build
  ...
  Application bundle generation complete. [0.847 seconds] - 2026-11-15T06:38:01.185Z
  Output location: C:\src\Bookstore\web\bookstore-web\dist\bookstore-web
  Client files: favicon.ico, index.html, main-BDBHRKZT.js, styles-S5NLN6H7.css
  Bookstore.Api -> C:\src\Bookstore\artifacts\publish\Bookstore.Api\release\

The API needed a few lines: UseDefaultFiles, UseStaticFiles, and MapFallbackToFile("index.html") for Angular's own routes. When we first added the fallback we noticed a side effect: an unknown address under /api/ also returned 200 and index.html. An API client would get HTML instead of JSON and notice the error late. We added a separate fallback for /api/{**path} that returns 404. We ran the published API locally and tried addresses that don't hit the database: / and /books/9780441172719 returned text/html, /main-BDBHRKZT.js returned text/javascript, and /api/nothing returned 404 with application/problem+json.

Incremental Builds with Inputs and Outputs

Inputs and Outputs tell MSBuild when a target can be skipped: if every output is newer than every input, the target doesn't run. We saw this in the detailed log (-v:d) of the second publish:

Skipping target "RestoreClient" because all output files are up-to-date with respect to the input files.
Skipping target "BuildClient" because all output files are up-to-date with respect to the input files.

The output of RestoreClient is node_modules/.package-lock.json, which npm maintains itself; npm ci writes it on every install, so the target is skipped as long as the lock file doesn't change. To measure the difference we temporarily removed the two attributes and repeated the publish three times: each publish took about 4.6 seconds, because npm ci deleted and reinstalled node_modules every time and Angular was rebuilt. With the attributes in place, the same publish finished in 0.6 seconds. Bookstore's client is small; in a real app the difference is minutes.

Why did we make PublishClient a separate target? Had we built the file list inside BuildClient, what happens in builds where the target is skipped would be left to MSBuild's rules for skipped targets. A separate target without Inputs runs on every publish and builds the list from the current dist folder. Separating the expensive work from the cheap work is the simplest way to write incremental targets.

Cleaning up is a target too: inside <Target Name="CleanClient" AfterTargets="Clean">, the RemoveDir task deletes the dist folder, and dotnet clean src/Bookstore.Api does that as well. Had we written rm -rf with Exec, the command would not be found on Windows, because Exec runs commands through cmd.exe there. Use the Copy, Delete, MakeDir and RemoveDir tasks for file operations, and keep Exec for tools like npm and git that have the same name on every platform.

"Where Did This Value Come From?" with a Binlog

The tool for seeing inside a build is the binary log. You get it with -bl; it is a single compressed file that contains every message at diagnostic level, the contents of every imported file, changes to properties and items, and the inputs and outputs of tasks. Let's ask the question from post 5 again: who clears TargetFramework in Bookstore.Contracts?

dotnet build src/Bookstore.Contracts -bl
dotnet msbuild msbuild.binlog -noconlog "-flp:v=diag;logfile=diag.log"
Select-String -Path diag.log -Pattern 'Property reassignment: \$\(TargetFramework\)' -List
diag.log:92: C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj(5,5): message : Property reassignment: $(TargetFramework)="" (previous value: "net10.0") at C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj (5,5)

The second command does not run the build again; it replays the binlog and writes a text log at diagnostic level. The answer is a single line: the value changed from net10.0 to an empty string on line 5 of the csproj. In PowerShell the -flp argument has to be quoted, because ; is a statement separator there.

Why not build with -v:diag from the start? The diagnostic log of a single small project with two TFMs was 134 thousand lines; the binlog was 500 KB. -v:diag slows the build down, dumps its output to the terminal and expects you to know in advance which information you will need. You take a binlog once and query it as much as you like. For browsing binlogs as a tree, seeing target durations and searching properties there is the MSBuild Structured Log Viewer (msbuildlog.com); we did not use its GUI in this post and did everything from the command line.

Think about the contents of a binlog before sharing it. In our test, a property value passed with -p appeared eight times in the replay log, while an environment variable the build did not use did not appear at all. The full contents of imported files are in the binlog too. If you keep binlogs as CI artifacts, treat them like secrets: connection strings and tokens passed on the command line are in there.

In Production

Take every CI build with -bl, and for failed builds keep the binlog as a short-lived artifact with restricted access. Opening the binlog is usually minutes faster than trying to reproduce a red build locally.

Write your custom targets to be incremental and measure them. You can see whether a target runs on every build from the Skipping target lines in -v:d output or from the target durations in the binlog. A target without Inputs and Outputs silently adds seconds to every developer's dotnet build.

Be careful when you hook targets to the SDK's internal target names. Targets without an underscore, such as GetAssemblyAttributes and ComputeResolvedFilesToPublishList, count as the SDK's public surface; .NET 11's breaking change note also recommends the point before ComputeResolvedFilesToPublishList for adding files to publish. SDK targets that start with an underscore are internal details and can change in the next SDK version. In post 9 we check such hooks while changing the SDK version.

Common Pitfalls

An expensive target that runs on every build. Without Inputs/Outputs, npm ci and ng build ran again on every publish; four seconds each time even for our small client.

Platform-dependent commands in Exec. rm, cp or bash-specific syntax doesn't work in cmd.exe on Windows, and the reverse is also true. Use MSBuild's own tasks.

Looking at an execution value during evaluation. Using a property created in targets in a condition at the top of the project file always sees the empty value.

An unnecessary target for the hash. Since .NET 8 the SDK adds the commit hash to InformationalVersion itself; an Exec git rev-parse target doing the same only slows the build down.

Hunting the problem with -v:diag. Finding what you are looking for in hundreds of thousands of lines is both slower and harder than taking a binlog once and querying it.

A Small Experiment

★☆☆ Take a binlog with dotnet build tools/Bookstore.Admin -bl, turn it into a diagnostic log and find how long the AddSourceStatusMetadata target took. Hint: if you add ;PerformanceSummary to the -flp argument, a summary with target durations appears at the end of the log.

★★☆ Add a ClientConfiguration property to PublishClient that runs the Angular build with a configuration other than production (npm run build -- --configuration $(ClientConfiguration)). See that BuildClient does not run again when you change the value with -p, and decide how to fix that.

★★★ Add the commit date to the metadata as well. Try running git log -1 --format=%cI with Exec and see that the % character has to be written as %25 in MSBuild. Taking into account that cmd.exe on Windows also interprets %, test that the command gives the same result on both platforms.

In this post we wrote code for the build itself. In the last post of the series we look back: what changed in the SDK from .NET 6 to 11, what to watch for when reading an old project, and what broke when we moved Bookstore's CI to SDK 11.