dotnet-msbuild-authoring について
SDK スタイルの .NET プロジェクトで MSBuild 要素を記述するためのスキルです。ターゲット順序、インクリメンタルビルド、インポート、アイテム、条件、`Directory.Build` の応用を解説します。
主な機能
- 01カスタムターゲットの実行順序
- 02入力と出力による増分ビルド
- 03Props と targets のインポート順
- 04アイテム、メタデータ、バッチ処理
- 05条件とプロパティ関数
- 06Directory.Build の応用パターン
活用シーン
- 01ビルド処理の前後にターゲットを追加
- 02不要な再ビルドの回避
- 03共有 props と targets のカスタマイズ
- 04アイテムごとのビルド動作の適用
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-msbuild-authoringインストール後、Claude Code で/dotnet-msbuild-authoringを入力して読み込まれたか確認します。
詳細
dotnet-msbuild-authoring
MSBuild プロジェクトシステム要素を作成するためのガイダンスです。BeforeTargets/AfterTargets/DependsOnTargets を使ったカスタムターゲット、Inputs/Outputs を使ったインクリメンタルビルド、props と targets のインポート順序、項目と項目メタデータ(Include/Exclude/Update/Remove)、条件、プロパティ関数、既知のメタデータ、Directory.Build.props/Directory.Build.targets の高度なパターンを扱います。
バージョンの前提: .NET 8.0 以降の SDK(MSBuild 17.8 以降)。すべての例で SDK スタイルのプロジェクトを使用します。
対象範囲: このスキルが扱うのは、カスタムターゲット、インポート順序、項目、条件、プロパティ関数、高度な Directory.Build パターンといった MSBuild 作成の基礎です。基本的なソリューション構成と共有設定(Directory.Build.props の構造、CPM、.editorconfig)は [skill:dotnet-project-structure] が扱います。MSBuild エラーの解釈と CI の差異の診断は [skill:dotnet-build-analysis] が扱います。
相互参照:ソリューション構成と基本的な Directory.Build.props の構造については [skill:dotnet-project-structure]、MSBuild エラーの解釈と CI の差異については [skill:dotnet-build-analysis] を参照してください。
カスタムターゲット
ターゲットは MSBuild における実行単位です。各ターゲットは一連のタスクを実行し、ほかのターゲットとの順序関係を宣言できます。
カスタムターゲットの定義
<Target Name="PrintBuildInfo"
BeforeTargets="Build">
<Message Importance="high"
Text="Building $(MSBuildProjectName) v$(Version) for $(TargetFramework)" />
</Target>
順序付け:BeforeTargets、AfterTargets、DependsOnTargets
ターゲットの実行順序は、次の 3 つの仕組みで制御します。
| 仕組み | 効果 | 使用する場面 |
|---|---|---|
BeforeTargets="X" | このターゲットを X より前に実行します | 既存のパイプラインに処理を挿入する場合(例:Build の前に実行) |
AfterTargets="X" | このターゲットを X の後に実行します | 後処理を行う場合(例:Publish の後に出力をコピー) |
DependsOnTargets="A;B" | このターゲットより前に A と B が実行されるようにします | 独自のターゲットグラフ内で前提となるターゲットを宣言する場合 |
<Target Name="CheckLicenseHeaders"
BeforeTargets="CoreCompile">
<Exec Command="dotnet tool run license-check -- --verify" />
</Target>
<Target Name="CopyNativeLibs"
AfterTargets="Publish">
<Copy SourceFiles="@(NativeLibrary)"
DestinationFolder="$(PublishDir)runtimes/%(NativeLibrary.RuntimeIdentifier)/native/" />
</Target>
<Target Name="FullValidation"
DependsOnTargets="CheckLicenseHeaders;RunApiCompat">
<Message Importance="high" Text="All validations passed." />
</Target>
標準ビルドパイプラインに処理を挿入する場合は、DependsOnTargets よりも BeforeTargets/AfterTargets を優先してください。DependsOnTargets は、独自のカスタムターゲットグラフを制御するのに最適です。
既存の DependsOn リストの拡張
SDK のターゲットは、拡張用に *DependsOn プロパティを公開しています。リストを置き換えるのではなく、ターゲット名を追加してください。
<PropertyGroup>
<BuildDependsOn>$(BuildDependsOn);GenerateVersionInfo</BuildDependsOn>
</PropertyGroup>
<Target Name="GenerateVersionInfo">
<WriteLinesToFile File="$(IntermediateOutputPath)Version.g.cs"
Lines="[assembly: System.Reflection.AssemblyInformationalVersion("$(InformationalVersion)")]"
Overwrite="true" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)Version.g.cs" />
</ItemGroup>
</Target>
Inputs/Outputs を使ったインクリメンタルビルド
Inputs と Outputs を持つターゲットは、出力が存在しない場合、または入力より古い場合にのみ実行されます。これはビルドのパフォーマンスにとって非常に重要です。
<Target Name="GenerateEmbeddedResources"
BeforeTargets="CoreCompile"
Inputs="@(EmbeddedTemplate)"
Outputs="@(EmbeddedTemplate->'$(IntermediateOutputPath)%(Filename).g.cs')">
<Exec Command="dotnet tool run template-gen -- %(EmbeddedTemplate.Identity) -o $(IntermediateOutputPath)%(EmbeddedTemplate.Filename).g.cs" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)%(EmbeddedTemplate.Filename).g.cs" />
</ItemGroup>
</Target>
インクリメンタルビルドの仕組み:
- MSBuild は
Inputsの項目とOutputsの項目のタイムスタンプを比較します。 - すべての出力が存在し、すべての入力より新しい場合、ターゲットは完全にスキップされます。
- いずれかの入力がいずれかの出力より新しい場合、ターゲット全体が実行されます。
インクリメンタルビルドが失敗するよくある原因:
Outputsの指定漏れ: ターゲットは毎回のビルドで実行されます。必ずInputsとOutputsを組み合わせてください。- 変動する出力: 別のターゲットがビルド中に出力パスへ書き込むと、タイムスタンプがリセットされ、不要な再ビルドが発生します。
- ジェネレーターの副作用: 内容が変わっていなくても無条件に書き込むコードジェネレーターは、インクリメンタルビルドを妨げます。一時ファイルに書き込み、内容に差異がある場合にのみコピーしてください。
- ファイルコピーのタイムスタンプ:
SkipUnchangedFiles="true"を指定したCopyタスクはタイムスタンプを保持します。指定しない場合、コピーのたびにタイムスタンプが更新されます。
Props と Targets:インポート順序
MSBuild は、特定の順序でプロジェクトファイルを評価します。適切にカスタマイズするには、この順序を理解することが不可欠です。
評価順序
1. Directory.Build.props (imported by SDK early)
2. <Project Sdk="..."> (SDK props imported)
3. Explicit <Import> in project (your .props imports)
4. Project body <PropertyGroup>, (project-level properties)
<ItemGroup>
5. SDK targets imported (SDK targets)
6. Directory.Build.targets (imported by SDK late)
7. Explicit .targets imports (your .targets imports)
ルール
.propsファイルは、プロパティの既定値を設定し、項目を定義します。プロジェクト本体より前に実行されるため、プロジェクトレベルのプロパティで上書きできます。.targetsファイルは、ターゲットを定義し、項目リストを確定します。プロジェクト本体より後に実行されるため、プロジェクトレベルの設定をすべて参照できます。
<Project>
<PropertyGroup>
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
</PropertyGroup>
</Project>
<Project>
<Target Name="ValidatePackageMetadata"
BeforeTargets="Pack"
Condition="'$(IsPackable)' == 'true'">
<Error Condition="'$(Description)' == ''"
Text="Description is required for packable projects." />
</Target>
</Project>
重要なルール: .props ファイルのプロパティには、プロジェクトレベルでの上書きを可能にするため、Condition="'$(Prop)' == ''" を使用してください。.targets ファイルのプロパティは最後に評価されるため、プロジェクトから上書きできません。
項目と項目メタデータ
項目は、ファイルまたは値をまとめた名前付きコレクションです。各項目にはメタデータ(キーと値のペア)を付与できます。
項目の操作
<ItemGroup>
<Content Include="assets/**/*.png" />
<Compile Include="**/*.cs" Exclude="**/*.generated.cs" />
<Content Update="assets/logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Pack>true</Pack>
<PackagePath>contentFiles/any/any/</PackagePath>
</Content>
<Compile Remove="legacy/**/*.cs" />
</ItemGroup>
SDK スタイルのプロジェクトでは、*.cs ファイルが自動的に含まれます。 <Compile Include="**/*.cs" /> を追加しないでください。重複項目が原因で NETSDK1022 が発生します。条件付きコンパイルのシナリオでは、先に Remove を使い、その後 Include を使ってください。
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<Compile Remove="Polyfills/**/*.cs" />
</ItemGroup>
既知のメタデータ
すべての項目には、%(ItemName.MetadataName) からアクセスできる組み込みメタデータがあります。
| メタデータ | 値 | src/Models/Order.cs の例 |
|---|---|---|
%(FullPath) | 絶対パス | /repo/src/Models/Order.cs |
%(RootDir) | ルートディレクトリ | / |
%(Directory) | ルートからの相対ディレクトリ | repo/src/Models/ |
%(Filename) | 拡張子を除いたファイル名 | Order |
%(Extension) | ファイル拡張子 | .cs |
%(RecursiveDir) | グロブ内の ** に一致する部分 | Models/(グロブが src/**/*.cs の場合) |
%(Identity) | 宣言された項目指定 | src/Models/Order.cs |
項目メタデータとバッチ処理
カスタムメタデータを使うと、MSBuild のバッチ処理を通じて項目ごとの動作を設定できます。
<ItemGroup>
<DbMigration Include="migrations/*.sql">
<TargetDb>main</TargetDb>
</DbMigration>
<DbMigration Include="migrations/audit/*.sql">
<TargetDb>audit</TargetDb>
</DbMigration>
</ItemGroup>
<Target Name="RunMigrations">
<Exec Command="sqlcmd -d %(DbMigration.TargetDb) -i %(DbMigration.Identity)"
Condition="'@(DbMigration)' != ''" />
</Target>
タスク属性内の %(Metadata) はバッチ処理を開始します。MSBuild はメタデータ値ごとに項目をグループ化し、グループごとにタスクを一度実行します。
条件
条件は、プロパティ、項目、ターゲット、タスクを評価するかどうかを制御します。
プロパティの条件
<PropertyGroup>
<LangVersion Condition="'$(LangVersion)' == ''">latest</LangVersion>
<DefineConstants Condition="$(TargetFramework.StartsWith('net8'))">$(DefineConstants);NET8_OR_GREATER</DefineConstants>
<Optimize Condition="'$(Configuration)' == 'Release'">true</Optimize>
<RuntimeIdentifier Condition="$([MSBuild]::IsOSPlatform('Windows'))">win-x64</RuntimeIdentifier>
</PropertyGroup>
項目とターゲットの条件
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
</ItemGroup>
<Target Name="SignAssembly"
AfterTargets="Build"
Condition="'$(Configuration)' == 'Release' AND '$(SignAssembly)' == 'true'">
<Exec Command="signtool sign /fd SHA256 $(TargetPath)" />
</Target>
条件演算子
| 演算子 | 例 |
|---|---|
== / != | '$(Config)' == 'Release' |
AND / OR | '$(A)' == '1' AND '$(B)' != '' |
!(否定) | !Exists('$(OutDir)') |
Exists() | Exists('$(SolutionDir)global.json') |
HasTrailingSlash() | HasTrailingSlash('$(OutputPath)') |
比較の両辺は必ずシングルクォートで囲んでください。 '$(Prop)' == 'value' が正しい書き方です。クォートなしの比較は、プロパティが空の場合に失敗します。
プロパティ関数
MSBuild プロパティでは、インラインで .NET の静的メソッドや MSBuild の組み込み関数を呼び出せます。
.NET の静的メソッド呼び出し
<PropertyGroup>
<NormalizedName>$([System.String]::Copy('$(PackageId)').ToLowerInvariant())</NormalizedName>
<ToolPath>$([System.IO.Path]::Combine('$(MSBuildThisFileDirectory)', 'tools', 'analyzer.dll'))</ToolPath>
<BuildId>$([System.Guid]::NewGuid().ToString('N'))</BuildId>
<CleanVersion>$([System.Text.RegularExpressions.Regex]::Replace('$(Version)', '-.*$', ''))</CleanVersion>
<CiServer>$([System.Environment]::GetEnvironmentVariable('CI'))</CiServer>
</PropertyGroup>
MSBuild の組み込み関数
<PropertyGroup>
<IsWindows>$([MSBuild]::IsOSPlatform('Windows'))</IsWindows>
<IsLinux>$([MSBuild]::IsOSPlatform('Linux'))</IsLinux>
<IsMacOS>$([MSBuild]::IsOSPlatform('OSX'))</IsMacOS>
<NextVersion>$([MSBuild]::Add($(PatchVersion), 1))</NextVersion>
<HasModernSdk>$([MSBuild]::VersionGreaterThanOrEquals('$(NETCoreSdkVersion)', '8.0.100'))</HasModernSdk>
<InputHash>$([MSBuild]::StableStringHash('$(InputFile)'))</InputHash>
<SharedPropsPath>$([MSBuild]::GetPathOfFileAbove('SharedConfig.props', '$(MSBuildProjectDirectory)'))</SharedPropsPath>
<SafePath>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '..', 'shared'))</SafePath>
</PropertyGroup>
便利な MSBuild プロパティ
| プロパティ | 値 |
|---|---|
$(MSBuildProjectDirectory) | 現在の .csproj を含むディレクトリ |
$(MSBuildThisFileDirectory) | 現在の .props/.targets ファイルを含むディレクトリ |
$(MSBuildProjectName) | 拡張子を除いたプロジェクト名 |
$(IntermediateOutputPath) | obj/ 出力パス |
$(OutputPath) | bin/ 出力パス |
$(TargetFramework) | 現在の TFM(例:net10.0) |
$(TargetFrameworks) | 複数の TFM のリスト(例:net8.0;net10.0) |
$(Configuration) | Debug または Release |
$(SolutionDir) | ソリューションディレクトリ(ソリューションからビルドする場合のみ設定) |
.props/.targets ファイルでは $(MSBuildProjectDirectory) ではなく $(MSBuildThisFileDirectory) を使ってください。 前者ではなく後者を使うと、ファイル自体の場所を基準に解決されます。NuGet パッケージや別のディレクトリからファイルをインポートする場合は、こちらが正しい値です。
Directory.Build.props/targets の高度なパターン
基本的な Directory.Build の構成については、[skill:dotnet-project-structure] を参照してください。このセクションでは、複数のリポジトリやモノレポのシナリオで使う高度なパターンを説明します。
GetPathOfFileAbove を使ったインポートチェーン
ネストされたディレクトリがあるモノレポでは、各階層で独自の Directory.Build.props を定義し、親へと連鎖させることができます。
repo/
Directory.Build.props (repo-wide defaults)
src/
Directory.Build.props (src-specific overrides)
MyApp/
MyApp.csproj
tests/
Directory.Build.props (test-specific overrides)
MyApp.Tests/
MyApp.Tests.csproj
<Project>
<PropertyGroup>
<_ParentBuildProps>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..'))</_ParentBuildProps>
</PropertyGroup>
<Import Project="$(_ParentBuildProps)" Condition="'$(_ParentBuildProps)' != ''" />
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
<Project>
<PropertyGroup>
<_ParentBuildProps>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..'))</_ParentBuildProps>
</PropertyGroup>
<Import Project="$(_ParentBuildProps)" Condition="'$(_ParentBuildProps)' != ''" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
</Project>
条件ガード
子のインポートによってプロパティ値が上書きされないようにします。
<Project>
<PropertyGroup>
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
</PropertyGroup>
</Project>
ルール: .props ファイル内のプロパティには、内側の .props ファイルやプロジェクトレベルのプロパティで上書きできるよう、Condition="'$(Prop)' == ''" ガードを使用してください。無条件に適用するプロパティは、最後に評価される Directory.Build.targets に記述します。
二重インポートの防止
複数の Directory.Build.props ファイルが上位へ連鎖している場合、共有インポートが 2 回評価されることがあります。これを防ぐには、センチネルプロパティを使用します。
<Project>
<PropertyGroup Condition="'$(_CommonPropsImported)' != 'true'">
<_CommonPropsImported>true</_CommonPropsImported>
<Authors>My Company</Authors>
<Company>My Company</Company>
<Copyright>Copyright (c) My Company. All rights reserved.</Copyright>
</PropertyGroup>
</Project>
センチネルプロパティとコンテンツプロパティは、!= 'true' 条件を指定した同じ PropertyGroup 内に記述する必要があります。コンテンツを == 'true' 条件を指定した別のブロックに記述しても、再評価は防げません。センチネルはすでに設定されているため、インポートのたびに実行されます。
よりすっきりとした方法として、<Import> 要素に Condition を指定できます。
<Import Project="$(SharedPropsPath)"
Condition="'$(_CommonPropsImported)' != 'true' AND Exists('$(SharedPropsPath)')" />
Directory.Build.targets での設定の強制
.targets ファイルで設定したプロパティは、プロジェクト本体の後に評価されるため、プロジェクトレベルの PropertyGroup 要素で上書きできません。
<Project>
<PropertyGroup>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsPackable)' != 'false'">
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
エージェントが注意すべき点
-
条件比較で引用符を付け忘れる。 両辺を必ず引用符で囲みます:
'$(Prop)' == 'value'。引用符のない$(Prop) == valueは、プロパティが空の場合や空白を含む場合に、エラーを出さずに失敗します。 -
共有
.props/.targetsファイルで$(MSBuildProjectDirectory)を使用する。 これはファイル自体のディレクトリではなく、インポート元のプロジェクトのディレクトリに解決されます。.props/.targetsファイル自体を基準にパスを指定するには、$(MSBuildThisFileDirectory)を使用します。 -
.targetsでプロパティを設定し、プロジェクト側で上書きできると想定する。.targetsのプロパティはプロジェクト本体の後に評価されるため、プロジェクトレベルの値を上書きします。上書き可能にするプロパティは、.propsでCondition="'$(Prop)' == ''"ガードを付けて設定します。 -
SDK スタイルのプロジェクトで
<Compile Include="**/*.cs" />を追加する。 SDK スタイルのプロジェクトでは、すべての*.csファイルが自動的に含まれます。明示的に含めると、NETSDK1022の重複項目エラーが発生します。条件付きで処理する場合は、Removeの後にIncludeを使用します。 -
Inputsを持つターゲットにOutputsを指定し忘れる。InputsはあるがOutputsがないターゲットは、ビルドのたびに実行されます。インクリメンタルに動作させるには、必ず両方を組み合わせて指定します。 -
.props/.targetsファイルで$(SolutionDir)を使用する。 このプロパティは、ソリューションファイル経由でビルドする場合にのみ設定されます。コマンドラインからdotnet build MyProject.csprojを実行すると、値は空になります。$([MSBuild]::GetPathOfFileAbove('*.sln', '$(MSBuildProjectDirectory)'))を使用するか、$(MSBuildThisFileDirectory)を基準にパスを指定します。 -
項目を
PropertyGroupに入れたり、プロパティをItemGroupに入れたりする。 項目(Include=を使用)は<ItemGroup>内に記述する必要があります。プロパティ(要素の値を使用)は<PropertyGroup>内に記述する必要があります。混在させると、評価がエラーなく失敗します。 -
親インポートチェーンに
Conditionガードを付け忘れる。 ファイルが見つからない場合、GetPathOfFileAboveは空の文字列を返します。<Import>にCondition="Exists('$(ResolvedPath)')"を指定しないと、ファイルが見つからないというエラーでビルドが失敗します。