如何在常规构建过程中发布独立的工具可执行文件?
可以使用内置的 MSBuild task,但还要处理目标的执行时机、发布目录隔离和最新状态检查。仅把 Exec 换成 <MSBuild Targets="Publish">,仍不足以解决文件复制失败和源码变化后未重新发布的问题。
一种便于维护的做法是,在入口项目完成 Build 后,使用独立的 artifacts 目录还原并发布工具,再把发布文件复制到 $(TargetDir)Diagnostics.Utility\。入口项目每次实际执行构建时都调用这一步,由工具项目的增量构建判断哪些文件需要重新编译。
先区分两个问题的原因
MSB3030 表示复制操作找不到源文件。现有日志还不能证明文件被另一个构建进程删除了,也可能是输出路径不一致,或依赖项目没有执行构建。
Exec 启动的 dotnet publish 是一次独立的构建调用,外层 MSBuild 无法统一调度它。独立的 artifacts 目录可以隔离 SDK 生成的文件,但隔离范围必须包含工具及其项目引用依赖,而且这些项目中的路径必须解析为同一个绝对位置。
如果源码变化后没有重新发布,需要检查以下环节:
- 自定义 target 必须通过
AfterTargets、BeforeTargets或依赖关系接入构建。只设置Name不会让它自动运行。 - Visual Studio 可能在调用 MSBuild 之前就认定入口项目已经是最新状态,导致自定义 target 没有执行机会。
CopyToOutputDirectory="Always"只控制文件复制,不负责检测工具项目的源码变化。
在入口项目中添加发布目标
下面的配置适用于 SDK 风格项目,假定工具的目标框架是 net10.0-windows,发布目标为 win-x64。工具及其依赖不能反向引用入口项目。
将以下配置加入入口项目的 .csproj,替换原有的发布 target:
<PropertyGroup>
<!-- 让 Visual Studio 每次都进入 MSBuild,
工具项目的编译仍由 MSBuild 执行增量检查。 -->
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
</PropertyGroup>
<Target Name="_PrepareDiagnosticsUtilityPaths">
<PropertyGroup>
<_DiagnosticsUtilityProject>$([MSBuild]::NormalizePath(
'$(MSBuildProjectDirectory)',
'..',
'EntryPoint.Diagnostics.Utility',
'EntryPoint.Diagnostics.Utility.csproj'))</_DiagnosticsUtilityProject>
<_DiagnosticsUtilityRoot>$([MSBuild]::NormalizeDirectory(
'$(MSBuildProjectDirectory)',
'$(IntermediateOutputPath)',
'DiagnosticsUtility',
'win-x64'))</_DiagnosticsUtilityRoot>
<_DiagnosticsArtifactsDir>$([MSBuild]::NormalizeDirectory(
'$(_DiagnosticsUtilityRoot)',
'artifacts'))</_DiagnosticsArtifactsDir>
<_DiagnosticsPublishDir>$([MSBuild]::NormalizeDirectory(
'$(_DiagnosticsUtilityRoot)',
'publish'))</_DiagnosticsPublishDir>
<_DiagnosticsUtilityProperties>Configuration=$(Configuration);TargetFramework=net10.0-windows;RuntimeIdentifier=win-x64;SelfContained=false;UseAppHost=true;UseArtifactsOutput=true;ArtifactsPath=$(_DiagnosticsArtifactsDir);PublishDir=$(_DiagnosticsPublishDir);BuildProjectReferences=true;BuildingInsideVisualStudio=false;BuildingSolutionFile=false;NoBuild=false</_DiagnosticsUtilityProperties>
<_DiagnosticsUtilityRemoveProperties>OutDir;OutputPath;BaseOutputPath;IntermediateOutputPath;BaseIntermediateOutputPath;MSBuildProjectExtensionsPath;ArtifactsPivots;PublishProfile;PublishProfileFullPath;CurrentSolutionConfigurationContents</_DiagnosticsUtilityRemoveProperties>
</PropertyGroup>
</Target>
<Target Name="PublishDiagnosticsUtility"
AfterTargets="Build"
DependsOnTargets="_PrepareDiagnosticsUtilityPaths"
Condition="'$(DesignTimeBuild)' != 'true'
and '$(IsCrossTargetingBuild)' != 'true'">
<!-- 与 Publish 使用相同的路径和发布参数。
附加属性让 Restore 使用独立的项目实例。 -->
<MSBuild
Projects="$(_DiagnosticsUtilityProject)"
Targets="Restore"
Properties="$(_DiagnosticsUtilityProperties);_DiagnosticsUtilityRestorePass=true"
RemoveProperties="$(_DiagnosticsUtilityRemoveProperties)" />
<MSBuild
Projects="$(_DiagnosticsUtilityProject)"
Targets="Publish"
Properties="$(_DiagnosticsUtilityProperties)"
RemoveProperties="$(_DiagnosticsUtilityRemoveProperties)" />
<!-- 必须在 Publish 完成后枚举文件。 -->
<ItemGroup>
<_DiagnosticsUtilityFiles Remove="@(_DiagnosticsUtilityFiles)" />
<_DiagnosticsUtilityFiles Include="$(_DiagnosticsPublishDir)**\*" />
</ItemGroup>
<Copy
SourceFiles="@(_DiagnosticsUtilityFiles)"
DestinationFiles="@(_DiagnosticsUtilityFiles->'$(TargetDir)Diagnostics.Utility\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
使用 AfterTargets="Build" 后,复制文件时入口项目的输出路径已经确定。这里直接执行 Copy,避免在构建结束后才添加 Content,错过 SDK 的内容复制阶段。
Restore 与 Publish 使用相同的 artifacts 路径、框架和 RID。还原调用额外传入 _DiagnosticsUtilityRestorePass=true,让后续发布使用不同的项目实例重新求值,避免沿用还原前已经求值的项目状态。这个属性是自定义标记,不是 SDK 开关。
这些设置如何处理并发和源码变化
隔离工具及其依赖的构建文件
ArtifactsPath 使用绝对路径,通过 MSBuild task 的 Properties 传入。标准项目引用构建会把这些全局属性传递给依赖项目,让这次发布使用独立的 artifacts 树。
入口项目的 IntermediateOutputPath 通常已经区分了配置、目标框架和入口项目的 RID。示例在此基础上加入工具的 win-x64,避免不同发布组合写入同一个工作目录。
RemoveProperties 会清除外层可能通过命令行传入的输出路径等全局属性,防止它们覆盖工具自身的目录布局。工具项目文件中的属性定义不会被删除。
这套隔离只针对 SDK 管理的构建输出。如果自定义 target 向固定目录写文件,或某个依赖主动移除了 ArtifactsPath,仍需单独处理。将 BuildInParallel 设置为 false 也无法锁住整个解决方案,或阻止其他独立进程写入同一目录。
确保工具依赖会参与构建
示例显式设置了以下属性:
BuildProjectReferences=true
BuildingInsideVisualStudio=false
这样,嵌套的工具发布会负责构建自身的项目引用依赖,无需依赖外层 IDE 在另一种输出布局下完成的构建。
让源码变化进入增量检查
这个发布 target 没有自行声明 Inputs 和 Outputs。入口项目每次实际执行 Build 时,都会调用工具的 Publish,由 SDK 检查工具及其依赖的源码、资源和引用是否发生变化。没有变化时,支持增量执行的构建目标仍可跳过,无需每次完整重编译。
DisableFastUpToDateCheck 用来处理 Visual Studio 在调用 MSBuild 之前跳过入口项目的情况。代价是每次构建都会进入 MSBuild,执行这里的还原和发布调用。如果以后要减少这部分开销,需要先建立完整的依赖与最新状态检查规则,不能只检查工具主 DLL 的时间戳。
清理与输出注意事项
直接复制发布目录不会自动删除上一次发布遗留的文件。例如,工具删除某个内容文件后,旧文件可能仍留在输出目录中。可以为这两个专用目录补充清理逻辑:
<Target Name="CleanDiagnosticsUtility"
AfterTargets="Clean"
DependsOnTargets="_PrepareDiagnosticsUtilityPaths"
Condition="'$(DesignTimeBuild)' != 'true'
and '$(IsCrossTargetingBuild)' != 'true'">
<RemoveDir
Directories="$(_DiagnosticsUtilityRoot);$(TargetDir)Diagnostics.Utility" />
</Target>
还需要注意:
SelfContained=false表示框架依赖部署,运行工具的计算机仍需安装兼容的 .NET 运行时。UseAppHost=true用于生成原生启动程序。Diagnostics.Utility是复制后的子目录名。可执行文件的实际名称由工具项目的AssemblyName等设置决定,复制操作不会将其重命名。- 示例将文件复制到入口项目的构建输出目录。如果还需要把它们放进入口项目自身的发布目录,必须另外接入该项目的发布文件收集流程。
- 如果手动修改了
BaseIntermediateOutputPath,仍需检查原有obj中的生成源码是否被默认通配符纳入编译。换用MSBuildtask 本身不会修复文件排除规则。
如果仍然出现 MSB3030
在解决方案目录执行:
dotnet build -c Release -bl:build.binlog
检查失败项目在普通构建和工具发布时使用的 ArtifactsPath、IntermediateOutputPath、OutputPath、RuntimeIdentifier,并确认负责生成缺失 DLL 的编译目标是否实际执行。
如果两个并发项目实例最终写入同一目录,继续追查属性在哪里被覆盖或移除。如果目录已经隔离,则检查依赖为何没有生成预期文件。现有错误日志还不足以将问题归因于某个确定的 SDK 缺陷。
备注:内容仅供参考。
