Repository Wiki
ChanIok/SpinningMomo

构建与发布脚本链(scripts/)

scripts/ 目录是 SpinningMomo 的本地构建与发布编排层:一组 Node.js / PowerShell 小脚本,负责把版本号传播到各产物、编译 Android 捕获服务 JAR、组装 dist/ 发布目录、调用 WiX 生成 MSI/Bundle 安装器,并为最终发布物生成 SHA256 校验清单。

目的与范围(Purpose and Scope)

本页覆盖 scripts/ 目录中构成"构建 → 打包 → 发布"完整链路的脚本,基于以下已读源文件做深入剖析:

以下相邻主题由兄弟页面承接,本页不展开:C++ 主程序本体构建(xmake/vcpkg 配置与 patch-xmake-*.js、patch-vcpkg.js 等工具链补丁脚本)、Web 前端构建(web/ 与 format-web.js)、代码格式化脚本(format-cpp.js、format-cpp-includes.py)、CI 工作流定义(.github/workflows 中的 Build Release workflow,仅由 release-version.js 的提示信息引用)。此外 build-portable.js、fetch-android-jar.js、fetch-third-party.js、generate-*.js、run-android.js、quick-cleanup-spinningmomo.ps1 等文件存在于同目录(见文件清单),但其内部实现未在本页读取范围内,本文不做臆测性描述。

概述(Overview)

SpinningMomo 是一个跨平台(Windows 桌面 + Android 捕获服务 + Web 资源)的混合工程,因此一次完整发布需要产出多种工件:

工件生成脚本输出位置
Android DEX JAR(momo-capture.jar)build-android.jsbuild/android/momo-capture.jar
发布目录骨架prepare-dist.jsdist/(exe、LICENSE、resources/web、resources/android)
MSI 安装包build-installer.jsdist/SpinningMomo-<ver>-x64.msi
Bundle 安装器build-installer.jsdist/SpinningMomo-<ver>-x64-Setup.exe
便携版 ZIPbuild-portable.js(本页未读取内部实现)dist/*-Portable.zip
校验清单generate-checksums.jsdist/SHA256SUMS.txt

设计上的几个核心意图:

  1. 版本号单一来源(version.json):release-version.js 一次性把 X.Y.Z 同步到 C++ 头文件、Windows 资源文件和线上版本探测文件,避免"安装器版本与程序内显示版本不一致"这类经典漂移问题。
  2. 幂等与前置校验前置:每个脚本在动手之前先检查依赖产物是否存在(prepare-dist.js 逐项 fs.existsSync 检查),失败立即 process.exit(1) 并打印下一步建议命令,把"缺产物"错误从打包阶段提前到组装阶段。
  3. 防误删的清理边界:build-android.js 在递归删除临时目录前做了 path.relative 边界断言,任何越界路径都会被拒绝——这是对"脚本递归删错目录"这一类灾难性事故的防御。
  4. 无 shell 依赖的子进程调用:Android 构建直接 spawnSync 到 java.exe/javac.exe 并使用 @argument-file 传参,绕开 Windows .bat 包装器与 shell 引号转义问题。

架构(Architecture)

Loading diagram...

要点说明:

  • release-version.js 处于链路最前端,它不构建任何东西,只做四向文本改写;改写完成后的提示信息明确要求人工 review diff、打 tag、push tag,再"手动从 tag 运行 Build Release workflow"——即正式产物构建发生在 CI 侧,本地脚本是同一套逻辑的可复现入口。
  • prepare-dist.js 是汇聚点:Windows 主程序、Web 静态资源、Android JAR 三条独立构建线的输出在 dist/ 汇合成最终目录结构,并被 MSI/Bundle/Portable 三种打包形态共同消费。
  • generate-checksums.js 只认 -Setup.exe 与 -Portable.zip 后缀,MSI 不在默认清单里(脚本用 endsWith("-Setup.exe") || endsWith("-Portable.zip") 过滤),这与"用户实际下载的是安装器与便携包"的发布策略一致。

主内容:各脚本深入剖析

release-version.js —— 版本号单一来源传播

该脚本接受的输入是可选带 v 前缀的三段版本号(2.0.1 或 v2.0.1 均可),内部先归一化再做两次形态转换:

  • 三段形式 X.Y.Z:写入 version.json 与 docs/public/version.txt(后者是线上版本探测用的纯文本,内容就是 "<version>\n")。
  • 四段形式 X.Y.Z.0:toVersion4Parts() 用 while (parts.length < 4) parts.push(0) 补齐,随后以两种形态写回 Windows 资源文件——resources/app.rc 中 APP_VERSION_NUM 使用逗号分隔(2, 0, 1, 0,供 VERSIONINFO 资源的数值字段使用),APP_VERSION_STR 使用点分字符串;src/core/version.hpp 中的内联函数则被整体替换:
js
1function updateVersionHeader(filePath, version4Parts) { 2 const content = fs.readFileSync(filePath, "utf8"); 3 const versionStr = version4Parts.join("."); 4 const pattern = /inline auto get_app_version\(\) -> std::string \{ return ".*"; \}/; 5 6 if (!pattern.test(content)) { 7 throw new Error("src/core/version.hpp format is unexpected. get_app_version() not found."); 8 } 9 10 const updated = content.replace( 11 pattern, 12 `inline auto get_app_version() -> std::string { return "${versionStr}"; }` 13 ); 14 15 fs.writeFileSync(filePath, updated, "utf8"); 16}

Source: release-version.js

设计意图(WHY):这里采用了"结构探测 + 整体替换"而不是逐行编辑的方式。updateAppRc() 先用 /#define APP_VERSION_NUM .*/ 和 /#define APP_VERSION_STR ".*"/ 两个正则探测目标行是否存在,缺失即抛错;这保证了脚本在源文件被格式化工具重排或字段被改名时会显式失败而不是静默漏改——版本号漏改的代价(发布出错误版本)远高于脚本报错重跑。

