把一个 NuGet 包做成瑞士军刀:类库、分析器、MSBuild 集成和 MinVer 版本管理
一个
.nupkg里可以同时装运行时 DLL、Roslyn Analyzer、Source Generator、MSBuild 构建逻辑和自动版本号——这篇文章是通用的搭建配方,附带每一层容易踩的坑。
一、NuGet 包的目录结构不是随便放的
NuGet 包本质上是一个 ZIP 文件,里面按约定目录组织内容。SDK 风格的 .csproj 会根据项目类型(类库、分析器、工具)自动把输出放到正确的目录,但如果你要在一个包里同时放多种角色,就得自己接管打包逻辑。
先看一个"全能型"包的标准目录结构:
MySdk.nupkg
├── lib/
│ └── net9.0/
│ ├── MySdk.dll ← 运行时库,使用者编译时引用
│ └── MySdk.pdb
├── analyzers/
│ └── dotnet/
│ └── cs/
│ ├── MySdk.Analyzer.dll ← Roslyn 诊断分析器,IDE 实时生效
│ └── MySdk.Generator.dll ← Source Generator,编译期运行
├── buildTransitive/
│ ├── MySdk.props ← MSBuild 属性默认值,沿依赖链传递
│ └── MySdk.targets ← MSBuild 构建钩子,AfterTargets="Build"
└── tools/└── net9.0/└── MySdk.Tool.dll ← 命令行工具,由 .targets 里 Exec 调用
NuGet 加载规则:
lib/<tfm>/— 编译时引用,SDK 自动添加到Referencesanalyzers/dotnet/cs/— Roslyn 自动发现并加载所有.dll为分析器和生成器buildTransitive/—.props在项目开头自动导入,.targets在项目末尾自动导入,且沿着依赖链传递tools/<tfm>/— 不参与编译,不自动引用,只由 MSBuild target 手动定位和调用
容易犯的错:把 buildTransitive/ 写成 build/。
build/ 只在直接引用此包的项目里生效。如果项目 A 引用了你的包,项目 B 引用了 A,那么 B 拿不到 build/ 里的 .props 和 .targets。用 buildTransitive/——它沿着整个依赖链传递,无论引用层级多深都能拿到。
二、第一步:壳项目——不产出 DLL 的打包组织者
打包壳项目的 .csproj:
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net9.0</TargetFramework><IsPackable>true</IsPackable><IncludeBuildOutput>false</IncludeBuildOutput><PackageId>MySdk</PackageId><Description>My SDK with runtime, analyzer, generator, tool and MSBuild integration.</Description><PackageReadmeFile>README.md</PackageReadmeFile><PackageLicenseFile>LICENSE.txt</PackageLicenseFile><PackageProjectUrl>https://github.com/me/MySdk</PackageProjectUrl></PropertyGroup>
</Project>
逐行解释:
TargetFramework:壳项目仍然需要一个TargetFramework,因为dotnet pack需要一个编译上下文来执行 MSBuild 逻辑。即使IncludeBuildOutput=false,MSBuild 的 pack target 仍然需要知道目标框架。你可以选任意一个net9.0、netstandard2.0之类。IsPackable=true:显式声明这个项目是可打包的(默认类库项目就是true,但显式写出来意图更清晰)。IncludeBuildOutput=false:核心配置。告诉 NuGet:不要把当前项目的编译输出放进包。真正的 payload 全部来自外部。PackageReadmeFile+PackageLicenseFile:NuGet.org 上展示的 README 和许可证。这两个文件需要在<ItemGroup>里分别Pack="true"。PackageProjectUrl:NuGet.org 包详情页的"项目地址"链接。
这个壳项目不包含任何 C# 源码——它只承担一个职责:把其他项目的编译产物按正确的目录结构组装进 .nupkg。
容易犯的错:忘记 <PackageReadmeFile>。
从 NuGet 6.x 开始,如果没有 README,dotnet pack 会产生 NU5128 警告。加上一条 <None Include="README.md" Pack="true" PackagePath="" /> 即可消除。
三、第二步:类库和分析器——两种塞法
方式 A:项目引用 + 自动打包(推荐起步方案)
如果每个子项目只扮演一种角色,直接引用它们,NuGet 按项目类型自动路由:
<ItemGroup><!-- 类库:自动进 lib/net9.0/ --><ProjectReference Include="..\MySdk.Core\MySdk.Core.csproj" /><!-- 分析器:自动进 analyzers/dotnet/cs/ --><ProjectReference Include="..\MySdk.Analyzer\MySdk.Analyzer.csproj" PrivateAssets="all" /><!-- Source Generator:自动进 analyzers/dotnet/cs/ --><ProjectReference Include="..\MySdk.Generator\MySdk.Generator.csproj"PrivateAssets="all" />
</ItemGroup>
NuGet 的路由判断依据:
- 类库项目(
<OutputType>Library</OutputType>)→lib/<tfm>/ - 标记了
<OutputType>Analyzer</OutputType>或含有Analyzer输出的项目 →analyzers/dotnet/cs/ - 其他按项目元数据推断
容易犯的错:分析器的 PrivateAssets 没设对。
如果不加 PrivateAssets="all",分析器 DLL 除了出现在 analyzers/ 下,还会被当作普通的编译引用——使用者的项目会直接依赖分析器 DLL,这通常不是你想要的。PrivateAssets="all" 的含义是:这个引用的所有资产(编译、运行时、内容文件)都只对当前打包项目可见,不会泄漏到使用者那边。
方式 B:手动打包编译输出(完全控制)
当你需要把多个不同项目的 DLL 精确放到同一个包内目录时,方式 A 不够用。例如,你有三个独立的运行时绑定项目,它们的 DLL 都要进 lib/net9.0/。
手动方式用 None + Pack="true" + PackagePath:
<ItemGroup><!-- 运行时引用:手动指定 --><None Include="$(BuildOutputRoot)MySdk.Core\bin\$(Configuration)\net9.0\MySdk.Core.dll"Pack="true" PackagePath="lib\net9.0\" /><None Include="$(BuildOutputRoot)MySdk.Core\bin\$(Configuration)\net9.0\MySdk.Core.pdb"Pack="true" PackagePath="lib\net9.0\" /><None Include="$(BuildOutputRoot)MySdk.Bindings\bin\$(Configuration)\net9.0\MySdk.Bindings.dll"Pack="true" PackagePath="lib\net9.0\" /><!-- Roslyn Analyzer:放在 analyzers/ 下 --><None Include="$(BuildOutputRoot)MySdk.Analyzer\bin\$(Configuration)\net9.0\MySdk.Analyzer.dll"Pack="true" PackagePath="analyzers\dotnet\cs\" /><!-- Source Generator --><None Include="$(BuildOutputRoot)MySdk.Generator\bin\$(Configuration)\net9.0\MySdk.Generator.dll"Pack="true" PackagePath="analyzers\dotnet\cs\" />
</ItemGroup>
容易犯的错:PackagePath 末尾缺反斜杠。
PackagePath="lib\net9.0\" 和 PackagePath="lib\net9.0" 的区别很微妙但致命。没有末尾反斜杠时,NuGet 会把它当作文件名而不是目录名——你的 DLL 会被重命名为 net9.0 这个没有扩展名的文件。永远在目录路径末尾加 \。
容易犯的错:依赖 DLL 的传递。
如果你的类库 DLL 自身依赖了其他 NuGet 包,这些传递依赖不会自动进入你的包。使用者在安装了你的包之后,dotnet restore 会根据你包内的 .nupkg 依赖声明自动拉取传递依赖。前提是你的壳项目正确地声明了这些依赖引用(通过 ProjectReference 或 PackageReference 带合适的 PrivateAssets)。
四、第三步:MSBuild .props 和 .targets ——让 dotnet build 替你干活
.props:定义属性默认值
<Project><PropertyGroup><!-- 是否启用编译。用户可以 -p:MySdkCompile=false 关闭 --><MySdkCompile Condition="'$(MySdkCompile)' == ''">true</MySdkCompile><!-- 是否在构建后执行 emit。可执行项目默认开启,类库默认关闭 --><MySdkEmit Condition="'$(MySdkEmit)' == '' and '$(OutputType)' == 'Exe'">true</MySdkEmit><MySdkEmit Condition="'$(MySdkEmit)' == '' and '$(OutputType)' != 'Exe'">false</MySdkEmit><!-- 输出目录 --><MySdkOutDir Condition="'$(MySdkOutDir)' == ''">$(MSBuildProjectDirectory)\output\</MySdkOutDir></PropertyGroup>
</Project>
Condition="'$(xxx)' == ''" 的含义是:如果用户设了这个属性就用用户的,否则用默认值。 用户可以通过命令行 -p:MySdkEmit=false 覆盖。
容易犯的错:.props 里把默认值写死了。
如果你写 <MySdkEmit>true</MySdkEmit> 不带 Condition,那用户无论怎么传 -p:MySdkEmit=false 都改不动——.props 的加载时机在所有用户属性之前。正确的做法是永远带 Condition="'$(xxx)' == ''"。
.targets:挂载构建钩子
<Project><PropertyGroup><MySdkToolTfm Condition="'$(MySdkToolTfm)' == ''">net9.0</MySdkToolTfm></PropertyGroup><Target Name="MySdkEmit"AfterTargets="Build"Condition="'$(DesignTimeBuild)' != 'true' and '$(MySdkEmit)' == 'true' and Exists('$(TargetPath)')"><PropertyGroup><_MySdkToolPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\tools\$(MySdkToolTfm)\MySdk.Tool.dll'))</_MySdkToolPath><_MySdkOutDir>$([System.IO.Path]::GetFullPath('$(MySdkOutDir)'))</_MySdkOutDir></PropertyGroup><MakeDir Directories="$(_MySdkOutDir)" /><Exec Command="dotnet "$(_MySdkToolPath)" --input "$(TargetPath)" --output "$(_MySdkOutDir)"" /></Target>
</Project>
逐点解释:
AfterTargets="Build":在正常Buildtarget 完成后自动执行。用户只需dotnet build,不需要额外命令。DesignTimeBuild:IDE(Visual Studio / Rider)在后台持续运行"设计时构建"来更新 IntelliSense。这些构建不应该触发你的工具——否则每次敲代码都跑一遍emit,IDE 会卡死。Exists('$(TargetPath)'):确保编译输出确实存在才执行。有些构建场景(如dotnet clean)不产出 DLL。$(MSBuildThisFileDirectory):指向.targets所在的目录。包内tools/的相对位置是..\tools\。_前缀的属性名:MSBuild 约定——下划线开头的是"临时内部属性",不会泄漏到 MSBuild 的全局属性空间。
容易犯的错:属性命名冲突。
不要在 .targets 里定义不带 _ 前缀的全局属性(除非你故意的)。MSBuild 的属性是全局的——两个不同的 target 如果碰巧用了相同的属性名,后加载的会覆盖前面的。用 _ 前缀隔离。
把 .props 和 .targets 打进包
<ItemGroup><None Include="buildTransitive\MySdk.props" Pack="true" PackagePath="buildTransitive\MySdk.props" /><None Include="buildTransitive\MySdk.targets" Pack="true" PackagePath="buildTransitive\MySdk.targets" />
</ItemGroup>
使用者 .csproj 只需一行引用,不需要任何额外配置:
<PackageReference Include="MySdk" Version="1.0.0" />
五、第四步:用 MinVer 自动管理版本号
手动维护版本号是技术债——每次发布都要改 <Version>,团队里的人一多就必然有人忘记。
MinVer 的思路:版本号完全由 git tag 决定,不是 .csproj 里的一个字符串。
安装和基本配置
<ItemGroup><PackageReference Include="MinVer" Version="6.*" PrivateAssets="All" />
</ItemGroup>
配置放在 Directory.Build.props 里,仓库级别只需一行:
<MinVerTagPrefix>v</MinVerTagPrefix>
版本号是怎么算出来的
假设仓库的 git 历史是:commit D (HEAD) ← 你现在在这里commit Ccommit Bcommit A ← tag: v1.2.3MinVer 的算法:
1. 从 HEAD 往回走,找到最新的带 v 前缀的 tag → v1.2.3
2. 去掉前缀 v → 1.2.3
3. 从 tag 到 HEAD 之间有 3 个 commit → height = 3
4. 最终版本号 = 1.2.4-alpha.0.3(patch +1,附加 -alpha.0.{height})
几种常见场景:
| git 状态 | MinVer 输出的版本号 |
|---|---|
HEAD 正好在 v1.2.3 上 |
1.2.3 |
| tag 之后 5 个 commit | 1.2.4-alpha.0.5 |
在 v1.2.3 之后又打了 v1.3.0-beta.1 |
1.3.0-beta.1 |
| 没有任何 tag | 0.0.0-alpha.0(或 MinVerDefaultPreReleaseIdentifiers) |
| 不在 git 仓库内 | MinVerVersionOverride 的值 |
常用配置大全
<PropertyGroup><!-- tag 前缀,MinVer 只识别以这个开头的 tag --><MinVerTagPrefix>v</MinVerTagPrefix><!-- 不在 git 仓库内时回退到这个版本 --><MinVerVersionOverride Condition="'$(MinVerVersionOverride)' == ''">0.0.0-local</MinVerVersionOverride><!-- 默认 pre-release 标识 --><MinVerDefaultPreReleaseIdentifiers>alpha.0</MinVerDefaultPreReleaseIdentifiers><!-- 最低主版本号,小于这个的 tag 会被忽略 --><MinVerMinimumMajorMinor>1.0</MinVerMinimumMajorMinor><!-- 忽略特定 tag --><MinVerIgnoredTagPrefixes>ci-;test-</MinVerIgnoredTagPrefixes>
</PropertyGroup>
容易犯的错:tag 格式不对。
MinVer 要求 tag 里的版本号必须符合 SemVer 2.0。v1.2 不合法(缺 patch 号),v1.2.3.4 不合法(四段版本号)。正确格式:v1.2.3、v1.2.3-beta.1、v1.2.3-alpha.0.68。
容易犯的错:CI 里用 shallow clone。
GitHub Actions 默认 fetch-depth: 1,只拉最新一个 commit,没有 tag 历史。MinVer 会退回到 0.0.0 或 MinVerVersionOverride。必须设置 fetch-depth: 0:
- uses: actions/checkout@v4with:fetch-depth: 0
多项目版本同步
一个常见需求:同一个仓库里有多个 NuGet 包,希望它们共享同一个版本号。做法是在 Directory.Build.props 里集中引用 MinVer,所有项目的 .csproj 里不单独声明 <Version>。MinVer 在所有项目里计算出的版本号一致(因为基于同一个 git 历史)。
六、第五步:手动预编译子项目
当包内有工具项目需要 dotnet publish(而不只是 build)时,打包前必须用 MSBuild target 触发编译。这是因为 dotnet pack 默认只会 pack,不会帮你 build 依赖。
<!-- 编译所有依赖项目 -->
<ItemGroup><_PackageBuildProject Include="$(MSBuildThisFileDirectory)..\MySdk.Core\MySdk.Core.csproj" /><_PackageBuildProject Include="$(MSBuildThisFileDirectory)..\MySdk.Analyzer\MySdk.Analyzer.csproj" /><_PackageBuildProject Include="$(MSBuildThisFileDirectory)..\MySdk.Generator\MySdk.Generator.csproj" /><!-- 工具项目需要单独 publish --><_PackageToolProject Include="$(MSBuildThisFileDirectory)..\MySdk.Tool\MySdk.Tool.csproj" />
</ItemGroup><Target Name="PreparePackageArtifacts"BeforeTargets="GenerateNuspec"Condition="'$(SkipPackagePreparation)' != 'true'"><!-- 编译类库 --><MSBuild Projects="@(_PackageBuildProject)"Targets="Build"BuildInParallel="false"Properties="Configuration=$(Configuration)"RemoveProperties="TargetFramework;RuntimeIdentifier;SelfContained" /><!-- publish 工具项目 --><MSBuild Projects="@(_PackageToolProject)"Targets="Publish"BuildInParallel="false"Properties="Configuration=$(Configuration);SelfContained=false"RemoveProperties="TargetFramework;RuntimeIdentifier" />
</Target><Target Name="AddToolOutputToPackage"DependsOnTargets="PreparePackageArtifacts"><ItemGroup><TfmSpecificPackageFile Include="$(ToolPublishOutputRoot)**\*"><PackagePath>tools\net9.0\%(RecursiveDir)%(Filename)%(Extension)</PackagePath></TfmSpecificPackageFile></ItemGroup>
</Target>
容易犯的错:BuildInParallel="false" 不能省略。
多个项目按顺序编译时,共享的输出目录可能冲突(比如多个项目都引用了同一个 NuGet 包的不同版本)。串行编译避免了这个问题。代价是慢一点——但在打包阶段这不是瓶颈。
容易犯的错:RemoveProperties 没配对。
MSBuild task 会从当前构建上下文继承大量属性,其中 TargetFramework、RuntimeIdentifier 等可能和子项目的不一致。用 RemoveProperties 显式剥离这些属性,让子项目的 .csproj 自身决定 TargetFramework。
工具 publish 的特殊注意事项
SelfContained=false 是关键。如果 SelfContained=true,dotnet publish 会把整个 .NET 运行时打进去——一个几 MB 的工具变成 70MB+。SelfContained=false 产出的是框架依赖的二进制,使用者机器上只要有对应版本的 .NET 运行时就能跑。
七、完整 .csproj 范例
把上面的零散配置拼成完整例子:
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net9.0</TargetFramework><IsPackable>true</IsPackable><IncludeBuildOutput>false</IncludeBuildOutput><PackageId>MySdk</PackageId><Description>My SDK with runtime, analyzer, generator, tool and MSBuild integration.</Description><PackageReadmeFile>README.md</PackageReadmeFile><PackageLicenseFile>LICENSE.txt</PackageLicenseFile><PackageProjectUrl>https://github.com/me/MySdk</PackageProjectUrl><RepositoryUrl>https://github.com/me/MySdk</RepositoryUrl><RepositoryType>git</RepositoryType><PackageTags>mysdk;analyzer;source-generator</PackageTags></PropertyGroup><ItemGroup><None Include="README.md" Pack="true" PackagePath="" /><None Include="LICENSE.txt" Pack="true" PackagePath="" /></ItemGroup><!-- MSBuild 集成文件 --><ItemGroup><None Include="buildTransitive\MySdk.props" Pack="true" PackagePath="buildTransitive\MySdk.props" /><None Include="buildTransitive\MySdk.targets" Pack="true" PackagePath="buildTransitive\MySdk.targets" /></ItemGroup><!-- 类库:运行时引用 --><ItemGroup><ProjectReference Include="..\MySdk.Core\MySdk.Core.csproj" /></ItemGroup><!-- 分析器和生成器 --><ItemGroup><ProjectReference Include="..\MySdk.Analyzer\MySdk.Analyzer.csproj"PrivateAssets="all" /><ProjectReference Include="..\MySdk.Generator\MySdk.Generator.csproj"PrivateAssets="all" /></ItemGroup><!-- MinVer --><ItemGroup><PackageReference Include="MinVer" Version="6.*" PrivateAssets="All" /></ItemGroup></Project>
八、本地测试:打包之后先验证再发布
在上传到 NuGet.org 之前,最好先验证包内容是否正确。两条途径:
用 dotnet nuget 查看包内容
# 打包
dotnet pack -c Release# 查看包内文件列表
dotnet nuget list package MySdk.1.0.0.nupkg --source .
也可以直接解压 .nupkg(它是 ZIP 文件):
unzip -l MySdk.1.0.0.nupkg
确认 lib/、analyzers/、buildTransitive/、tools/ 下面都有预期的文件。
用本地 NuGet 源测试使用者体验
# 1. 创建一个本地 source
mkdir ~/local-nuget# 2. 把打包好的 nupkg 放进去
cp MySdk.1.0.0.nupkg ~/local-nuget/# 3. 在一个测试项目里引用
# 先添加源:dotnet nuget add source ~/local-nuget --name local
# 然后 dotnet add package MySdk
# 最后 dotnet build —— 看 MSBuild target 是否触发
容易犯的错:dotnet pack 在不同配置下的路径差异。
dotnet pack -c Release 输出到 bin/Release/,dotnet pack -c Debug 输出到 bin/Debug/。配置名(Debug/Release)是路径的一部分。如果你在 CI 里用 Release 打包但手动测试时忘了指定 -c,会找不到文件。
九、踩坑记录
1. buildTransitive vs build
已在前文说明。一句话:永远用 buildTransitive/,除非你确定你的包只会被直接引用。
2. Analyzer 不要跟类库放一起
分析器 DLL 如果放在 lib/ 下,它会成为编译引用。后果:使用者的代码能 using MySdk.Analyzer,直接调用分析器的内部 API。正确的做法是分析器只在 analyzers/dotnet/cs/ 里。
3. SDK 版本不匹配
你的壳项目用了 net9.0,但子项目用了 net8.0?打包时 MSBuild 会试图用 net9.0 的 SDK 去编译 net8.0 的项目——这通常能工作,但如果子项目用了只存在于新版 SDK 的 API,就会在打包时构建失败。对策:壳项目的 TargetFramework 选一个所有子项目都支持的,或者用 netstandard2.0 作为最大公约数。
4. PackageReference 的 PrivateAssets 粒度
PrivateAssets="all"— 这个依赖只对当前项目可见,不会传递到使用者PrivateAssets="compile;runtime"— 只隐藏编译和运行时资产,但 contentFiles 等仍然传递PrivateAssets="none"— 全部传递(默认)
对于 Analyzer 和 MinVer:用 PrivateAssets="All"。
对于运行时库:不要加 PrivateAssets,否则使用者装包后找不到依赖 DLL。
5. 多个 TFM 的工具分发
如果你的工具需要同时支持 net8.0 和 net9.0,tools/ 下可以放两个子目录:
tools/
├── net8.0/
│ └── MySdk.Tool.dll
└── net9.0/└── MySdk.Tool.dll
.targets 里根据 MSBuild 属性选择正确的工具路径:
<MySdkToolTfm Condition="'$(MySdkToolTfm)' == '' and '$([System.Text.RegularExpressions.Regex]::IsMatch($(TargetFramework), ''net9\\.''))'">net9.0</MySdkToolTfm>
<MySdkToolTfm Condition="'$(MySdkToolTfm)' == ''">net8.0</MySdkToolTfm>
6. Source Generator 找不到宿主项目类型
如果你的 Source Generator 依赖了 Roslyn 的 API(Microsoft.CodeAnalysis),确保这些依赖在包内或者声明为 PrivateAssets。Source Generator 运行时使用的是编译宿主的 Roslyn 版本——不是你打包进去的版本。如果你的 Generator 用了新版 Roslyn API 但宿主项目跑在老版编译器上,Generator 会加载失败。
7. dotnet pack 在解决方案里 vs 单独项目
# 直接在项目上 pack:正常
dotnet pack src/MySdk/MySdk.csproj -c Release# 在解决方案上 pack:小心,它会 pack 所有 IsPackable=true 的项目
dotnet pack MySolution.sln -c Release
如果你在解决方案级别 run pack,确认只有壳项目设置了 IsPackable=true,其他子项目没有。
8. $(TargetPath) 在多 TFM 构建中的行为
$(TargetPath) 指向的是当前构建的第一个 TargetFramework 的输出 DLL。如果你的项目用 <TargetFrameworks>net8.0;net9.0</TargetFrameworks> 多 TFM 构建,AfterTargets="Build" 的 target 会在每个 TFM 完成后各触发一次,每次 $(TargetPath) 指向对应的输出。这是正确的行为。
9. README 和许可证文件路径的坑
<None Include="README.md" Pack="true" PackagePath="" />
PackagePath="" 意味着这个文件放在包的根目录。NuGet.org 在这个位置寻找 README。如果你写 PackagePath="docs/",README 会被放到 docs/README.md,NuGet.org 找不到它。
10. MinVer 在 monorepo 里的 tag 污染
大仓(monorepo)里可能有多个独立的 NuGet 包。如果它们共享同一个 git 历史和 tag 前缀,MinVer 会给所有包算出相同的版本号——这可能不是你想要的。解法:每个包用不同的 tag 前缀(MinVerTagPrefix=pkg-a-、MinVerTagPrefix=pkg-b-),或者用 Nerdbank.GitVersioning 替代 MinVer(支持按目录指定版本)。
十、小结
一个"全能型" NuGet 包的配方:
| 层 | 包内路径 | 关键配置 | 最常见错误 |
|---|---|---|---|
| 壳项目 | — | IncludeBuildOutput=false |
忘了这是空壳,还往里写代码 |
| 运行时库 | lib/<tfm>/ |
项目引用 / 手动 PackagePath |
PackagePath 缺末尾 \ |
| 分析器 | analyzers/dotnet/cs/ |
PrivateAssets="all" |
错放到 lib/ 下 |
| Source Generator | analyzers/dotnet/cs/ |
PrivateAssets="all" |
依赖了错误版本的 Roslyn API |
| MSBuild | buildTransitive/ |
AfterTargets="Build" + DesignTimeBuild 跳过 |
用了 build/ 而不是 buildTransitive/ |
| 工具 | tools/<tfm>/ |
SelfContained=false publish |
BuildInParallel 没关导致并行冲突 |
| 版本号 | MinVer | <MinVerTagPrefix>v</MinVerTagPrefix> |
CI shallow clone 找不到 tag |
