深入研究.NET是如何构建和发布的

2020-08-21 13:49:24

这是对.NET团队用来构建和发布.NET的机制和过程的深入技术探讨。希望了解以下主题的人将会感兴趣:

这篇文章一开始就展示了组成.NET产品的多存储库世界,它的内在挑战,以及我们如何应对这些挑战。这是对“.NET Core的不断发展的基础结构”中提供的一些信息的回顾。然后,我们将仔细查看我们是如何构建、准备和发布产品的,尤其是在包含安全修复的版本周围。

开发人员使用GitHub(以及那里使用/描述的工具和实践)作为他们的主要开发环境。

.NET不是作为单一的repo开发的,而是作为一组相互依赖的repo开发的。有关在何处开发各种功能的信息,请参阅.NET存储库。例如,DotNet/Runtime Repo构建了核心.NET运行时和一些附加的NuGet包。它的输出由.DotNet/aspnetcore、.DotNet/Installer、.DotNet/Extensions和其他几个应用程序使用。.NET中依赖项的使用有几种形式:

使用关于依赖项的公共API的信息-在重大发布之后,公共API不会改变。然而,在积极开发新的主要版本期间,随着新API或新功能的引入,它可能会定期变化。

引用在另一个回购系统中生成的资产的特定版本号-例如,DotNet/aspnetcore可能会生成NuGet包,这些包编码对特定版本的Microsoft.Extensions.Logging的依赖关系。

重新分发在另一个repo版本中产生的依赖项-一些示例:虽然Microsoft.Extensions.Logging https://nuget.org,作为独立的NuGet包发布在Asp.net上,但它也会作为dotnet/aspnetcore版本的一部分重新打包到.ASP.NET.NET核心共享框架中。

在DotNet/aspnetcore内部版本中生成的.ASP.NET核心运行时可以作为独立组件安装,但也包含在从DotNet/Installer生成的.NET SDK中。

从源代码构建依赖关系-并非总是可以使用或重新分发由微软CI系统构建的.NET二进制文件。具体地说,预构建的二进制文件在Linux发行版中通常是被禁止的。这意味着依赖项有时是从源代码构建的。

这样做的结果是,当我们发布一个.NET版本时,我们不能简单地并行生成每个repo的构建、签名、发布和发布。相反,我们必须确保在组成产品的所有repos中引用每个依赖项的所需版本,并引用该依赖项。更新repo中依赖项的版本意味着根据那些更新的依赖项创建新的构建,这反过来可能需要其他repo更新它们的依赖项并重新构建。这组相互依赖的REPO形成了一个图。在.NET 5中,此依赖关系流图目前有6层深(DotNet/Runtime->;DotNet/WinForms->;DotNet/WPF-&>;DotNet/windowsktop->;DotNet/SDK->;DotNet/Installer)。

考虑到这个依赖图相当复杂,我们需要自动化的方法来跟踪和更新它。我们通过每个回购的.Eng/Version.Details.xml文件中的元数据跟踪依赖关系。此文件标识一组输入依赖项的名称和版本。每个依赖项还标识为创建它而构建的源提交和repo。例如,这段摘录来自DotNet/Installer。它标识DotNet/Installer有一个名为Microsoft.NET.Sdk的输入资产,版本为5.0.100-rc.1.20403.9,该输入资产是在https://github.com/dotnet/sdk的内部版本356005e13634b9388aa53596891bc2e8192e2978c的基础上生成的。

<;?xml版本=";1.0";encoding=";utf-8";?>;<;Dependencies>;<;产品依赖关系>;<;!--其他依赖关系..。-->;<;依赖项名称=";Microsoft.NET.Sdk";版本=";5.0.100-rc.1.20403.9";>;<;Uri>;https://github.com/dotnet/sdk<;/Uri>;<;Sha>;56005e13634b9388aa53596891bc2e8192e2978c<;/Sha>;<;/依赖项>;<;/ProductDependencies>;<;/Dependencies>;

然后,依赖项名称对应于Eng/Versions.props文件中的一组MSBuild属性名称,该文件也位于repo中:

然后,回购可以根据自己的意愿自由使用MicrosoftNETSdkPackageVersion属性。在本例中,它用于指定名为Microsoft.NET.Sdk的包的版本,以及指向在DotNet/SDK版本中生成的归档zip文件的路径的一部分。