build-android.js —— Android 捕获服务的确定性编译

该脚本把 android/capture/src/**/*.java 编译为 DEX 格式的 momo-capture.jar,全过程只用 JDK 与 Android SDK 自带的两个 Java 入口,不依赖 Gradle、不依赖 .bat 包装器。

关键路径一:预编译 JAR 跳过机制。 若开发者此前通过 fetch:android-jar 拉取过发布版 JAR,目录里会留下标记文件,脚本据此短路:

js
1const outputDirectory = path.join(root, "build", "android"); 2const markerPath = path.join(outputDirectory, ".android-jar-fetched"); 3const jarPath = path.join(outputDirectory, "momo-capture.jar"); 4if (fs.existsSync(markerPath) && fs.existsSync(jarPath)) { 5 const tag = fs.readFileSync(markerPath, "utf8").trim() || "unknown"; 6 console.log(`Android jar fetched from release ${tag}; skipping build:android.`); 7 console.log(`Remove ${path.relative(root, markerPath)} to rebuild from source.`); 8 return; 9}

Source: build-android.js

注意短路条件是标记文件与 JAR 同时存在(&&),只删 JAR 不删标记不会误判;提示信息会打印标记里记录的 release tag,方便确认当前用的是哪个上游版本。

关键路径二:环境与工具校验。 脚本要求 JAVA_HOME(JDK 21 根目录)与 ANDROID_HOME(Android SDK 根目录)两个环境变量,并用 requireFile() 逐一确认四个可执行文件/包真实存在:java.exe、javac.exe、platforms/android-<compileSdk>/android.jar、build-tools/<buildTools>/lib/d8.jar。其中 compileSdk、buildTools、javaRelease、minSdk 均来自 android/capture/build-config.json。

关键路径三:argument file 规避命令行长度与转义问题。 javac 的源文件列表不直接放在命令行上,而是写入 javac.args 再用 @ 引用:

js
1const args = ["--release", String(config.javaRelease), "-encoding", "UTF-8", "-g:none", 2 "-classpath", androidJar, "-d", classes, ...sources]; 3fs.writeFileSync(argumentsFile, args.map(quoteJavaArgument).join("\n"), "utf8"); 4run(javac, [`@${argumentsFile}`]);

Source: build-android.js

quoteJavaArgument() 实现了 Java argument file 专属的引号规则(转义反斜杠与双引号、禁止换行),与 shell 规则不同——源码注释明确指出"不经过 Windows shell"。D8 同样绕过 d8.bat,直接 java -cp d8.jar com.android.tools.r8.D8 启动。-g:none 去掉调试信息,--release 锁定字节码目标版本,配合 --min-api 保证 DEX 兼容面。

