|
| 1 | +--- |
| 2 | +title: "Creating Your Own MSBuild SDK - It's Easier Than You Think!" |
| 3 | +date: "2025-12-25T21:58:58-05:00" |
| 4 | +categories: [msbuild, dotnet] |
| 5 | +description: "An MSBuild SDK is basically a NuGet package that automatically imports .props and .targets files into your project. That's it. That's the whole thing." |
| 6 | +image: ./cover.png |
| 7 | +--- |
| 8 | + |
| 9 | +I'll be honest - I put off learning how MSBuild SDKs work for way too long. Every time I saw that `Sdk="Microsoft.NET.Sdk"` attribute at the top of a `.csproj` file, I just accepted it as magic and moved on. But recently I needed to create a custom SDK, and after banging my head against the wall for a bit, I finally figured it out. |
| 10 | + |
| 11 | +Spoiler: it's not nearly as scary as I thought. |
| 12 | + |
| 13 | +## What Even Is an MSBuild SDK? |
| 14 | + |
| 15 | +Before we dive in, let's clear up what we're actually talking about. An MSBuild SDK is basically a NuGet package that automatically imports `.props` and `.targets` files into your project. That's it. That's the whole thing. |
| 16 | + |
| 17 | +When you write: |
| 18 | + |
| 19 | +```xml |
| 20 | +<Project Sdk="MyAwesome.Sdk/1.0.0"> |
| 21 | +``` |
| 22 | + |
| 23 | +MSBuild goes "oh, you want that SDK?" and then imports `Sdk.props` at the very beginning and `Sdk.targets` at the very end of your project. Everything in between is your actual project content. |
| 24 | + |
| 25 | +## The Folder Structure |
| 26 | + |
| 27 | +Here's what your SDK package needs to look like: |
| 28 | + |
| 29 | +``` |
| 30 | +MyAwesome.Sdk/ |
| 31 | +├── Sdk/ |
| 32 | +│ ├── Sdk.props ← Imported first |
| 33 | +│ └── Sdk.targets ← Imported last |
| 34 | +└── MyAwesome.Sdk.csproj |
| 35 | +``` |
| 36 | + |
| 37 | +The `Sdk/` folder is the magic folder. MSBuild looks there specifically. |
| 38 | + |
| 39 | +## Creating the Props File |
| 40 | + |
| 41 | +The `.props` file runs before anything else in the project. This is where you set up defaults: |
| 42 | + |
| 43 | +```xml |
| 44 | +<Project> |
| 45 | + <PropertyGroup> |
| 46 | + <!-- Set defaults that users can override --> |
| 47 | + <TargetFramework Condition="'$(TargetFramework)' == ''">net8.0</TargetFramework> |
| 48 | + <ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings> |
| 49 | + |
| 50 | + <!-- Properties you always want set --> |
| 51 | + <MyCustomProperty>true</MyCustomProperty> |
| 52 | + </PropertyGroup> |
| 53 | +</Project> |
| 54 | +``` |
| 55 | + |
| 56 | +See those `Condition` attributes? That's the key pattern. You're saying "only set this if the user hasn't already set it." This lets your SDK provide sensible defaults while still allowing customization. |
| 57 | + |
| 58 | +## Creating the Targets File |
| 59 | + |
| 60 | +The `.targets` file runs after the project content. This is where you do the real work: |
| 61 | + |
| 62 | +```xml |
| 63 | +<Project> |
| 64 | + <!-- Auto-include certain files --> |
| 65 | + <ItemGroup Condition="'$(EnableDefaultMyItems)' != 'false'"> |
| 66 | + <None Include="**/*.config" /> |
| 67 | + </ItemGroup> |
| 68 | + |
| 69 | + <!-- Add custom build targets --> |
| 70 | + <Target Name="MyCustomTarget" BeforeTargets="Build"> |
| 71 | + <Message Importance="high" Text="Look ma, I'm in a custom SDK!" /> |
| 72 | + </Target> |
| 73 | + |
| 74 | + <!-- Validate configuration --> |
| 75 | + <Target Name="ValidateStuff" BeforeTargets="BeforeBuild"> |
| 76 | + <Warning Condition="'$(SomeProperty)' == ''" |
| 77 | + Text="Hey, you probably want to set SomeProperty." /> |
| 78 | + </Target> |
| 79 | +</Project> |
| 80 | +``` |
| 81 | + |
| 82 | +## Wrapping Other SDKs |
| 83 | + |
| 84 | +Here's where it gets interesting. You probably don't want to recreate everything from scratch - you want to build *on top of* the existing .NET SDK. The pattern looks like this: |
| 85 | + |
| 86 | +**Sdk.props:** |
| 87 | + |
| 88 | +```xml |
| 89 | +<Project> |
| 90 | + <!-- Import the base SDK props first --> |
| 91 | + <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" /> |
| 92 | + |
| 93 | + <!-- Then add your customizations --> |
| 94 | + <PropertyGroup> |
| 95 | + <MyCustomDefault>true</MyCustomDefault> |
| 96 | + </PropertyGroup> |
| 97 | +</Project> |
| 98 | +``` |
| 99 | + |
| 100 | +**Sdk.targets:** |
| 101 | + |
| 102 | +```xml |
| 103 | +<Project> |
| 104 | + <!-- Your custom logic first --> |
| 105 | + <ItemGroup> |
| 106 | + <None Include="**/*.special" /> |
| 107 | + </ItemGroup> |
| 108 | + |
| 109 | + <!-- Then import base SDK targets --> |
| 110 | + <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" /> |
| 111 | +</Project> |
| 112 | +``` |
| 113 | + |
| 114 | +The order matters here. Props imports happen outside-in (base first, then yours), and targets happen inside-out (yours first, then base). At least, that's what made sense for my use case - your mileage may vary. |
| 115 | + |
| 116 | +## The .csproj for Your SDK Package |
| 117 | + |
| 118 | +Your SDK project itself is pretty minimal: |
| 119 | + |
| 120 | +```xml |
| 121 | +<Project Sdk="Microsoft.NET.Sdk"> |
| 122 | + <PropertyGroup> |
| 123 | + <TargetFramework>netstandard2.0</TargetFramework> |
| 124 | + <PackageId>MyAwesome.Sdk</PackageId> |
| 125 | + <Version>1.0.0</Version> |
| 126 | + <PackageType>MSBuildSdk</PackageType> |
| 127 | + <IncludeBuildOutput>false</IncludeBuildOutput> |
| 128 | + <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking> |
| 129 | + </PropertyGroup> |
| 130 | + |
| 131 | + <ItemGroup> |
| 132 | + <None Include="Sdk\**" Pack="true" PackagePath="Sdk" /> |
| 133 | + </ItemGroup> |
| 134 | +</Project> |
| 135 | +``` |
| 136 | + |
| 137 | +The important bits: |
| 138 | + |
| 139 | +- `PackageType>MSBuildSdk` - tells NuGet this is an SDK package |
| 140 | +- `IncludeBuildOutput>false` - you're not shipping a DLL, just the props/targets |
| 141 | +- That `ItemGroup` makes sure your `Sdk/` folder ends up in the right place in the package |
| 142 | + |
| 143 | +## Testing Locally (The Part I Struggled With) |
| 144 | + |
| 145 | +This is where I wasted the most time. You can't just `dotnet build` and expect Visual Studio to find your SDK. Here's what actually works: |
| 146 | + |
| 147 | +1. Build your SDK package: `dotnet pack -c Release` |
| 148 | +2. Add your output folder as a local NuGet source |
| 149 | +3. Reference the exact version in your test project |
| 150 | + |
| 151 | +Or, the sneaky way - during development, just use `Microsoft.NET.Sdk` in your test project and manually import your props/targets: |
| 152 | + |
| 153 | +**Directory.Build.props:** |
| 154 | + |
| 155 | +```xml |
| 156 | +<Project> |
| 157 | + <Import Project="../src/MyAwesome.Sdk/Sdk/Sdk.props" /> |
| 158 | +</Project> |
| 159 | +``` |
| 160 | + |
| 161 | +**Directory.Build.targets:** |
| 162 | + |
| 163 | +```xml |
| 164 | +<Project> |
| 165 | + <Import Project="../src/MyAwesome.Sdk/Sdk/Sdk.targets" /> |
| 166 | +</Project> |
| 167 | +``` |
| 168 | + |
| 169 | +This way you can iterate without constantly rebuilding packages. Just switch to the real `Sdk="..."` reference when you're ready to ship. |
| 170 | + |
| 171 | +## Common Gotchas |
| 172 | + |
| 173 | +A few things that tripped me up: |
| 174 | + |
| 175 | +1. **Props vs Targets confusion** - If your defaults aren't working, you probably put them in targets instead of props. Properties need to be set before the project content, not after. |
| 176 | + |
| 177 | +2. **Condition syntax** - It's `Condition="'$(Prop)' == ''"` with single quotes inside double quotes. I mess this up constantly. |
| 178 | + |
| 179 | +3. **Import order matters** - Some properties from other SDKs or build tools need to be set super early in props, before they get evaluated. If something isn't working, try moving it earlier in the import chain. |
| 180 | + |
| 181 | +4. **The casing matters** - The folder must be `Sdk/` with a capital S. Lowercase won't work on case-sensitive file systems. |
| 182 | + |
| 183 | +5. **NuGet caching will drive you insane** - When testing locally, NuGet aggressively caches packages. Either bump your version number every time, or clear the cache with `dotnet nuget locals all --clear`. Trust me on this one. |
| 184 | + |
| 185 | +## Wrapping Up |
| 186 | + |
| 187 | +Creating an MSBuild SDK isn't rocket science - it's really just packaging up props and targets files in a specific folder structure. The hardest part is figuring out which properties go where and what order to import things. |
| 188 | + |
| 189 | +If you're building something that needs consistent project configuration across multiple projects, or you're wrapping complex build tooling behind a simpler interface, an SDK is a great way to go. |
| 190 | + |
| 191 | +If you have questions or run into issues I didn't cover, let me know - I'm still learning this stuff myself and always happy to compare notes. |
0 commit comments