请注意,因为依赖项的源信息保存在.Eng/Version.Details.xml文件中,所以可以在源repo+Commit处查找Eng/Version.Details.xml文件并确定其依赖项。这样做会递归地构建产品中存在的所有依赖项的图。有关最新的Preview 8版本的可视化信息,请参见下面的内容。我们将这些依赖项分为两类:

产品依赖关系-产品依赖关系表示那些对产品功能至关重要的依赖关系。如果将构成图形的产品依赖项的所有构建的输出与现有的外部源(例如,https://nuget.org))一起收集在一起,则产品应按预期运行。

工具集依赖项-工具集从属关系不随产品一起提供。它们可能代表用于测试目的或构建目的的输入。如果没有这些,产品应该可以按预期运行。这些产品不包括在产品降价范围内,也不会在发布日发货。不应将它们与SDK打包的编译器和构建工具(如F#、C#或MSBuild)混淆。相反,这些依赖项表示用于打包或签署产品、引导测试等的功能。

我们使用名为maestro的自定义服务自动更新跟踪的依赖项(有关实现,请参阅https://github.com/dotnet/arcade-services指南)。每个正式构建(在任何分支上)都包含一个向Maestro服务报告其状态的阶段。Maestro维护关于报告的构建的元数据的注册表,包括它们的输出、提交、repo URL等的列表。然后,Maestro使用称为通道和订阅的概念来确定如何处理这些新报告的构建。我们称这个过程为“依赖流”。

渠道-并不是所有的构建都是以相同的目的创建的-DotNet/EFcore的主要分支机构的构建可能是为了日常开发,这意味着它的输出应该流向也在跟踪日常发展的其他repos分支机构。另一方面,测试分支的构建并不打算在任何地方流动。通道实际上是表示构建意图的标签。任何版本都可以分配给任何通道。

订阅-订阅将具有特定意图(分配给特定渠道)的源回购的构建映射到另一个回购中的目标分支。当将构建分配给通道时,Maestro会通过修改.eng/Version.Details.xml文件和.eng/Versions.props文件来更改目标分支的状态,以更新其依赖项。然后,它会打开一个包含更改的拉取请求。

还值得注意的是,通道会影响构建资产的发布。未分配给任何通道的生成不会在任何地方发布其输出。分配给通道将触发这些资产的发布,通道定义了所需的端点。例如,.NET5的日常开发通道指出,应该将SDK安装程序或zip归档等文件推送到.dotnetcli的存储帐户,将包推送到.dotnet5和Azure DevOps NuGet提要。另一方面,用于工程服务和工具的渠道推送到DotNet-Eng和NuGet套餐Feed。这意味着在构建时不必知道构建的真正意图。渠道分配指示意图,并确保根据该意图将输出发布到正确的位置。

需要注意的是:目前,定义频道、订阅和依赖流的Maestro系统没有一个公开可用的可视化门户,尽管Maestro的行为通过依赖更新请求是公开可见的。Https://github.com/dotnet/arcade/issues/1818的文章涵盖了这一功能。通过对问题发表评论或投票,让我们知道这是否有价值。

典型的产品开发工作流基于分支分配构建意图。随着回购数量的增加和规模的扩大,这种模式往往会崩溃。根据不同的需求,不同的团队有不同的开发实践和分支策略。当回购装运在多辆按不同时间表运行或具有不同服务要求的车辆中时,情况尤其如此。例如,Roslyn C#编译器在.NET SDK中提供,也在Visual Studio中单独提供。通过使用订阅,将已分配给通道的构建拉入,我们最终获得了一个更干净的依赖流生产者/消费者模型。

例如,NetDotNet/SDK的repo为.NET5Preview 8引入了各种各样的输入依赖项,其中之一就是NuGet客户端。Nuget倾向于生成新的构建、测试它们,然后挑选准备好插入到Visual Studio或.NET SDK中的构建。他们不是让DotNet/SDK的团队跟踪NuGet当时正在使用的分支和构建的状态,而是简单地设置一个订阅,将目标定位于NuGet.Client构建分配给‘VS 16.8’频道的最新版本/5.0.1xx-preview8。同样,NuGet团队也不需要了解SDK的分支结构,只需要针对VS16.8的构建。

每天,代码都会签入到每个Repo中,执行这些代码更改的构建,并将这些构建分配给渠道。Maestro使用订阅信息将那些构建产生的新输出流向其他repos。对于开发渠道来说,这个过程是持续不断的。产品在不断发展和变化。因为一些repos沿着多条路径流经依赖关系图,所以在任何给定时刻,产品中的单个依赖关系可能有不同的版本。我们称这种状态为“不连贯”。对于日常构建,这通常很好。输出产品的行为通常与预期一致,团队可以花时间对更改中断、引入新功能或处理依赖项更新PR中的故障做出反应。然而,在创建要发布的产品时,我们需要repo依赖关系图中引用的每个资产的单一版本。运行时的单一版本、aspnetcore的单一版本、每个NuGet包的单一版本等等。我们称这种状态为“一致的”。

不连贯代表一种不可能的错误状态。例如,让我们看看.NET共享框架运行时。它公开特定的公共API外围应用。虽然在repo依赖图中可能会引用它的多个版本,但SDK只附带了一个版本。此运行时必须满足可能在该运行时上执行的可传递引用的组件(例如WinForms和WPF)的所有要求。如果运行时不能满足这些需求(例如,破坏API更改、错误等),则可能会发生故障。在不连贯的图中,因为所有存储库都没有吸收共享框架的相同版本,所以有可能遗漏了突破性的更改。

现在让我们看一下构建和发布产品的工作流程。

我们可以将Maestro订阅看作形成一个流程图,每条边代表存储库之间的更改流。如果没有在图中的任何repo中进行额外的“真正的”(非依赖流)更改,那么最终更改流将停止,产品将达到不变的、连贯的状态,其中每个依赖项只有一个版本。这就是我们为预览版和服务版本定期推动的目标。每个版本的一般流程如下:

为新版本做准备-为即将发布的版本更新品牌(例如版本号、预览标识符、将提供哪些软件包)。如果这是预览,则从主开发分支派生新分支,并在新分支中实现稳定。

提交更改-在每个回购中提交发布所需的任何更改(例如,批准的错误修复)。

迭代直到一致-允许新的构建完成,并且依赖项流动,直到我们达到一致状态。

准备DotNet/源代码构建发布版-一致的产品的源代码依赖项通过TDotNet/source-build发布项目提供。

验证-执行PR/CI测试中不存在的附加验证(例如,测试Visual Studio方案)。

将DotNet/source-build发送给合作伙伴-产品的源代码将发送给需要从其自己配置项中的源代码构建.NET的合作伙伴(例如Red Hat)。

必要时修复-如果发现任何问题,请准备任何其他更改,提交,然后返回步骤3。

发行版-该产品现已准备好作为适用于每个支持的操作系统的相应软件包中的一组二进制文件发布。

这一过程有时会大大提前于预定的发布日期完成。这正是我们所希望的。当这种情况发生时,资产只需在Azure blob存储和各种包源中等待,就可以在预定的发货日期发布。其他时候,这一过程非常接近预定的发货日期完成,几乎没有喘息的空间。当预定的发货日期不可改变时,这一点尤其具有挑战性,比如当它与大型会议的第一天捆绑在一起的时候。

.NET的日常开发是在GitHub上完成的。对于包含安全修复的版本,我们需要一个非公共位置来提交这些修复,并结合在GitHub上公开进行的任何非安全更改。在内部提交和构建可防止过早披露会使客户应用程序面临风险的漏洞。为了满足此需求,我们在Azure DevOps Repo中维护两个并行分支集:

GitHub版本中对应分支的直接副本--例如,如果在GitHub的DotNet/Runtime版本repo中有一个版本/5.0版本的分支,那么在Azure DevOps版本-DotNet-Runtime版本中就有一个版本版本/5.0版本的分支,并且版本头总是匹配的。每次提交到GitHub中时,GitHub DotNet-Runtime Repo都会执行快进合并,以引入新的更改。

与内部版本/5.0版本分支相关的相应的内部/版本/5.0版本分支-每次提交到GitHub的版本/5.0版本分支时,提交都会自动合并到内部/版本/5.0版本中。如果没有提交给内部/Release/5.0分支的安全修复,则其标题与Release/5.0分支匹配。如果有的话,那么他们的头就会分道扬镳。

这组并行分支为我们提供了一种方法,可以在生产带有安全修复的产品的同时,尽可能多地公开执行我们的更改:

当我们构建一个没有安全修复的版本时,内部/发布/...分支是未使用的。所有提交和依赖项流程都是针对GitHub的Release/...分支进行的。

当我们构建具有安全修复程序的版本时,这些特定的修复程序将签入到Azure DevOps中适当的内部/Release/...分支中,而依赖项流针对每个repo中的内部/Release/...分支。任何与安全或依赖项流无关的更改都会提交到相应的公共GitHub分支,然后自动合并到内部/发布/...分支。在发布日,我们打开PR以将内部/Release/...分支合并回公共发布分支(例如,如果提交位于内部/Release/5.0中,则我们将其合并回Release/5.0)。这会将内部分支状态重置为与公共分支状态匹配,从而为下一个版本做好准备。

公众有一个不幸的副作用&>;内部合并。如果在公共和内部分支上都启用了依赖流,即公共构建流到公共GitHub分支,内部构建流到内部分支,那么在公共端提交的每个依赖流在合并到内部分支时都会发生冲突。这需要广泛的、容易出错的手动干预,因为每天可能有10个依赖项流提交,分布在多个repos中。为了避免这种情况,除了图中的一些叶节点(例如,Roslyn、FSharp、MSBuild等)和构建公共版本时的内部依赖流之外,我们在构建内部发布时很大程度上关闭了公共依赖流。因此,与公开可用的安装程序的每日构建打包在一起的运行时和SDK功能将在发布日之前过期。在未来,.NET团队将研究同时维护公共构建和内部构建的健壮方法。

发货日期-这些资产应该出现在外部端点上,如https://nuget.org,、.NET下载站点等。当为RTM构建和服务版本时,这些资产通常获得“稳定”版本(例如5.0.6),而不是“不稳定”版本(例如5.0.6-servicing.20364.11)。

非发货资产-这些资产不会出现在外部端点上。他们总是有不稳定的版本。它们有时被称为运输包裹。

通常情况下,非航运资产的存在是为了回购间运输。例如,.dotnet/sdk会生成一个名为“microsoft.NET.Sdk”的包,该包只在.dotnet/安装程序的输出中重新分发(例如,zip和tar.gz文件),不会出现在https://nuget.org.上。我们总是对非发货包使用非稳定版本控制,以便它们的版本保持唯一构建。

我们的多repo OSS开发环境有一个特别有趣的含义。正如前面在本深度研究中所讨论的,回购往往彼此之间有很强的依赖关系。它们重新分发许多二进制文件,并从它们的依赖项引用特定的版本号(例如NuGet包版本)。例如,Microsoft.Extensions.Logging https://nuget.org.是在Dotnet/Runtime的基础上构建的,它的内容在Microsoft.Extensions.Logging的共享框架中重新分发,但也可以独立发送给Microsoft.Extensions.Logging。当我们构建新版本的Microsoft.Extensions.Logging版本时,我们希望它有一个稳定的版本和可预测的版本号。我们想要5.0.6,而不是5.0.6-Servicing.20364.11.。但是,当我们需要为给定的版本多次构建DotNet/运行时环境时会发生什么呢?DotNet/Runtime的第一次构建是在Microsoft.Extensions.Logging 5.0.6版本中构建的,随后的构建也是如此。通常,NuGet提要是不变的。我们不能简单地覆盖旧的包,我们也不想这样做。那么,我们如何流动这个新的Microsoft.Extensions.Logging实例,并确保下游repos获得正确的包?

.NET团队通过为我们的构建基础架构提供3组NuGet解决了这个问题:

发货饲料-这些饲料包含在日常构建中生产的不稳定、不发货的包裹。每个主要产品版本都有这些内容的内部和公共变体:dotnet5-此提要可以在每个产品repo的.NuGet.config文件中看到,并且可以公开访问。

Dotnet5-Internal-在进行正式构建时,此提要会自动添加到回购的.NuGet.config文件中。在构建时添加它可以避免在公开构建时出现NuGet恢复问题,因为在这种情况下,它是不可访问的。此提要仅在进行内部构建时接收新包。

非发货饲料-这些饲料包含在日常构建中生产的不稳定的非发货饲料包裹。这样的软件包对于每个新版本总是有唯一的版本号。每个主要产品版本都有这些内容的内部和公共变体:dotnet5-Transport-此提要可以在每个产品repo的.NuGet.config文件中看到,并且可以公开访问。

Dotnet5-Internal-Transport-在进行正式构建时,此提要会自动添加到repo的.NuGet.config文件中。

.