关键路径四:越界防护的临时目录清理。 编译在一个 mkdtempSync 生成的 compile-* 前缀目录中进行,finally 块里删除前先做五重断言:

js
1const resolvedRoot = fs.realpathSync(outputDirectory); 2const resolvedWork = fs.realpathSync(workingDirectory); 3const relative = path.relative(resolvedRoot, resolvedWork); 4if (!relative || relative.startsWith("..") || path.isAbsolute(relative) 5 || path.dirname(relative) !== "." || !path.basename(relative).startsWith("compile-")) { 6 throw new Error(`Refusing to clean out-of-bounds directory: ${resolvedWork}`); 7} 8fs.rmSync(resolvedWork, { recursive: true });

Source: build-android.js

五个条件分别排除:相对路径为空(工作目录即根本身)、以 .. 开头(在根之外)、是绝对路径、不在根的直接子层(path.dirname(relative) !== ".")、目录名不带 compile- 前缀。realpathSync 的介入是为了对抗符号链接导致的路径逃逸。这是全目录最严格的安全防御段落:任何一条不满足就宁可抛错也不删。

prepare-dist.js —— 发布目录的组装与前置门禁

该脚本职责单一:把三条构建线的产物复制成固定布局。它的价值不在复制本身,而在把"缺产物"错误从 WiX 打包阶段提前到组装阶段:

js
1if (!fs.existsSync(webDist)) { 2 console.error("web/dist not found. Run 'pnpm run build:web' first."); 3 process.exit(1); 4} 5if (!fs.existsSync(exePath)) { 6 console.error("SpinningMomo.exe not found. Run 'pnpm run build:cpp' first."); 7 process.exit(1); 8}

Source: prepare-dist.js

每个错误信息都直接给出修复命令(pnpm run build:web / build:cpp / build:android),操作者无需猜测依赖顺序。校验通过后,脚本先 fs.rmSync(distDir, { recursive: true, force: true }) 整体删除再重建 dist/——保证目录里不会残留上一个版本的陈旧文件(这是"清空重建"策略,与 build-android.js 的"受控删除"策略形成对比:前者目录归脚本独占所以可以粗暴,后者临时目录在共享的 build/android/ 之下所以必须严格断言)。

最终的目录布局:

