PAYATHON 2026

如何在常规构建过程中发布独立的工具可执行文件?

技术老齐

可以使用内置的 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 中的生成源码是否被默认通配符纳入编译。换用 MSBuild task 本身不会修复文件排除规则。

如果仍然出现 MSB3030

在解决方案目录执行:

dotnet build -c Release -bl:build.binlog

检查失败项目在普通构建和工具发布时使用的 ArtifactsPath、IntermediateOutputPath、OutputPath、RuntimeIdentifier,并确认负责生成缺失 DLL 的编译目标是否实际执行。

如果两个并发项目实例最终写入同一目录,继续追查属性在哪里被覆盖或移除。如果目录已经隔离,则检查依赖为何没有生成预期文件。现有错误日志还不足以将问题归因于某个确定的 SDK 缺陷。

备注:内容仅供参考。