发布打包流水线(Tools.Publish)
BD.WTTS.Client.Tools.Publish 是 SteamTools 仓库中的一个独立命令行工具项目,负责将 Avalonia 客户端(BD.WTTS.Client.Avalonia.App)的发布产物按 RID(运行时标识符)矩阵重新组织、命名并打包为跨平台分发包(含 SCD 自包含与 FDE 依赖框架两种部署模式,以及 Linux 打包元数据)。
Purpose and Scope
本页覆盖该发布打包工具的完整机制:
- 工具入口
Program.cs如何通过反射自动发现并注册所有命令(ICommand模式); Constants.cs中的路径约定、RID 矩阵、产物命名规则(GetFileName/GetPackPath)、版本来源与DeconstructRuntimeIdentifier解析逻辑;- SCD / FDE 两种部署模式的目录布局差异,以及 Linux(deb/rpm)打包常量
LinuxPackConstants。
本页不覆盖(留给兄弟页面):
- CI/CD 工作流的触发、矩阵构建与上传策略——参见流水线/工作流相关页面;
- 应用本体
BD.WTTS.Client.Avalonia.App的构建与发布参数——参见客户端应用页面; - 安装器/引导器
BD.WTTS.Client.AppHost的实现细节——参见 AppHost 相关页面。
Overview
SteamTools 需要同时面向 Windows(x64/x86/arm64)、macOS(x64/arm64)、Linux(x64/arm64/loongarch64)共 8 个 RID 发布。dotnet publish 原生输出只有 bin/Release/Publish/<rid> 这种扁平目录,产物命名也不符合发行需要。Tools.Publish 工具的作用是:
- 以命令行工具形式运行(
RootCommand,基于 System.CommandLine 风格); - 通过反射自动发现命令:所有实现
ICommand接口的类型(在源码中表现为接口,见下文)都会被泛型调用ICommand.AddCommand<T>()注册到根命令,新增打包命令无需修改入口; - 依据
Constants中硬编码的路径与命名约定,把发布产物定位、打包并生成统一命名规则的压缩包/安装包文件名。
关键概念:
| 概念 | 含义 |
|---|---|
| RID | Runtime Identifier,如 win-x64、osx-arm64、linux-loongarch64 |
| SCD | Self-Contained Deployment,自包含部署,携带 .NET 运行时 |
| FDE | Framework-Dependent Deployment,依赖框架部署 |
| HARDCODED_APP_NAME | 不可变更的硬编码应用名 Steam++,用于文件/文件夹等不可变值 |
| Trademark | 可变的展示名(AssemblyInfo.Trademark),用于产物文件名前缀 |
Architecture
架构说明:
- 入口极薄:
Program.cs仅 13 行(含两条程序集级 StyleCop 抑制),所有命令注册完全由反射驱动。ICommand.AddCommand<T>是一个静态方法,通过MakeGenericMethod(x)对每个发现的命令接口生成泛型调用,再用m.Invoke(null, new object?[] { rootCommand, })把命令挂到根命令上。 - 常量集中:
Constants.cs以内部接口(interface Constants)承载const/static成员,路径全部基于ProjectUtils.ProjPath(仓库根)拼接,保证工具可在任意工作目录下运行。 - 命令即接口:
x.IsInterface && interfaceType.IsAssignableFrom(x)这一过滤条件表明仓库中的命令类型被建模为接口(配合静态抽象成员的模式),由各命令自身决定打包行为;入口无需感知具体命令集合。
Core Flow
流程要点(逐条对应源码):
- 命令发现:
interfaceType.Assembly.GetTypes()取出定义ICommand的程序集中全部类型,Where(x => x != interfaceType && x.IsInterface && interfaceType.IsAssignableFrom(x))过滤出命令接口,Select(x => addMethod!.MakeGenericMethod(x))为每个命令生成AddCommand<T>泛型方法,最后Array.ForEach(commands, m => m.Invoke(null, new object?[] { rootCommand, }))批量注册。 - 执行分发:
rootCommand.Parse(args).InvokeAsync()返回int(退出码),由顶层return await直接作为进程退出码。 - 产物定位:命令通过
Constants.DirPublish_SCD(src/BD.WTTS.Client.Avalonia.App/bin/Release/Publish)或Constants.DirPublish_FDE(其下FrameworkDependent子目录)找到dotnet publish的输出。 - 打包输出位置随部署模式不同:
GetPackPath中 SCD 用Path.Combine(item.DirectoryPath, "..", fileName)(产物目录上一级),FDE 用两级上级("..", ".."),这与两种模式目录嵌套深度差异一致。
Data Model / 产物命名与目录模型
产物命名与路径计算是本工具的核心业务逻辑,全部位于 Constants.cs:
1static string GetPackPath(AppPublishInfo item, string fileEx)
2{
3 var fileName = GetFileName(item, fileEx);
4 var packPath = item.DeploymentMode switch
5 {
6 DeploymentMode.SCD => Path.Combine(item.DirectoryPath, "..", fileName),
7 DeploymentMode.FDE => Path.Combine(item.DirectoryPath, "..", "..", fileName),
8 _ => throw new ArgumentOutOfRangeException(nameof(item.DeploymentMode), item.DeploymentMode, null),
9 };
10 return packPath;
11}Source: Constants.cs
1static string GetFileName(AppPublishInfo item, string fileEx)
2{
3 var name = item.DirectoryPath.Replace("-", "_");
4 if (name.Contains("osx_")) name = name.Replace("osx_", "macos_");
5 var version = GetVersion();
6 var fileName = item.DeploymentMode switch
7 {
8 DeploymentMode.SCD =>
9 $"{AssemblyInfo.Trademark.Replace(" ", "_")}_with_runtime_v{version}_{name}{fileEx}",
10 DeploymentMode.FDE =>
11 $"{AssemblyInfo.Trademark.Replace(" ", "_")}_v{version}_{name}{fileEx}",
12 _ => throw new ArgumentOutOfRangeException(nameof(item.DeploymentMode), item.DeploymentMode, null),
13 };
14 return fileName;
15}Source: Constants.cs
设计意图:
- 目录名即 RID:
item.DirectoryPath直接来源于 RID 命名的发布目录(如win-x64),因此Replace("-", "_")得到win_x64这样的标识片段。 - osx → macos 改名:用户可读性优先,
macos_arm64比osx_arm64更符合最终用户认知(Apple Silicon 用户更熟悉 "Apple silicon/macOS" 而非 RID 术语)。 _with_runtime_后缀:让用户一眼分辨自包含(带运行时、体积大)与依赖框架(需自行安装 .NET)两种发行包,避免误下。AssemblyInfo.Trademark.Replace(" ", "_"):展示名中的空格替换为下划线以保证文件名跨平台安全;版本号取AssemblyInfo.Version(GetVersion()直接返回该值)。- 未知模式的防御性抛错:
_ => throw new ArgumentOutOfRangeException(...)保证新增部署模式时若未更新命名逻辑会立即失败(fail-fast),而不是静默产出错误命名。
RID 解析:DeconstructRuntimeIdentifier(string rid) 把 RID 拆为 (Platform, DeviceIdiom, Architecture) 三元组:
1static (Platform Platform, DeviceIdiom DeviceIdiom, Architecture Architecture) DeconstructRuntimeIdentifier(string rid)
2{
3 (Platform Platform, DeviceIdiom DeviceIdiom, Architecture Architecture) info = default;
4 var array = rid.Split('-', StringSplitOptions.RemoveEmptyEntries);
5 if (array.Length == 2)
6 {
7 switch (array[0]?.ToLower())
8 {
9 case "win":
10 info.Platform = Platform.Windows;
11 info.DeviceIdiom = DeviceIdiom.Desktop;
12 break;
13 case "linux":
14 info.Platform = Platform.Linux;
15 info.DeviceIdiom = DeviceIdiom.Desktop;
16 break;
17 case "osx":
18 info.Platform = Platform.Apple;
19 info.DeviceIdiom = DeviceIdiom.Desktop;
20 break;
21 }
22 ...Source: Constants.cs
该解析支持的平台段:win/linux/osx(均映射为 Desktop 设备形态);架构段:x86/x64/arm/arm64/loongarch64(ArchToString 反向映射亦包含 Architecture.LoongArch64,见 Constants.cs)。
RID 矩阵(all_rids)
1static readonly string[] all_rids = new[] {
2 "win-x64", "win-x86", "win-arm64",
3 "osx-x64", "osx-arm64",
4 "linux-x64", "linux-arm64", "linux-loongarch64",
5};Source: Constants.cs
8 个 RID 构成完整发布矩阵:Windows 3 个架构、macOS 2 个架构(Intel + Apple Silicon)、Linux 3 个架构(含国产龙芯 loongarch64)。
Linux 打包常量(LinuxPackConstants)
1interface LinuxPackConstants
2{
3 const string TargetName = Constants.HARDCODED_APP_NAME;
4 const string PackagePrefix = TargetName;
5 const string PackageName = PackagePrefix;
6 const string Prefix = "/usr/share/" + PackagePrefix;
7 const string Release = "0";
8 const bool CreateUser = false;
9 const string UserName = Constants.HARDCODED_APP_NAME;
10 const bool InstallService = false;
11 const string ServiceName = PackagePrefix;
12 const string RpmVendor = AssemblyInfo.Company;
13 const string Description = AssemblyInfo.Description;
14 const string Url = "https://steampp.net";
15 const string PreInstallScript = null!;
16 const string PostInstallScript = null!;
17 const string PreRemoveScript = null!;
18 const string PostRemoveScript = null!;
19 const string FileNameDesktop = Constants.HARDCODED_APP_NAME + ".desktop";
20 const string DebMaintainer = AssemblyInfo.Company;
21 const string DebSection = "misc";
22 const string DebPriority = "extra";
23 const string DebHomepage = Url;
24 static readonly string dotnet_runtime = $"dotnet-runtime-{Environment.Version.Major}.{Environment.Version.Minor}";
25 static readonly string aspnetcore_runtime = $"aspnetcore-runtime-{Environment.Version.Major}.{Environment.Version.Minor}";
26}Source: Constants.cs
设计意图:这是 deb/rpm 打包所需元数据的单一事实来源——包名、安装前缀 /usr/share/steampp、desktop 文件名 Steam++.desktop(与项目内 Steam++.desktop 对应)、deb 的 maintainer/section/priority/homepage、rpm 的 vendor。dotnet_runtime/aspnetcore_runtime 按运行工具的当前 .NET 版本(Environment.Version)动态计算依赖包名,用于 FDE 包声明对系统 .NET 运行时的依赖,避免硬编码版本导致升级 .NET 后忘改依赖。四个安装/卸载脚本槽位均置 null!,表示当前打包流程不注入额外脚本钩子。
目录结构模型
- SCD 模式:每个 RID 的产物在
Publish/<rid>/,压缩包输出到该目录上一级(即Publish/本身),多个 RID 的包并列放在Publish/下。 - FDE 模式:产物在
Publish/FrameworkDependent/<rid>/,压缩包输出两级上级,即与 SCD 包同处Publish/层,两类产物可并列分发。
Usage Examples
工具入口:反射驱动的命令自动注册
1var rootCommand = new RootCommand($"{AssemblyInfo.Product} Publish Tools");
2var interfaceType = typeof(ICommand);
3var addMethod = interfaceType.GetMethod(nameof(ICommand.AddCommand), BindingFlags.Static | BindingFlags.Public);
4var commands = interfaceType.Assembly.GetTypes().
5 Where(x => x != interfaceType && x.IsInterface && interfaceType.IsAssignableFrom(x)).
6 Select(x => addMethod!.MakeGenericMethod(x)).
7 ToArray();
8Array.ForEach(commands, m => m.Invoke(null, new object?[] { rootCommand, }));
9return await rootCommand.Parse(args).InvokeAsync();Source: Program.cs
逐行解读:
new RootCommand($"{AssemblyInfo.Product} Publish Tools"):根命令描述由程序集产品名动态拼接(如 "Steam++ Publish Tools"),保证改名时帮助文本自动跟随。interfaceType.GetMethod(nameof(ICommand.AddCommand), BindingFlags.Static | BindingFlags.Public):拿到静态泛型方法ICommand.AddCommand<T>()的MethodInfo。Where(...):从ICommand所在程序集中筛出除自身外所有实现ICommand的接口——命令被建模为接口(配合 C# 静态抽象接口成员),工具通过该约定实现"零修改扩展"。MakeGenericMethod(x)+Invoke(null, ...):对每个命令接口实例化AddCommand<T>并传入rootCommand完成挂载;首个参数为null因其为静态方法。return await ...InvokeAsync():把命令执行结果(退出码)直接作为进程返回值,供 CI 判断成败。
关键路径常量与调试产物定位
1interface Constants
2{
3 const string ProjectDir_AvaloniaApp = "BD.WTTS.Client.Avalonia.App";
4 const string ProjectDir_AppHost = "BD.WTTS.Client.AppHost";
5 const string windowssdkver = "10.0.19041.0";
6
7 static string DebugRuntimeConfigPath => Path.Combine(ProjectUtils.ProjPath, "src", ProjectDir_AvaloniaApp, "bin", "Debug", $"net{Environment.Version.Major}.{Environment.Version.Minor}-windows{windowssdkver}", runtimeconfigjsonfilename);
8
9 static string DirPublish_SCD => Path.Combine(ProjectUtils.ProjPath, "src", ProjectDir_AvaloniaApp, "bin", "Release", "Publish");
10
11 static string DirPublish_FDE => Path.Combine(ProjectUtils.ProjPath, "src", ProjectDir_AvaloniaApp, "bin", "Release", "Publish", "FrameworkDependent");
12 ...Source: Constants.cs
解读:
- 所有路径基于
ProjectUtils.ProjPath(仓库根)派生,工具不依赖当前工作目录,可在 CI 任意 checkout 路径下运行。 DebugRuntimeConfigPath指向 Debug 构建的Steam++.runtimeconfig.json,配合windowssdkver = "10.0.19041.0"与net{major}.{minor}动态拼接 TFM 路径(如net8.0-windows10.0.19041.0)——用于在打包时读取/校正运行时配置。- 文件名常量
runtimeconfigjsonfilename = "Steam++.runtimeconfig.json"、depsjsonfilename = "Steam++.deps.json"、exefileName = "Steam++.exe"均使用硬编码应用名HARDCODED_APP_NAME = "Steam++",源码注释明确标注"此值不可更改!"(见 Constants.cs),因为它们被用于缓存目录、协议注册等不可变值。
打包时排除运行期数据目录
1static readonly string[] ignoreDirNames = new[]
2{
3 IOPath.DirName_AppData,
4 IOPath.DirName_Cache,
5};
6
7[MethodImpl(MethodImplOptions.AggressiveInlining)]
8static async ThreadTask InBackground(Action action, bool longRunning = false)
9{
10 TaskCreationOptions options = TaskCreationOptions.DenyChildAttach;
11
12 if (longRunning)
13 {
14 options |= TaskCreationOptions.LongRunning | TaskCreationOptions.PreferFairness;
15 }
16
17 await ThreadTask.Factory.StartNew(action, CancellationToken.None, options, TaskScheduler.Default).ConfigureAwait(false);
18}Source: Constants.cs
解读:
ignoreDirNames复用IOPath.DirName_AppData/IOPath.DirName_Cache(与应用层共享的目录名常量),打包遍历时跳过 AppData 与 Cache 目录——防止把应用运行期产生的用户数据/缓存误打进分发包。InBackground是打包并发执行辅助方法:DenyChildAttach防止任务内再派生子任务干扰调度;longRunning=true时追加LongRunning | PreferFairness提示调度器为长耗时压缩/复制任务创建专用线程而非占用线程池;ConfigureAwait(false)避免上下文回切。MethodImplOptions.AggressiveInlining表明它是高频小任务包装器。
Configuration Options
本工具无独立配置文件,全部"配置"以代码内常量/静态属性形式集中管理:
| 常量/属性 | 类型 | 值(默认) | 说明 |
|---|---|---|---|
ProjectDir_AvaloniaApp | const string | "BD.WTTS.Client.Avalonia.App" | 被打包的 Avalonia 应用项目目录名 |
ProjectDir_AppHost | const string | "BD.WTTS.Client.AppHost" | 安装器/引导器项目目录名 |
windowssdkver | const string | "10.0.19041.0" | Windows TFM SDK 版本,用于定位 Debug 产物 |
DirPublish_SCD | static string | src/BD.WTTS.Client.Avalonia.App/bin/Release/Publish | SCD 发布产物根目录 |
DirPublish_FDE | static string | 上述目录下 FrameworkDependent | FDE 发布产物根目录 |
DebugRuntimeConfigPath | static string | bin/Debug/net{ver}-windows10.0.19041.0/Steam++.runtimeconfig.json | Debug 运行时配置文件路径 |
runtimeconfigjsonfilename | const string | "Steam++.runtimeconfig.json" | 运行时配置文件名 |
depsjsonfilename | const string | "Steam++.deps.json" | 依赖清单文件名 |
exefileName | const string | "Steam++.exe" | 主程序可执行文件名 |
HARDCODED_APP_NAME | const string | "Steam++" | 不可变硬编码应用名(文件/目录/协议等不可变值) |
all_rids | static string[] | win-x64/x86/arm64, osx-x64/arm64, linux-x64/arm64/loongarch64 | 完整发布 RID 矩阵(8 项) |
ignoreDirNames | static string[] | {IOPath.DirName_AppData, IOPath.DirName_Cache} | 打包时忽略的运行期数据目录 |
LinuxPackConstants.TargetName/PackageName/PackagePrefix | const string | "Steam++" | deb/rpm 包名与目标名 |
LinuxPackConstants.Prefix | const string | "/usr/share/Steam++" | Linux 安装前缀 |
LinuxPackConstants.Release | const string | "0" | 包 Release 号 |
LinuxPackConstants.CreateUser / InstallService | const bool | false | 不创建专用用户/不安装系统服务 |
LinuxPackConstants.RpmVendor / DebMaintainer | const string | AssemblyInfo.Company | 包维护者/厂商 |
LinuxPackConstants.DebSection / DebPriority | const string | "misc" / "extra" | deb 分类与优先级 |
LinuxPackConstants.Url / DebHomepage | const string | "https://steampp.net" | 项目主页 |
LinuxPackConstants.dotnet_runtime | static string | dotnet-runtime-{major}.{minor} | FDE 包对 .NET 运行时的 deb/rpm 依赖名(按工具自身 .NET 版本动态计算) |
LinuxPackConstants.aspnetcore_runtime | static string | aspnetcore-runtime-{major}.{minor} | ASP.NET Core 运行时依赖名 |
LinuxPackConstants.PreInstallScript 等 4 个脚本槽位 | const string | null! | 当前不注入安装/卸载脚本 |
API Reference
以下为 Constants(internal interface,源码级 API)中的关键方法签名与行为:
GetPackPath(AppPublishInfo item, string fileEx): string
计算最终打包产物的完整输出路径。
参数:
item(AppPublishInfo):包含DirectoryPath(RID 发布目录)与DeploymentMode(SCD/FDE)fileEx(string):产物扩展名(如.7z、.deb)
返回: 完整打包路径。SCD 为 item.DirectoryPath/../<fileName>,FDE 为 item.DirectoryPath/../../<fileName>。
抛出:
ArgumentOutOfRangeException:DeploymentMode不是 SCD 或 FDE 时(fail-fast)。
Source: Constants.cs
GetFileName(AppPublishInfo item, string fileEx): string
生成分发包文件名,规则:{Trademark 去空格}[_with_runtime]_v{version}_{目录名(- 换 _,osx 换 macos)}{扩展名}。
参数: 同 GetPackPath。
返回: 例如 Steam++_with_runtime_v3.0.0_macos_arm64.7z(SCD)或 Steam++_v3.0.0_win_x64.7z(FDE)形式。
抛出:
ArgumentOutOfRangeException:DeploymentMode未知。
Source: Constants.cs
GetVersion(): string
返回 AssemblyInfo.Version,即程序集版本号,用作产物文件名中的版本段。
Source: Constants.cs
ArchToString(Architecture architecture): string
把 System.Runtime.InteropServices.Architecture 枚举映射为小写字符串:arm/arm64/x64/x86/loongarch64;未知值抛 ArgumentOutOfRangeException。
Source: Constants.cs
DeconstructRuntimeIdentifier(string rid): (Platform, DeviceIdiom, Architecture)
把 RID 拆解为平台/设备形态/架构三元组;仅支持两段式 RID(平台-架构)。无法识别的平台/架构段保持 default(元组默认值),不抛异常。
Source: Constants.cs
InBackground(Action action, bool longRunning = false): ThreadTask
把同步打包动作调度到后台线程执行;longRunning=true 时使用 LongRunning | PreferFairness | DenyChildAttach 任务选项。
Source: Constants.cs
Failure Modes, Edge Cases & Concurrency
依据源码可验证的边界与防御行为:
- 未知部署模式 fail-fast:
GetPackPath/GetFileName的 switch 均以throw new ArgumentOutOfRangeException(nameof(item.DeploymentMode), ...)兜底。设计意图:新增部署模式时强制开发者显式更新命名与路径规则,杜绝静默产出错误命名的分发包。 - 未知架构 fail-fast:
ArchToString对未映射的Architecture值抛ArgumentOutOfRangeException,防止产出命名不完整的包。 - RID 解析静默降级:
DeconstructRuntimeIdentifier对不认识的段(例如未来出现新平台前缀)不抛异常而是保持默认值——这是刻意的宽松解析,让新 RID 至少能参与平台无关的处理步骤;但两段式之外(array.Length != 2)的三段 RID(如linux-musl-x64)同样返回默认元组,调用方需自行校验。 osx前缀重写:仅当目录名包含osx_(已先做过-→_替换)才替换为macos_,避免误伤其他片段。- 并发任务调度隔离:
InBackground使用DenyChildAttach防止子任务逃逸到外层延续、TaskScheduler.Default固定线程池调度,多 RID 并行压缩时行为可预测;ConfigureAwait(false)避免 UI/上下文回切(工具为纯命令行,无同步上下文,这是防御性写法)。 - 运行期数据防泄漏:
ignoreDirNames确保打包不吞入 AppData/Cache。 null!脚本槽位:四个安装/卸载脚本常量声明为null!——若未来 deb/rpm 打包逻辑直接拼接这些脚本路径而不判空,将产生空路径异常;当前无注入脚本需求,属于"已知留白"。
Performance & Operational Notes / Extension Points
扩展点(零修改入口):新增打包命令只需新建一个实现 ICommand 的接口(静态 AddCommand<T> 约定),Program.cs 的反射扫描即自动注册——入口对命令集合完全开放闭合(OCP)。这也是把命令建模为接口而非类的直接原因:静态抽象接口成员(C# 11 static abstract members in interfaces)使得 AddCommand<T>() where T : ICommand 可在无需实例化的前提下挂载命令。
版本一致性:dotnet_runtime/aspnetcore_runtime 依赖名按 Environment.Version(工具自身运行时)计算,因此用与目标应用相同/匹配的 .NET SDK 运行本工具是产出正确 FDE 依赖声明的前提;升级 .NET 后无需改代码,但需确保 CI 中工具与被发布应用的 TFM 一致。
运行方式:该工具是独立控制台程序(Program.cs 顶层语句 + return await ...InvokeAsync() 返回退出码),天然适配 CI 脚本调用;产物统一落到 Publish/ 层,便于 CI 单点收集上传(CI 工作流的矩阵构建与上传策略由兄弟页面描述)。
产物命名含义速查:
| 文件名片段 | 含义 |
|---|---|
Steam++(Trademark 前缀) | 可变展示名,空格转下划线 |
_with_runtime_ | 自包含部署(SCD),内置 .NET 运行时 |
无 _with_runtime_ | 依赖框架部署(FDE),需系统 .NET |
_v{version}_ | 程序集版本号 |
macos_ / win_ / linux_ | 平台(osx RID 已重写为 macos) |
x64 / x86 / arm64 / loongarch64 等 | CPU 架构 |
Related Links
- Program.cs — 反射命令注册入口
- Constants.cs — 路径/命名/RID/Linux 打包常量
- BD.WTTS.Client.Tools.Publish.csproj — 工具项目定义
- Steam++.desktop — Linux desktop 入口文件(对应
FileNameDesktop常量) - README.md — 工具项目说明
相邻主题:客户端应用构建(BD.WTTS.Client.Avalonia.App)、安装器(BD.WTTS.Client.AppHost)与 CI 工作流由各自兄弟页面覆盖,本页不展开。