目标来源
dist/SpinningMomo.exebuild/windows/x64/release/SpinningMomo.exe
dist/LICENSE仓库根 LICENSE
dist/resources/web/**web/dist/**(cpSync 递归整树复制)
dist/resources/android/momo-capture.jarbuild/android/momo-capture.jar

注意主程序期望的 JAR 落点是 resources/android/,脚本为此专门 mkdirSync 创建该层——这意味着 Android 服务是随主程序一同分发的运行时资源,而非独立安装项。

build-installer.js —— WiX 安装器构建(MSI → Bundle 两段式)

脚本头部的注释列出了前置条件:.NET SDK 8.0+、全局安装的 WiX CLI(dotnet tool install --global wix)以及三个扩展(WixToolset.UI.wixext、WixToolset.Util.wixext、WixToolset.BootstrapperApplications.wixext)。ensureWix() 用 execSync("wix --version") 探测安装,失败则打印安装命令并退出——把环境问题的报错放在最前面。

参数模型:--version X.Y.Z(缺省时回退读 version.json,并以 /^\d+\.\d+\.\d+$/ 校验格式)、--msi-only、--bundle-only;后两者互斥(同时给出直接抛错)。版本解析优先级是 CLI 显式参数 > version.json。

MSI 构建:wix build 接收 installer/Package.wxs 作为主源文件,通过 -d 注入 ProductVersion、ProjectDir、DistDir 三个变量,-loc 指定 installer/Package.en-us.wxl 本地化文件,-arch x64 锁定平台:

js
1runWixBuild([ 2 "-arch", "x64", 3 "-d", `ProductVersion=${version}`, 4 "-d", `ProjectDir=${projectDir}`, 5 "-d", `DistDir=${distDir}`, 6 "-ext", "WixToolset.UI.wixext", 7 "-ext", "WixToolset.Util.wixext", 8 "-culture", "en-US", 9 "-loc", "installer/Package.en-us.wxl", 10 "-out", outputMsi, 11 "installer/Package.wxs", 12]);

Source: build-installer.js

Bundle 构建:installer/Bundle.wxs 通过 -d MsiPath=<msi 路径> 引用上一步产物——这解释了为什么 Bundle 与 MSI 是两段式:典型发布流程是"构建 MSI → 代码签名 → 用签名后的 MSI 构建 Bundle",--bundle-only 分支正是为这个重打包场景准备的:

js
1if (bundleOnly) { 2 if (!fs.existsSync(outputMsi)) { 3 throw new Error(`MSI not found: ${outputMsi}. Build or restore the signed MSI first.`); 4 } 5 buildBundle({ version, projectDir, outputMsi, outputExe });

Source: build-installer.js

错误信息"Build or restore the signed MSI first"明确表述了这个工作流的签名前提。默认(无参数)路径是 MSI + Bundle 连续构建,且仅当 dist/SpinningMomo.exe 不存在时才触发 pnpm run build——已构建过则跳过,避免重复全量编译。

失败处理:runWixBuild() 用 spawnSync 并 stdio: "inherit" 让 WiX 输出直接透传到终端;非零退出码时 process.exit(result.status ?? 1) 原样转发 WiX 的退出码,便于 CI 精确判定失败原因。

generate-checksums.js —— 发布校验清单

实现极简:对 dist/ 下所有匹配 *-Setup.exe 或 *-Portable.zip 的文件计算 SHA256,逐行打印并汇总写入 dist/SHA256SUMS.txt:

js
const files = fs.readdirSync(distDir).filter((f) => { return f.endsWith("-Setup.exe") || f.endsWith("-Portable.zip"); });

Source: generate-checksums.js

calculateSHA256() 用 fs.readFileSync 整文件读入内存后 crypto.createHash("sha256")——安装器体积在数百 MB 量级时可接受,因为发布是低频操作;若未来文件增大可改为流式 createReadStream().pipe(hash)。清单行格式为 "<hash> <file>"(两个空格),与 GNU sha256sum -c 的标准校验格式兼容,用户可直接 sha256sum -c SHA256SUMS.txt 验证下载。空文件列表时同样给出下一步命令提示(先跑 build:portable 与 build:installer)。

核心流程(Core Flow)

一次完整的本地发布按以下顺序执行(各脚本通过 dist/ 与 build/ 目录间接耦合,没有显式的总控脚本):

Loading diagram...

顺序之所以重要:build-installer.js 的 WiX 源文件通过 -d DistDir 引用 dist/ 布局,generate-checksums.js 依赖 -Setup.exe/-Portable.zip 命名约定,而这两者都由上游步骤决定。版本号必须在一切构建之前同步,否则 version.json(build-installer.js 的回退来源)、version.hpp(程序内显示)与 MSI ProductVersion 会三者不一致。

使用示例(Usage Examples)

示例 1:更新版本号

bash
# 带 v 前缀与不带均可,内部会归一化为 X.Y.Z pnpm run release:version -- 2.1.0

Source: release-version.js

脚本结束时会打印后续手工步骤(review diff → git tag v2.1.0 → push → 从 tag 手动触发 Build Release workflow)。

示例 2:构建 Android 捕获服务

bash
# 前置:设置 JAVA_HOME(JDK 21)与 ANDROID_HOME(Android SDK) node scripts/build-android.js # 不接受任何额外参数,多参会抛 Usage 错误

Source: build-android.js

若之前用 fetch:android-jar 拉取过预编译 JAR,需删除 build/android/.android-jar-fetched 才会触发重新编译。

示例 3:仅重建签名后的 Bundle

bash
1# 用已签名(或手工还原)的 MSI 重新打 Bundle,跳过 MSI 重建 2node scripts/build-installer.js --bundle-only 3# 或显式指定版本、只出 MSI 4node scripts/build-installer.js --version 2.1.0 --msi-only

Source: build-installer.js

示例 4:生成校验清单并验证

bash
node scripts/generate-checksums.js # 产出 dist/SHA256SUMS.txt,随后可按 GNU 标准格式校验: cd dist && sha256sum -c SHA256SUMS.txt

Source: generate-checksums.js

配置选项(Configuration Options)

脚本自身通过 argv 解析的参数:

脚本参数类型 / 默认值说明
build-installer.js--versionstring / 空(回退 version.json)覆盖产品版本,必须匹配 ^\d+\.\d+\.\d+$
build-installer.js--msi-onlyflag / false只构建 MSI,不构建 Bundle
build-installer.js--bundle-onlyflag / false只构建 Bundle,要求 MSI 已存在;与 --msi-only 互斥
build-android.js(无)—多余参数直接抛 Usage 错误
release-version.js<version>位置参数,必填可带 v/V 前缀,形如 X.Y.Z

脚本依赖的外部配置与环境变量:

来源键说明
version.jsonversion版本号单一来源;build-installer.js 在未显式传 --version 时读取
android/capture/build-config.jsoncompileSdk / buildTools / javaRelease / minSdk分别决定 platforms/android-<n>/android.jar、build-tools/<v>/lib/d8.jar 的定位,以及 javac --release 与 D8 --min-api
环境变量JAVA_HOMEJDK 21 根目录,用于定位 java.exe/javac.exe
环境变量ANDROID_HOMEAndroid SDK 根目录
文件存在性build/android/.android-jar-fetched预编译 JAR 标记文件,内容为 release tag
工具链wix CLI + 三个 WiX 扩展WixToolset.UI.wixext、WixToolset.Util.wixext、WixToolset.BootstrapperApplications.wixext
目录build/windows/x64/release/、web/dist/、installer/*.wxs、installer/*.wxl上游产物与 WiX 源文件固定位置

API 参考(脚本内部可复用函数)

这些脚本均为一次性 CLI 入口,不导出模块接口;下表按文件列出其内部函数的真实签名(供维护者参考,非公共 API)。

release-version.js

  • normalizeVersion(input: string): string — 去掉 v/V 前缀并用 /^\d+\.\d+\.\d+$/ 校验,失败抛 Error。
  • toVersion4Parts(version: string): number[] — 按点切分并 parseInt,末尾补 0 到 4 段。
  • updateVersionJson(filePath: string, version: string): void / updateVersionTxt(filePath, version): void — 写 {"version": ...} JSON 与纯文本。
  • updateAppRc(filePath: string, version4Parts: number[]): void — 先探测 APP_VERSION_NUM / APP_VERSION_STR 两行,缺失抛错,然后整体替换。
  • updateVersionHeader(filePath: string, version4Parts: number[]): void — 见前文代码块;探测不到 get_app_version() 抛错。

build-android.js

  • requireFile(file: string, description: string): string — 存在性 + isFile() 双重检查,缺失抛含描述的 Error。
  • collectFiles(directory: string, suffix: string): string[] — 递归收集指定后缀文件并 sort()(排序保证 javac 输入确定性)。
  • run(executable: string, args: string[]): void — spawnSync(..., { cwd: root, stdio: "inherit", windowsHide: true, shell: false });shell: false 是规避 Windows 转义问题的核心;非零退出抛含退出码的 Error。
  • quoteJavaArgument(value: string): string — Java argument file 引号规则:含 \r\n 抛错,转义 \ 与 "。

build-installer.js

  • parseArgs(argv: string[]): { version, msiOnly, bundleOnly } — 未知参数抛错;互斥参数组合抛错。
  • resolveVersion(cliVersion: string): string — CLI 优先,否则读 version.json 并校验格式。
  • ensureWix(): void — wix --version 探测,缺失时打印安装指引并 exit(1)。
  • runWixBuild(args: string[]): void — spawnSync("wix", ["build", ...args]),失败转发 WiX 退出码。
  • buildMsi({version, projectDir, distDir, outputMsi}) / buildBundle({version, projectDir, outputMsi, outputExe}) — 分别驱动 Package.wxs 与 Bundle.wxs。

generate-checksums.js

  • calculateSHA256(filePath: string): string — 同步整读 + crypto.createHash("sha256"),返回小写 hex。

失败模式、边界与并发(Failure Modes & Edge Cases)

显式失败优先于静默成功。 四个已读脚本共有同一个模式:所有前置条件(文件、环境变量、工具、格式)不满足时立即抛错或 exit(1),且错误信息附带修复动作。这是刻意选择——发布链路里"带病成功"(例如版本号没同步上、JAR 是旧版本)比"提前失败"代价高得多。

格式漂移防御(release-version.js)。 app.rc 与 version.hpp 的改写都以正则探测为前提:目标行被改名、删除或格式变化 → 抛 format is unexpected 错误而非漏改。这把"源文件演化"转化为显式维护信号。

清理策略的两极分化。 prepare-dist.js 对 dist/ 无条件 rmSync(目录归其独占,残留即脏数据);build-android.js 对临时目录做五重路径断言 + realpathSync 防符号链接逃逸(目录位于共享的 build/android/ 之下)。两者体现了同一条原则的不同实现:删除操作的作用域必须可证明。

并发:所有脚本均无锁、无队列,属"单人本地串行"设计。同时运行两个 build-installer.js(例如同时触发 MSI 与 Bundle 构建)会竞争同一 dist/ 输出路径;同时运行 prepare-dist.js 与 build-installer.js 则可能出现"边删边打包"。源码中没有任何互斥机制,运维上必须靠"串行执行"的约定来保证正确性——这也是把正式产物构建放在 CI 单一 workflow 中串行运行的原因之一。

平台边界(build-installer.js)。 main() 第一行 if (process.platform !== "win32") 直接退出,Windows-only 是硬约束而非软提示。相对地,build-android.js 在非 Windows 上会在 requireFile 阶段失败于 java.exe 不存在——即其 Windows 专属性是隐式的(通过 .exe 后缀体现),并非显式平台检查。

退出码语义。 build-installer.js 转发 WiX 的原始退出码(process.exit(result.status ?? 1));build-android.js 与 release-version.js 统一 exitCode = 1 或 process.exit(1)。所有顶层都包了 try/catch,未捕获异常不会以堆栈形式裸露给使用者,而是打印 error.message。

性能与运维注意事项(Performance & Operational Notes)

  • 增量感知:build-installer.js 仅在 dist/SpinningMomo.exe 缺失时触发 pnpm run build,重复打包安装器时无需重新编译 C++ 主程序;build-android.js 的 fetch 标记机制同理省去 Android 重编译。
  • 确定性输入:collectFiles() 对源文件列表排序、javac 使用 @argument-file,使同一源码树的重编译结果可复现(排除文件系统目录顺序差异)。
  • 大文件哈希:generate-checksums.js 整文件读入内存。发布物通常在数百 MB 以内可接受;若需支持更大产物,改造点集中在 calculateSHA256 一个函数(换 createReadStream + hash.update 分块)。
  • 签名工作流:MSI 与 Bundle 两段拆分的存在意义即"签名后的重打包"(--bundle-only 的错误文案明示了 signed MSI 前提)。运维顺序应为:构建 MSI → 签名 MSI → --bundle-only 重建 Bundle → 生成校验清单。
  • CI 一致性:release-version.js 的输出提示要求"从 tag 手动触发 Build Release workflow",说明本地脚本是 CI 工作流的可复现镜像;修改脚本行为时需同步检查 CI 侧调用是否受影响(CI 定义文件不在本页读取范围内)。

扩展点(Extension Points)

  • 新增发布物形态:若要新增一种打包形态(如 zip 内嵌清单、增量包),参照 build-portable.js 的模式——从 dist/ 读取、向 dist/ 写出、命名以 -<suffix> 结尾;若希望纳入校验清单,只需让 generate-checksums.js 的过滤器(当前只匹配 -Setup.exe / -Portable.zip)多接受一个后缀。
  • 新增版本传播目标:在 release-version.js 中新增一个 updateXxx(filePath, ...) 函数并在 main() 尾部追加调用即可;关键约束是必须沿用"正则探测 → 缺失抛错 → 整体替换"三段式,以维持显式失败语义。
  • 替换 Android 编译入口:build-android.js 对工具的引用集中在 requireFile 四连与 run(javac/d8) 两处;更换编译器或增加混淆步骤时保持 shell: false + argument file 的调用方式即可继续规避 Windows 转义问题。
  • 脚本间无总控:目前链路由人工按顺序执行(或由 CI 编排)。若要增加一个总控脚本,天然切入点是 prepare-dist.js 的前置校验清单——把"缺什么、该跑什么"从错误信息升级为自动调度。

测试(Tests)

已读取的五个脚本文件中未发现对应的单元测试文件(源码内亦无自测试逻辑);scripts/ 目录下未列出任何 *.test.js 或 __tests__ 结构。脚本正确性的保障手段是运行时断言 + 失败即退出,而非自动化测试覆盖。此为本页读取范围内的客观情况,不代表仓库其他位置不存在针对发布链路的测试。