Rush:把大型 Monorepo 的安装、构建与发布变成可治理系统
一个拥有两百多个包的前端平台仓库,把 PR 构建从四十分钟优化到了八分钟。两周后,公共类型包修改了导出结构,流水线却只构建了类型包本身,没有重跑下游应用;本地缓存又恢复了一份由另一套环境变量生成的制品。PR 全绿,主干发布后才在浏览器端暴露运行时错误。团队最初把问题归咎于“Monorepo 太大”,真正失控的却是三条边界:谁决定项目依赖图,哪些输入变化必须传播到下游,什么样的构建结果才有资格被复用。
Rush 的价值不只是并行执行多个 package.json 脚本。它把仓库项目清单、包管理器版本、集中 lockfile、项目选择、任务依赖、增量状态、缓存制品和发布策略放进同一套控制面。这样做会增加配置文件和治理成本,却能让大型团队回答几个必须可审计的问题:这次安装到底使用了哪套依赖,为什么某个项目被跳过,缓存键包含了哪些输入,谁能向共享缓存和制品仓库写入,以及一次公共包变更会触发哪些下游验证。
先把 Rush 固定在仓库里
Rush 需要 Node.js 和一个能够访问 npm registry 的 npm 客户端。全局安装只负责第一次执行 rush init;真正进入仓库后,rush.json 中的 rushVersion 才是团队基线,Rush 的版本选择器和 common/scripts/install-run-rush.js 都会读取它。官方的新仓库初始化流程也把 rushVersion、包管理器版本、Node.js 支持范围和 registry 配置列为初始化后的首要检查项。
当前 npm latest 发行线的 @microsoft/rush 为 5.177.2,pnpm 为 11.13.1,两者均采用 MIT 许可证。版本号说明的是可复现示例,不是“所有仓库应立即升级”的指令;Rush、pnpm、Node.js 与插件之间仍要通过仓库自己的兼容矩阵。Rush 本身没有商业版功能开关,但云存储、Redis、CI runner、制品库和企业支持会产生独立成本,不能把开源许可误读为整条构建链免费。
先在实验仓库中确认环境,再初始化配置:
node --version
npm --version
npm view @microsoft/rush version
npm view pnpm version
npm install --global @microsoft/rush
mkdir rush-order-platform
cd rush-order-platform
git init
rush initrush init 会生成 rush.json、common/config/rush/、common/scripts/ 和 CI 样例。不要把 npm view 返回的版本每次自动写回主干;升级 Rush 或 pnpm 会改变安装器、lockfile 和策略行为,应由独立升级 PR 完成。下面的版本是一组明确锁定的示例基线,落地时应替换为团队已经在目标 Node.js、操作系统、registry 和 CI 镜像上验证过的稳定版本:
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json",
"rushVersion": "5.177.2",
"pnpmVersion": "11.13.1",
"nodeSupportedVersionRange": ">=20.19.0 <21.0.0 || >=22.12.0 <23.0.0",
"projectFolderMinDepth": 2,
"projectFolderMaxDepth": 2,
"ensureConsistentVersions": true,
"projects": []
}rushVersion 决定仓库执行的 Rush 引擎,pnpmVersion 决定 Rush 下载并调用的 pnpm,二者都不应依赖开发机的偶然全局状态。nodeSupportedVersionRange 是入口门禁,不是“Node 越新越好”的口号;CI 矩阵和开发机管理器必须使用这个范围内的版本。两个 folder depth 字段强制项目位于类似 apps/storefront、libraries/order-model 的两层目录,能阻止团队把组织架构无限复制成深层文件树。ensureConsistentVersions 会在安装、更新、链接、版本和发布流程中检查依赖声明的一致性。
初始化成功后,先证明仓库固定版本能独立启动:
node common/scripts/install-run-rush.js --version
rush --version两条命令应显示相同的仓库 Rush 版本。第一条会根据 rush.json 下载并缓存对应引擎,适合 CI 和刚克隆仓库的开发机;第二条依赖全局入口,但进入 Rush 后仍会选择仓库指定版本。若第一条失败而 npm view 成功,优先比较 common/config/rush/.npmrc、代理、企业 CA 和 registry 凭证,而不是反复重装全局 Rush。若两条命令显示不同版本,检查当前目录是否位于另一个 rush.json 之下,以及全局入口是否真的完成了版本选择。
建立两个能证明依赖传播的项目
仅有空配置无法验证任务图。创建一个公共模型包和一个消费它的应用:
rush-order-platform/
├─ apps/
│ └─ storefront/
│ ├─ package.json
│ ├─ src/index.js
│ └─ scripts/build.mjs
├─ libraries/
│ └─ order-model/
│ ├─ package.json
│ ├─ src/index.js
│ └─ scripts/build.mjs
├─ common/config/rush/
└─ rush.jsonlibraries/order-model/package.json:
{
"name": "@acme/order-model",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"_phase:build": "node scripts/build.mjs",
"_phase:test": "node --test"
}
}libraries/order-model/src/index.js:
export const orderStatus = Object.freeze({
created: "CREATED",
paid: "PAID",
});libraries/order-model/scripts/build.mjs:
import { cp, mkdir, rm } from "node:fs/promises";
await rm("lib", { recursive: true, force: true });
await mkdir("lib", { recursive: true });
await cp("src/index.js", "lib/index.js");
console.log("ORDER_MODEL_BUILT");apps/storefront/package.json:
{
"name": "@acme/storefront",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@acme/order-model": "workspace:*"
},
"scripts": {
"build": "node scripts/build.mjs",
"_phase:build": "node scripts/build.mjs",
"_phase:test": "node --test"
}
}apps/storefront/src/index.js:
import { orderStatus } from "@acme/order-model";
export function renderPaidLabel(status) {
return status === orderStatus.paid ? "已支付" : "处理中";
}apps/storefront/scripts/build.mjs:
import { cp, mkdir, rm } from "node:fs/promises";
await rm("dist", { recursive: true, force: true });
await mkdir("dist", { recursive: true });
await cp("src/index.js", "dist/index.js");
console.log("STOREFRONT_BUILT");把项目登记进 rush.json。Rush 不通过磁盘通配符猜测项目,因为缓存机器可能残留旧目录,深度扫描也会让项目身份失去集中审计入口。rush.json 配置参考将 projects 定义为受管项目清单,packageName 必须与项目 package.json 的名称一致:
{
"packageName": "@acme/order-model",
"projectFolder": "libraries/order-model"
},
{
"packageName": "@acme/storefront",
"projectFolder": "apps/storefront"
}此处的 workspace:* 不是一个可以随意发布的 registry 版本,它告诉 pnpm 在工作区内链接本地项目。Rush 根据项目清单和 package.json 依赖建立有向图:storefront 依赖 order-model,所以构建模型必须先于应用,修改模型默认还会影响应用。跨项目直接写 ../../libraries/order-model/src/index.js 会绕过这张图,增量分析、版本发布和包边界检查都会失真,应作为代码审查禁项。
让 Rush 管理 pnpm,而不是在仓库根目录裸跑 pnpm
现代 Rush 仓库把 pnpm 设置放在 common/config/rush/pnpm-config.json。官方pnpm-config.json 参考说明,启用 workspace 后,Rush 会在 common/temp/ 生成 pnpm-workspace.yaml 和适配 Rush 策略的 .pnpmfile.cjs shim。也正因为工作区被迁移到临时目录,根目录直接运行 pnpm install 可能找不到正确工作区或绕开 Rush 注入的策略;需要调用 pnpm 子命令时使用 rush-pnpm。
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json",
"useWorkspaces": true,
"strictPeerDependencies": true,
"pnpmStore": "local",
"preventManualShrinkwrapChanges": true,
"globalOverrides": {},
"globalPeerDependencyRules": {},
"globalPackageExtensions": {},
"globalNeverBuiltDependencies": [],
"globalIgnoredOptionalDependencies": [],
"globalAllowedDeprecatedVersions": {},
"globalPatchedDependencies": {},
"unsupportedPackageJsonSettings": {}
}useWorkspaces 让 pnpm 负责工作区链接;strictPeerDependencies 把缺失或不满足的 peer dependency 从警告升级为安装失败;pnpmStore: local 把 content-addressable store 放在仓库临时区,隔离性更高,但多个仓库不能共享下载内容;global 能节省磁盘和下载时间,却会扩大不同 pnpm 版本、权限和损坏状态之间的耦合。preventManualShrinkwrapChanges 通过仓库状态摘要发现未经 rush update 处理的 lockfile 改动。
第一次生成 lockfile 应由维护者执行:
rush update
rush list --detailed
rush-pnpm list --depth 0
git status --short预期生成或更新 common/config/rush/pnpm-lock.yaml、common/config/rush/repo-state.json 等受版本控制文件,并在 common/temp/ 建立临时工作区。rush list --detailed 应列出两个项目,依赖图中 storefront 指向 order-model。审查 lockfile 后,将确定性安装文件与项目改动一起提交。
rush update 与 rush install 不能互换使用:
rush update 允许根据各项目的 package.json 更新集中 lockfile,适合依赖声明发生变化的开发流程。rush install 要求现有 lockfile 已经满足项目声明,不应改写仓库配置,适合 CI 和验证“提交内容是否完整”。rush update --full 会重新选择所有 SemVer 范围内可用版本,应由依赖升级任务使用,不能塞进每个 PR。
rush update --recheck 强制包管理器重新处理看似已满足的 lockfile,用于怀疑增量判断或 Rush fixup 未生效的诊断。
官方rush update 命令说明明确指出,普通 update 尽量保留既有选择,而 --full 会更新所有兼容版本。把 --full 放进开发机启动脚本,会让两个开发者在不同时间拉到不同的传递依赖,并制造与业务改动无关的 lockfile 噪声。
用一个失败实验抓住 lockfile 漂移
先保持工作区干净,然后只修改 apps/storefront/package.json,增加一个尚未进入 lockfile 的依赖:
{
"dependencies": {
"@acme/order-model": "workspace:*",
"nanoid": "^5.1.0"
}
}执行只读安装:
rush install应观察到非零退出码,以及 lockfile 与项目依赖声明不一致的错误。这个失败是供应链门禁正常工作,不应通过在 CI 中改成 rush update 来“修绿”。正确修复是在开发分支执行 rush update,审查 pnpm-lock.yaml 中新增解析、完整性哈希和来源,再提交变更。若企业 registry 返回 ERR_PNPM_NO_MATCHING_VERSION,先核对 scope registry、代理镜像同步和包权限;若是 ERR_PNPM_PEER_DEP_ISSUES,修复声明或记录经过评审的兼容例外,不能永久关闭严格 peer 检查。
实验结束时移除 nanoid 并再次执行 rush update,或者保留依赖并提交更新后的 lockfile。不要手工删改 YAML 条目;手工编辑很容易留下 orphan snapshot 或破坏 integrity 关系。
用 common-versions 管理依赖分歧
ensureConsistentVersions 约束的是各项目对同一依赖声明的 SemVer 范围,而 common/config/rush/common-versions.json 负责声明仓库级偏好和允许的例外。二者不是“所有项目只能安装一个物理版本”的承诺。根据官方common-versions.json 说明,preferredVersions 会影响包管理器选择,但不会突破不兼容的项目约束;allowedAlternativeVersions 是 rush check 接受的额外声明范围。
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json",
"preferredVersions": {
"typescript": "~5.9.0"
},
"implicitlyPreferredVersions": false,
"allowedAlternativeVersions": {
"typescript": ["~5.8.0"]
}
}这组配置表达的是“多数项目使用 TypeScript 5.9,迁移中的项目可以暂时声明 5.8”,而不是静默把所有 5.8 项目强制解析成 5.9。例外需要 owner、退出条件和可查询的迁移任务;否则 allowedAlternativeVersions 会变成永久债务仓库。修改 preferred versions 后执行:
rush update --full
rush check --verboserush check 应在不存在未登记分歧时成功。若同一依赖出现两个范围,先判断是否是工具链迁移、运行时兼容矩阵或错误复制,再决定统一声明还是添加限时例外。不要用 globalOverrides 掩盖本地包真实声明,尤其不能用它强行跨 major 覆盖安全补丁;override 影响整个依赖解析面,回归验证应覆盖所有消费者。
当单一 lockfile 的解析和所有权已经成为组织瓶颈时,Rush subspaces 可以让不同项目组使用多个 pnpm lockfile,但一个 Rush 项目只能属于一个 subspace。官方Subspaces 说明也建议数量尽可能少,因为拆分会减少依赖协调范围,同时增加版本冲突、跨 subspace 注入和维护成本。它是超大型仓库或隔离遗留技术栈的工具,不是按团队建文件夹的默认动作。
从项目图进入 phased commands
默认 rush build 把每个项目的 build 脚本视为一个不可分割操作。若 order-model 的编译已经结束但测试仍在运行,storefront 仍要等待整个脚本结束。Phased build 把编译、测试、打包拆成操作图,使“上游编译完成”与“本项目测试开始”能够分别调度。
Phased build 依赖 build cache 的 operation 与输入输出模型,因此先在 common/config/rush/build-cache.json 启用本地缓存。这里暂不接入共享存储:
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/build-cache.schema.json",
"buildCacheEnabled": true,
"cacheProvider": "local-only",
"cacheHashSalt": "rush-order-platform-v1"
}在 common/config/rush/command-line.json 中定义 phase 和命令:
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/command-line.schema.json",
"phases": [
{
"name": "_phase:build",
"dependencies": {
"upstream": ["_phase:build"]
},
"ignoreMissingScript": false,
"allowWarningsOnSuccess": false
},
{
"name": "_phase:test",
"dependencies": {
"self": ["_phase:build"]
},
"ignoreMissingScript": false,
"allowWarningsOnSuccess": false
}
],
"commands": [
{
"commandKind": "phased",
"name": "build",
"summary": "Build all selected projects.",
"phases": ["_phase:build"],
"enableParallelism": true,
"incremental": true
},
{
"commandKind": "phased",
"name": "verify",
"summary": "Build and test all selected projects.",
"phases": ["_phase:build", "_phase:test"],
"enableParallelism": true,
"incremental": true
},
{
"commandKind": "phased",
"name": "reverify",
"summary": "Rebuild and retest without incremental skipping.",
"phases": ["_phase:build", "_phase:test"],
"enableParallelism": true,
"incremental": false
}
],
"parameters": []
}_phase:build 的 upstream 依赖表示每个项目构建前,先完成所有上游项目的同名 phase。_phase:test 的 self 依赖只要求当前项目先构建,不会无条件等待上游测试。ignoreMissingScript: false 让缺脚本直接失败,适合已经完成标准化的仓库;迁移期可以暂时为 true,但应统计缺失项目并设退出门槛。allowWarningsOnSuccess: false 防止“有警告也算绿”吞掉编译或测试风险。
官方Phased build 指南强调 phase 名称必须以 _phase: 开头,并且 phased build 建立在 build cache 机制之上。phase 不是把同一脚本复制几份,而是重画操作依赖图。若 _phase:test 会修改 _phase:build 的输出目录,两个操作就无法独立缓存和恢复。
明确每个操作的输入和输出
在两个项目中创建 config/rush-project.json。模型包的构建输出是 lib,应用输出是 dist:
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-project.schema.json",
"incrementalBuildIgnoredGlobs": ["docs/**"],
"operationSettings": [
{
"operationName": "_phase:build",
"outputFolderNames": ["lib"],
"dependsOnAdditionalFiles": [
"../../common/config/rush/command-line.json"
],
"dependsOnEnvVars": ["NODE_ENV", "BUILD_TARGET"]
},
{
"operationName": "_phase:test",
"outputFolderNames": ["coverage", "test-results"],
"dependsOnEnvVars": ["TZ"]
}
]
}应用项目把 _phase:build 的 outputFolderNames 改为 dist。rush-project.json 参考说明,默认输入是项目目录下 Git 跟踪且未被 .gitignore 排除的文件;dependsOnAdditionalFiles 将项目外的工具配置纳入哈希,dependsOnEnvVars 将会改变输出的环境变量值纳入哈希。
这里有四个架构约束:
输出目录不能被 Git 跟踪,也不能包含符号链接,否则缓存归档与恢复语义不可靠。不同 phase 的输出目录不能重叠;测试不能在 lib 中写覆盖率或修改编译结果。所有影响输出的项目外配置、代码生成模板和环境变量都必须进入缓存键。
写到项目目录外、调用不可重复外部服务或依赖机器隐式状态的操作,应设置 disableBuildCacheForOperation: true,直到它被改造成纯输入到纯输出的任务。
incrementalBuildIgnoredGlobs 只应忽略被证明不会改变输出的文件。把 config/** 或 *.json 一刀切排除,会让编译器、代码生成器和运行时常量变化逃出增量分析。每新增一条 ignore,都应有反向实验:修改被忽略文件后做一次全量构建,比较输出哈希,证明结果确实不变。
正向实验:让上游变化触发下游构建
先提交实验仓库中的源文件,因为 Rush 的传统增量分析依赖 Git 跟踪文件。随后执行:
git add .
git commit -m "初始化 Rush 实验仓库"
rush update
rush reverify --verbose
rush verify --verbose第一次 reverify 应执行两个项目的 build 和 test phase;紧接着的增量 verify 应跳过未变化操作,或者从启用的缓存中恢复结果。然后修改 libraries/order-model/src/index.js,增加一个状态:
export const orderStatus = Object.freeze({
created: "CREATED",
paid: "PAID",
cancelled: "CANCELLED",
});再次运行:
rush verify --verbose应看到 order-model 重新构建,storefront 也因下游影响被纳入执行图。Rush 的rush build 命令说明指出,增量状态保存在各项目 .rush/temp 中,并根据 Git 跟踪的项目文件判断源输入变化。这里的证据不是总耗时,而是 verbose 日志中的项目选择、operation 状态与具体脚本输出。
若模型变了而应用被跳过,依次检查:
应用是否通过 workspace:* 或正常 SemVer 声明本地依赖,而不是相对路径、TypeScript path alias 或打包器 alias 偷渡。两个项目是否都登记在 rush.json,packageName 是否一致。phase 是否声明了正确的 upstream 依赖。
变更文件是否被 Git 跟踪,是否被 .gitignore 或 incrementalBuildIgnoredGlobs 排除。自定义脚本是否把真实输入放在项目目录之外,却没有写入 dependsOnAdditionalFiles。
反向实验:证明不安全选择会漏掉消费者
保持模型包处于修改状态,执行:
rush build --changed-projects-only --verbose预期只构建文件直接变化的项目,而忽略依赖它的 storefront。这不是更聪明的 affected 分析,而是操作者明确承诺“下游不需要重建”。官方增量构建说明和命令帮助都把 --changed-projects-only 标为不安全选项。它可以用于只改文案、注释或已证明不影响契约的局部变化,不应成为 PR 默认命令。
项目选择器表达的是不同意图:
# 构建 storefront 及其全部依赖
rush build --to @acme/storefront
# 从 order-model 向下验证消费者,同时补齐执行所需依赖
rush build --from @acme/order-model
# 只选 order-model,不补依赖,属于不安全选择
rush build --only @acme/order-model
# 以主干为基线选择发生文件变化的项目,再补齐其上游依赖
rush build --to git:origin/main
# 查看相对主干变化可能影响的下游项目
rush list --impacted-by git:origin/main --detailed官方项目子集选择说明区分了 --to、--from、--impacted-by 和 --only:--to 补上游依赖,--from 选择下游并补齐执行所需依赖,--impacted-by 与 --only 假定依赖已经可用,因此被标记为不安全。CI 不应把“选择更少”直接等价成“构建更快”;正确指标是漏测率、缓存命中率、关键路径时长和全量基线差异。
从输出保留升级到可恢复的构建缓存
Rush 的传统增量构建会在输入未变时保留工作区已有输出;切换分支或清空输出后,仍可能需要重建。Build cache 则把每个项目操作的输出打成归档,通过输入状态匹配后恢复。官方Build cache 指南将两者分别称为 output preservation 和 cache restoration,并说明 rush build 会读缓存,而 rush rebuild 不会从缓存恢复。
Rush 当前仍把 build cache 和依赖它的 phased builds 标记为持续演进能力。升级 Rush 时要把缓存 schema、provider 插件、归档兼容性和 phase 调度纳入回归,不能只验证 CLI 能启动;关键发布链还应保留无缓存重建入口。
前面启用的 local-only 配置把归档保存在 common/temp/build-cache。它足以验证缓存键与输出所有权,不会把实验结果传播到其他机器。
执行两轮并观察日志:
rush rebuild --verbose
rush build --verbose
rm -rf libraries/order-model/lib apps/storefront/dist
rush build --verbose在类 Unix shell 中,最后一轮应出现 Build cache hit、清理输出目录和恢复归档之类的日志;PowerShell 可用 Remove-Item -Recurse -Force 删除两个输出目录。cacheHashSalt 是全仓缓存失效开关,工具链发生无法被其他输入捕获的破坏性变化时才递增。频繁修改 salt 会抹掉共享缓存收益,也会隐藏真正漏入缓存键的输入。
缓存命中必须同时满足“输入相同”和“输出可安全复用”。常见污染来源包括:
构建脚本读取 NODE_ENV、feature flag、locale 或目标平台,却未列入 dependsOnEnvVars。代码生成器读取仓库根目录模板或外部 schema,却未列入 dependsOnAdditionalFiles。输出写入绝对路径、用户名、时间戳或随机 ID,使同一输入产生不同结果。
测试 phase 修改 build phase 的输出,造成归档所有权重叠。脚本读取未跟踪文件、开发机全局工具或网络最新内容,缓存键无法感知。
缓存不是正确性的来源。每个主干基线都应保留一条不读缓存的重建任务,比较构建结果、测试集合和制品摘要;一旦发现缓存恢复与重建不一致,先关闭相关 operation 的缓存,再补输入建模和确定性构建。rush purge 会删除 common/temp 下的本地缓存和临时安装;rush purge --unsafe 还会处理用户目录共享文件,可能破坏并发 Rush 进程,只能在隔离机器上用于最后诊断。rush purge 命令说明明确标注了这一并发风险。
云端缓存的信任边界
本地缓存只影响单个开发环境,云端缓存把一次构建结果传播给整个团队。Rush 的标准 cacheProvider 当前提供 local-only、Azure Blob Storage 和 Amazon S3 三种选择;HTTP 或其他后端需要通过 Rush 插件实现,不能只把 cacheProvider 写成任意字符串就期待内置支持。推荐权限模型是:开发者和 PR 流水线只读,受保护主干流水线在完成全量验证后写入;来自 fork、外部贡献者或低信任分支的作业不得写共享缓存。
Azure 配置的核心形态如下:
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/build-cache.schema.json",
"buildCacheEnabled": true,
"cacheProvider": "azure-blob-storage",
"cacheHashSalt": "rush-order-platform-v1",
"azureBlobStorageConfiguration": {
"storageAccountName": "acmebuildcache",
"storageContainerName": "rush-cache",
"isCacheWriteAllowed": false
}
}仓库只保存账户和容器标识,不保存 SAS、访问密钥或长期 token。开发者可使用 rush update-cloud-credentials 将 provider 支持的凭证写入用户目录的 Rush credential store;CI 通过 secret manager 注入凭证,受保护作业再临时设置 RUSH_BUILD_CACHE_WRITE_ALLOWED=1。Azure Blob 的 RUSH_BUILD_CACHE_CREDENTIAL 是 SAS 查询参数串;S3 优先读取工作负载身份提供的 AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY 和可选 AWS_SESSION_TOKEN,若改用 RUSH_BUILD_CACHE_CREDENTIAL,其格式是 accessKey:secretKey[:sessionToken]。自定义 HTTP provider 若提供 tokenHandler 一类扩展点,应动态取得短期 token,不能把 Authorization header 或长期 token 固化进 build-cache.json。插件接口和凭据格式由具体实现负责,升级时必须连同插件版本一起验证。日志脱敏必须覆盖这些字段及 helper 的标准输出。
# 普通 PR:允许读取,不允许写入
export RUSH_BUILD_CACHE_WRITE_ALLOWED=0
node common/scripts/install-run-rush.js build --verbose
# 受保护主干:验证通过后才允许写入
export RUSH_BUILD_CACHE_WRITE_ALLOWED=1
node common/scripts/install-run-rush.js build --verbose共享缓存容器还需要服务端版本控制或生命周期策略、传输加密、静态加密、访问审计、容量配额和删除流程。缓存归档可能包含 source map、内联环境配置、测试快照、内部 URL 甚至误写入输出的密钥,因此它的敏感级别至少与构建制品相同。不能把“只是缓存”当作降低数据治理等级的理由。
发现疑似污染时,先禁止所有写入,记录命中 entry、项目、phase、commit、runner 身份和输出哈希;随后递增 cacheHashSalt 或隔离缓存命名空间,使新构建不再命中旧对象;最后在无缓存重建中验证正确输出。直接清空整个容器虽然快,却会丢失取证线索并制造大规模冷缓存成本。
Cobuild 用 Redis 协调多台 runner,而不是替代调度器
单机已经打满 CPU 和内存、operation 图仍有足够并行空间时,Rush 的 Cobuild 可以让同一次 CI 流水线的多台 runner 协作。每台机器仍运行完整的 Rush 命令,云端 build cache 负责传递 operation 归档,Redis provider 负责抢占锁和记录完成状态。它不是 Bazel Remote Execution 或 BuildXL 那样的集中 action 调度器,也不会把一个不可拆分的长任务自动切成多个远程 action。
采用 Cobuild 前先证明三个条件:单机 --timeline 显示 CPU、内存或磁盘已经成为瓶颈;phase 图中存在可并行的独立 operation;云端 build cache 能在干净 runner 上完整恢复上游输出。若关键路径是一条串行链,增加 runner 只会增加队列和缓存流量。若缓存少归档了声明文件,Cobuild 反而更容易稳定暴露“下游在干净机器上拿不到上游文件”的问题。
Redis 示例需要一个与 rushVersion 保持兼容的 @rushstack/rush-redis-cobuild-plugin,并通过 autoinstaller 和 rush-plugins.json 加载。common/config/rush/cobuild.json 只声明协作能力和锁提供方:
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/cobuild.schema.json",
"cobuildFeatureEnabled": true,
"cobuildLockProvider": "redis"
}Redis 地址放在 common/config/rush-plugins/rush-redis-cobuild-plugin.json,密码只引用环境变量名:
{
"url": "rediss://redis.example.invalid:6380",
"passwordEnvironmentVariable": "REDIS_PASSWORD"
}生产环境使用 TLS、私网、独立数据库或 key prefix、最小权限账号、连接上限和过期策略。Redis 存的是 cobuild:lock:<context_id>:<cluster_id> 锁与 cobuild:completed:<context_id>:<cache_id> 完成状态,不是构建制品本身;制品仍在云端 build cache。Redis 不可用会影响协作调度,云缓存不可用则影响结果传递,两条链路必须分开监控和降级。
同一轮流水线的 runner 必须共享 RUSH_COBUILD_CONTEXT_ID,每个 runner 必须有不同的 RUSH_COBUILD_RUNNER_ID:
strategy:
matrix:
runner_id: [runner-1, runner-2, runner-3]
steps:
- run: node common/scripts/install-run-rush.js install
- name: Cooperative verify
run: node common/scripts/install-run-rush.js verify --verbose --timeline
env:
RUSH_BUILD_CACHE_WRITE_ALLOWED: "1"
RUSH_BUILD_CACHE_CREDENTIAL: ${{ secrets.RUSH_BUILD_CACHE_CREDENTIAL }}
RUSH_COBUILD_CONTEXT_ID: ${{ github.run_id }}_${{ github.run_attempt }}
RUSH_COBUILD_RUNNER_ID: ${{ matrix.runner_id }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}RUSH_COBUILD_CONTEXT_ID 在同一次执行的所有 runner 上必须相同,在重试和重新运行时必须变化。Cobuild 会为失败 operation 缓存错误日志和完成状态,避免其他 runner 重复执行同一个已知失败;新的 context ID 让重试真正重新执行失败项。若把 context ID 固定成分支名,旧失败状态可能污染后续运行;若每台 runner 使用不同 context ID,则所有机器都会重复抢相同工作,协作形同虚设。
先在两台 runner 上做正向验证:日志应显示某个 runner 获得 operation 锁并构建,另一台等待后从 build cache 恢复结果,最终两边汇总状态一致。反向实验则让 RUSH_COBUILD_CONTEXT_ID 在两台机器上故意不同,预期看到相同 cache miss 被重复执行;恢复一致 ID 后重复工作应消失。这个对照只能证明协调身份生效,缓存正确性仍需用干净 runner、rush-audit-cache-plugin 或无缓存制品摘要比较单独证明。
Cobuild 只应在受保护、同信任级别的 runner 集合中启用,因为所有参与者通常都需要写共享缓存并读取 Redis 状态。外部 PR 不应取得 Redis 密码或 cache writer 身份。容量治理至少记录 runner 数、重复 operation 数、锁等待、Redis 错误、云缓存上传下载字节和关键路径;当增加 runner 后高分位耗时不降反升,应先收缩并发,检查串行依赖、缓存归档和对象存储吞吐,而不是继续横向扩容。
把 CI 拆成确定性安装、选择验证和可信写入
CI 不需要全局安装 Rush。官方CI 启动说明提供的 common/scripts/install-run-rush.js 会读取 rushVersion、使用仓库 .npmrc 下载对应引擎并转发参数,避免根目录出现额外 node_modules。
一条稳健的 PR 流水线可以按下面的责任拆分:
steps:
- name: Bootstrap pinned Rush
run: node common/scripts/install-run-rush.js --version
- name: Install exactly from lockfile
run: node common/scripts/install-run-rush.js install
- name: Validate dependency policy
run: node common/scripts/install-run-rush.js check --verbose
- name: Verify public package change files
run: node common/scripts/install-run-rush.js change --verify
- name: Build changed graph
env:
RUSH_BUILD_CACHE_WRITE_ALLOWED: "0"
run: node common/scripts/install-run-rush.js verify --from git:origin/main --verbose真实流水线还应先完整 fetch 比较基线;浅克隆缺少 origin/main 历史时,git: selector 和 change file 验证可能得到错误集合。构建矩阵中的 Node.js 版本必须落在 nodeSupportedVersionRange 内,且至少有一条无缓存、全量 reverify 基线用于发现 affected 或缓存建模缺口。PR 快速路径与主干全量路径的项目数、operation 数、命中率和耗时都应进入 telemetry,不能只记录一个总耗时。
建议保存以下证据:
Rush、Node.js、pnpm 版本及 lockfile 摘要。基线 commit、选择表达式、选中的项目与 operation 列表。本地命中、云端命中、实际执行、跳过和失败的数量。
每个 phase 的关键路径时间、排队时间和输出归档大小。缓存读写身份、缓存 entry ID 与写入来源分支。发布包名、版本、change file、审批和 registry 响应。
指标异常要能导向行动。缓存命中率突然升高但实际测试数下降,可能是测试输出被错误复用;affected 项目数长期接近全仓,可能是基础包耦合过重或根配置被纳入所有缓存键;云缓存下载时间超过重建时间,则应按项目大小和网络位置关闭低收益缓存,而不是继续扩大对象存储预算。
Change files 与发布边界
Rush 的 change file 记录公共包的版本变化意图和 changelog 描述。它不是 Git commit 的替代物,也不等价于“有代码变化就必须发包”。只有在 rush.json 中声明可发布的项目,才进入这条流程:
{
"packageName": "@acme/order-model",
"projectFolder": "libraries/order-model",
"shouldPublish": true
}开发者在公共 API 变化后执行:
rush change
rush change --verify第一条交互式生成 common/changes/ 下的 change file,第二条检查修改过的公共包是否缺少变更记录。官方发布流程建议将 change file 与代码一同提交,并在 CI 中执行 rush change --verify。change type 应根据兼容性判断:破坏已有消费者用法是 major,向后兼容的新能力是 minor,兼容修复是 patch;“改了几行”不是版本级别标准。
当多个包必须同版本发布,或维护分支需要锁定 major 时,使用 common/config/rush/version-policies.json:
[
{
"policyName": "commerce-sdk",
"definitionName": "lockStepVersion",
"version": "3.4.0",
"nextBump": "patch"
},
{
"policyName": "legacy-adapters",
"definitionName": "individualVersion",
"lockedMajor": 2
}
]项目通过 versionPolicyName 关联策略,且不能同时设置 shouldPublish。Lockstep 适合必须成套消费和发布的 SDK,不适合为了“看起来整齐”把数百个独立包绑成同版本;后者会扩大无关发布、changelog 噪声和回滚半径。Individual policy 保留各包独立升级,同时可以约束 major。
发布流水线必须与 PR 验证分离:
# 只计算并展示版本、changelog 变化,不发布
rush publish
# 写入版本与 changelog,供审查和测试
rush publish --apply
# 普通 change file 流程的真实发布;由受保护流水线提供目标分支和 registry 身份
rush publish --apply --target-branch main --publish
# 使用版本策略时先提升版本并完成验证,再发布全部已提升的公共包
rush version --bump
rush publish --include-all没有参数的 rush publish 是只读演练;--apply 会修改磁盘文件;普通 change file 流程再加 --target-branch 与 --publish 才会提交版本变化并写 registry。版本策略是另一条两阶段流程:rush version --bump 先把策略应用到版本,团队在这个状态上完成构建与制品验证,随后 rush publish --include-all 会真实发布所有已经提升版本的公共包,它不是 dry-run 命令。真正发布只能在受保护分支、通过构建和制品检查后进行,npm token 使用短期或细粒度凭证,限制 scope、registry 和写权限,并确保命令输出不会打印 token;不要使用 --npm-auth-token 把凭证暴露在命令历史和进程参数中。发布使用 common/config/rush/.npmrc-publish,日常安装使用 .npmrc,两者的 registry 与认证边界应分别审查。
回滚 npm 包不能假设“覆盖同一个版本”。registry 通常禁止覆盖已发布版本,消费者也可能已经缓存或安装。常用恢复路径是发布兼容修复版本、对问题版本标记 deprecate、在极短窗口内按 registry 政策 unpublish,并通过 change file 留下原因。Lockstep 组回滚时必须评估整组包,而不是只修其中一个版本号。
权限、凭证与数据边界
Rush 把许多团队动作集中到仓库入口,权限设计也要随之集中:
| 资产 | 开发者 | PR 流水线 | 受保护主干/发布流水线 |
|---|---|---|---|
| 公共与私有依赖 registry | 只读 | 只读 | 只读 |
| 云构建缓存 | 只读 | 只读 | 验证后写入 |
| npm 发布 registry | 无 | 无 | 指定 scope 写入 |
| lockfile 与 Rush 配置 | PR 修改、owner 审核 | 校验 | 受保护合并 |
| change files | 创建 | 校验 | 消费并归档 |
| 版本策略 | 提案 | 校验 | owner 审批 |
common/config/rush/.npmrc 可以提交 registry URL 和 ${ENV_VAR} 引用,不能提交展开后的 token。企业代理做 TLS 检查时,应向 Node.js 与系统信任库安装企业 CA,并保留证书轮换流程;strict-ssl=false 会让依赖包和元数据暴露给中间人。开发者个人凭证、云缓存凭证和发布凭证必须分离,避免一个泄漏 token 同时拥有读取源码依赖、污染缓存和发布包的权限。
依赖安装本身会执行第三方 lifecycle scripts。对高风险包应使用 pnpm 的 only-built/never-built 策略、审批数据库和隔离构建 runner;globalNeverBuiltDependencies 能禁止指定包的安装脚本,但可能使原生模块缺少二进制,必须通过正反安装验证。lockfile integrity 哈希可以发现下载内容变化,却不能证明包没有恶意行为,还需要来源限制、依赖审查、漏洞扫描和构建网络策略。
故障诊断从阶段证据开始
安装阶段
rush install 报 lockfile 过期时,检查 PR 是否改了任一 package.json、common-versions.json、.pnpmfile.cjs 或 pnpm policy。开发分支执行 rush update 后审查差异,CI 不生成修复提交。下载超时使用 rush update --network-concurrency <较小值> --debug-package-manager 收集包管理器日志,同时核对代理、DNS、CA、registry 限流和凭证过期。ERR_PNPM_UNEXPECTED_STORE 通常说明 store 位置或 pnpm 版本变化,先比较 pnpmStore 与 RUSH_PNPM_STORE_PATH,再决定清理,避免直接删除共享 store 影响其他作业。
项目图与选择阶段
项目未被选中时,运行 rush list --detailed 并检查 rush.json 清单、包名和本地依赖声明。git: selector 结果异常时,确认 CI 已 fetch 基线 commit、merge-base 存在、变更文件位于项目目录下。根配置变化往往无法自然映射到单个项目,应通过 dependsOnAdditionalFiles、自定义选择策略或全量验证处理,不能假装它只影响最近的目录。
构建与缓存阶段
构建脚本成功但没有缓存条目时,检查项目是否存在 config/rush-project.json、operation 名称是否与 phase 一致、输出目录是否存在且未被 Git 跟踪。缓存命中却输出错误时,用 rush rebuild 绕过读取,比较环境变量、外部文件、Node.js 粒度、工具链版本和输出哈希。若任务写项目外目录或输出含 symlink,先禁用该 operation 缓存;不要靠反复改 salt 长期维持。
Phased command 阶段
_phase:test 在编译前启动,说明 self 依赖缺失;下游在上游编译前启动,说明 _phase:build 没有 upstream 同名依赖。缓存配置报告输出冲突时,把 build、test、pack 的目录拆开,并确保每个目录只有一个 operation owner。项目缺 phase 脚本时,迁移期可以允许 missing,但最终应由仓库级门禁拒绝,防止新项目静默跳过测试。
发布阶段
rush change --verify 失败时,先确认项目是 shouldPublish 还是关联了 version policy,再判断变更是否确实影响公共包。发布 401/403 要区分 registry 地址错误、scope 权限不足、token 过期和双因素策略,不要把 token 直接加到命令行重试。版本已经存在时停止流水线,调查重复 tag、并发发布或状态恢复逻辑;自动改成下一个 patch 版本会掩盖发布幂等性缺陷。
从普通多包仓库迁入 Rush
迁移不能同时重写包结构、构建工具、依赖版本和发布流程。较稳妥的顺序是:
盘点所有可构建项目、包名、目录、内部依赖、产物、测试命令和发布权限,先修复跨目录相对引用。在迁移分支初始化 Rush,固定 Rush、Node.js 和 pnpm 版本,只登记少量叶子项目。用 rush update 生成集中 lockfile,比较旧 lockfile 的关键运行时依赖、peer dependency 和安装脚本变化。
先保持各项目原有 build 命令,通过 rush rebuild --to <项目> 证明项目图和构建顺序。把 PR 流水线并行运行旧路径与 Rush 路径,比较测试集合、制品文件清单和哈希,不立即切断旧入口。在输出所有权清楚后启用本地 build cache,再拆 phased commands;云端写缓存放到最后。
公共包先接入 rush change --verify 的只读门禁,经过数轮版本演练后再迁移真实发布凭证。达到连续基线一致后切换默认入口,保留一个短周期回滚窗口和旧流水线定义。
迁移的回滚单位应是能力层。若集中安装失败,可以恢复旧 lockfile 和旧安装入口;若 phase 图不稳定,可以保留 Rush 项目图但退回单一 build operation;若云缓存污染,可以切回 local-only 或关闭 build cache;若发布流程异常,可以继续使用 Rush 构建而把发布切回原有审批流水线。一次性删除旧脚本会把所有问题绑成整仓回滚。
多 lockfile 仓库迁入单一 Rush lockfile 时,依赖选择变化最危险。不要只看 rush update 成功,还要比较运行时依赖树、包体、许可证、原生模块平台和 peer dependency。若仓库规模或遗留技术栈确实无法在一个解空间内稳定安装,再评估 subspaces,而不是在迁移第一天就按团队拆成几十个 lockfile。
清理实验与退出 Rush
本地排障后可以清理 Rush 临时安装、链接和缓存:
rush purge
npm uninstall --global @microsoft/rush卸载全局 Rush 不影响仓库的 install-run-rush.js。不要删除受版本控制的 common/config/rush/pnpm-lock.yaml、repo-state.json、common/scripts/ 或项目配置;这些文件是可重复安装和 CI 启动的一部分。
若团队决定退出 Rush,先把 Rush 管理的临时 workspace 还原为目标包管理器认可的根 workspace,生成并审查新的 lockfile,再替换 CI、项目选择、缓存和发布入口。确认每个内部依赖仍使用正确 workspace 语义、所有项目脚本能在新调度器下运行、私有 registry 和发布 token 已迁移后,才删除 rush.json 与 common/。退出工具不等于退出治理:项目清单、依赖一致性、affected 传播、缓存信任、change file 和发布审批必须有新的明确 owner。
架构选型与长期治理
Rush 更适合项目数量多、共享工具链复杂、需要集中依赖策略和包发布治理的 TypeScript/JavaScript 仓库。它用显式配置换来可审计性,适合平台团队维护统一工程入口。只有少量应用、任务关系简单的仓库,原生 pnpm workspace 加轻量任务运行器可能成本更低;需要跨语言、远程执行和严格沙箱的超大代码库,则应把 Bazel 等系统纳入对比。选型不能只比较一次冷启动速度,还要比较迁移成本、配置 owner、缓存后端、发布模型、故障可诊断性和开发者学习曲线。
稳定运行后,团队至少维护这些规则:
rush.json、pnpm 配置、command-line、build cache 和 version policy 都有明确 owner 与强制评审。Rush、Node.js、pnpm 和 CI 镜像按独立升级 PR 演进,保留全量无缓存基线。新项目必须登记项目清单、声明正常 workspace 依赖、实现要求的 phases,并定义互不重叠的输出目录。
所有缓存输入都有证据;外部文件、环境变量和工具版本变化能触发正确失效。不安全 selector 不进入默认 CI,例外使用有可查询的原因和到期时间。PR 或低信任分支只读云缓存,主干写入发生在全量验证之后,写入身份可审计。
公共包变更有 change file,version policy 与发布 scope 一致,发布凭证与安装凭证隔离。定期比较增量与全量构建的项目集合、测试集合和制品摘要,用差异发现漏测而不是只追求耗时下降。
Rush 真正带来的效率,不是让每个命令都更短,而是让安装、构建、缓存和发布的因果链能够被重放。开发者知道该运行什么,CI 能解释为什么跳过,平台团队能限制谁可以写入共享状态,事故发生时也能从项目图、输入哈希、operation 日志和发布记录一路回溯。只有达到这个状态,Monorepo 的规模才从组织负担变成可治理的工程资产。
