Angular adev 仓库如何自动同步 Aria、CDK 与 CLI 文档资产:update-cross-repo-docs 脚本解析
本文围绕 adev/scripts/update-cross-repo-docs 目录下的跨仓库文档资产更新脚本展开,说明它如何从 angular/aria-builds、angular/cdk-builds、angular/cli-builds 三个下游构建仓库拉取 JSON 文档资产,落地到 adev(即 angular.dev 站点)的内容目录中。读完后你将理解该脚本的完整工作流:分支与 SHA 的版本比对、增量变更检测、临时仓库克隆与文件替换策略,以及 _build-info.json 作为同步状态锚点的设计原理。
一、脚本职责:为 angular.dev 维护三类跨仓库文档资产
根据 README 的描述,该脚本负责从其他仓库更新 angular.dev 所需的文档资产,覆盖三类内容:
| 资产类型 | 来源仓库 | 资产子路径 | 落地目录 | 用途 |
|---|---|---|---|---|
| Aria API JSON | angular/aria-builds |
_adev_assets |
adev/src/content/aria |
angular.dev 的 API 文档页 |
| CDK API JSON | angular/cdk-builds |
_adev_assets |
adev/src/content/cdk |
angular.dev 的 API 文档页 |
| CLI Help JSON | angular/cli-builds |
help |
adev/src/content/cli |
angular.dev 的 CLI 帮助页 |
这三个目录中存放的实际内容可在仓库中直接看到:
- adev/src/content/aria 下包含
aria-accordion.json、aria-combobox.json、aria-grid.json、aria-listbox.json、aria-menu.json、aria-tabs.json、aria-toolbar.json、aria-tree.json等逐组件的 API JSON 文件; - adev/src/content/cdk 下包含
cdk_drag_drop.json、cdk_testing.json、cdk_testing_protractor.json等 CDK 包的 API JSON 文件; - adev/src/content/cli 下包含
build.json、serve.json、generate.json、update.json、extract-i18n.json等与 CLI 子命令一一对应的帮助 JSON 文件。
每个目录中还存在一个同名的 _build-info.json 文件,它是整个同步机制的版本锚点,后文会详细展开。
二、主入口:三个仓库的批量更新编排
主入口文件 index.mjs 的逻辑非常直接——依次对三个下游仓库调用同一个 updateAssets 函数:
async function main() {
await updateAssets({
repo: 'angular/aria-builds',
assetsPath: '_adev_assets',
destPath: join(import.meta.dirname, '../../src/content/aria'),
});
await updateAssets({
repo: 'angular/cdk-builds',
assetsPath: '_adev_assets',
destPath: join(import.meta.dirname, '../../src/content/cdk'),
});
await updateAssets({
repo: 'angular/cli-builds',
assetsPath: 'help',
destPath: join(import.meta.dirname, '../../src/content/cli'),
});
console.log('\n-----------------------------------------------');
console.log('Change list');
console.log('-----------------------------------------------\n');
execSync(`git status --porcelain`, {stdio: 'inherit'});
}
要点说明:
- 参数三元组:
updateAssets接收repo(下游 GitHub 仓库全名)、assetsPath(下游仓库中资产所在的子路径)、destPath(本仓库中的落地目录)。注意 Aria 与 CDK 仓库的资产都放在_adev_assets目录下,而 CLI 仓库放在help目录下,这与三个构建仓库各自不同的产物布局相对应。 import.meta.dirname:入口脚本用 Node.js 的 ESM 内置变量import.meta.dirname定位自身目录,再以相对路径../../src/content/...计算目标目录,保证无论从工作目录何处执行,落地路径都锚定在仓库内的adev/src/content/下。- 变更清单输出:全部更新完成后,脚本执行
git status --porcelain打印改动文件清单(第 35 行),方便 CI 环境或人工快速核对本次同步实际改动了哪些文件。 - 失败即退出:
main().catch(...)中捕获异常后process.exit(1),确保任何一个仓库更新失败时整个任务以非零状态码结束,便于流水线感知失败。
三、核心流程:updateAssets 的版本锚点与增量同步
真正的同步逻辑全部实现在 update-assets.mjs 中的 updateAssets 函数。整个流程可以拆解为六个阶段。
3.1 读取并校验 _build-info.json
const buildInfoPath = join(destPath, '_build-info.json');
if (!existsSync(buildInfoPath)) {
throw new Error(`${buildInfoPath} does not exist.`);
}
assert(process.env.GITHUB_REF);
const currentBranch = process.env.GITHUB_REF;
const {sha: storedSha, branchName: storedBranch} = JSON.parse(
await readFile(buildInfoPath, 'utf-8'),
);
每个目标目录中的 _build-info.json 记录了“上一次成功同步时对应的下游分支与提交”,例如当前仓库中 adev/src/content/aria/_build-info.json 的内容为:
{
"branchName": "refs/heads/main",
"sha": "d2a5d8578e039301b04c1d28f09d90e73cfd36d8"
}
该文件在每次同步结束时被写回(见 3.7 节),因此它既充当增量比对的基线,也充当同步状态的水位线。脚本首先要求该文件存在,然后从环境变量 GITHUB_REF 读取当前运行所在的分支引用(这要求脚本在具备 GitHub Actions 环境的 CI 中运行),并校验其中存储的 SHA 与分支名格式:
shaRegex = /^[0-9a-f]{40}$/i:40 位十六进制的完整提交哈希;branchRegex = /^(?!.*\.\.)[a-zA-Z0-9/_.-]+$/:合法的分支名,且显式排除包含..的输入(防止路径/命令注入类风险)。
3.2 解析下游最新 SHA 及分支回退策略
assert(process.env.ANGULAR_READONLY_GITHUB_TOKEN);
const githubApi = new GithubClient(
repo,
process.env.ANGULAR_READONLY_GITHUB_TOKEN,
'ADEV_Cross_Repo_Docs_Update',
);
let downstreamBranch = currentBranch;
let latestSha = await githubApi.getShaForBranch(currentBranch);
if (
latestSha.includes('No commit') &&
currentBranch !== 'refs/heads/main' &&
currentBranch !== storedBranch
) {
// 某些情况下(例如为特性新建分支时),
// 该分支可能不存在于下游仓库。
// 例如异常的 minor 发布(FW 20.3.x 对 Components: 20.2.x)。
// 此时回退到最后一次已知的分支。
latestSha = await githubApi.getShaForBranch(storedBranch);
downstreamBranch = storedBranch;
}
这里的回退逻辑值得注意:Angular 框架(FW)与 Components/CDK 等仓库的分支名并不总是对齐的。当本仓库处于一个特性分支(如 FW 的 20.3.x 维护分支),而下游仓库根本没有同名分支时,查询会返回 “No commit” 之类的占位结果。此时脚本不再强求同名分支,而是回退到 _build-info.json 中记录的 storedBranch(上一次成功同步的分支)取最新 SHA,并把本次比对所用的分支记录为 downstreamBranch。这保证在主干之外的维护线/特性线上同步不会直接失败。
3.3 通过 GitHub API 比对两个 SHA 之间的变更文件
console.log(`Comparing ${storedSha}...${latestSha}.`);
const affectedFiles = await githubApi.getAffectedFiles(storedSha, latestSha);
const changedFiles = affectedFiles.filter((file) => file.startsWith(`${assetsPath}/`));
getAffectedFiles 调用 GitHub REST API 的 compare 端点,返回 storedSha 到 latestSha 之间所有发生变化的文件名,脚本再按 assetsPath/ 前缀过滤,只关心资产目录内的变更。这一步决定了“是否需要执行文件替换”——如果资产目录没有任何变化,脚本只输出一行日志并直接跳到写回水位线,不做任何文件操作。
3.4 在临时 Git 仓库中检出资产并确定“文件实际变更的 SHA”
当检测到资产有变更时,脚本在系统临时目录中初始化一个一次性 Git 仓库来取文件,而不是把下游仓库整体克隆进工作区:
const temporaryDir = await realpath(await mkdtemp(join(tmpdir(), 'update-assets-')));
try {
const execOptions = {cwd: temporaryDir, stdio: 'inherit'};
execFileSync('git', ['init'], execOptions);
execFileSync('git', ['remote', 'add', 'origin', `https://github.com/${repo}.git`], execOptions);
// fetch a commit
execFileSync('git', ['fetch', 'origin', latestSha], execOptions);
// reset this repository's main branch to the commit of interest
execFileSync('git', ['reset', '--hard', 'FETCH_HEAD'], execOptions);
// get sha when files where changed
shaWhenFilesChanged = execFileSync('git', ['rev-list', '-1', latestSha, `${assetsPath}/`], {
encoding: 'utf8',
cwd: temporaryDir,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
// ...
} finally {
await rm(temporaryDir, {force: true, recursive: true});
}
流程要点:
mkdtemp创建临时目录并realpath解析:先realpath是为了消除 macOS 上/var与/private/var这类符号链接差异带来的路径不一致问题(mkdtemp返回的路径可能与git内部使用的工作目录不一致,进而引发 cwd 校验失败)。- 浅取目标提交:
git fetch origin <latestSha>只拉取指定提交而非完整历史,配合后续的git reset --hard FETCH_HEAD,把临时仓库的主干定位到目标 SHA 上。 git rev-list -1 <latestSha> <assetsPath>/:这是整个流程里最巧妙的一步——它找出“最近一次实际改动过assetsPath/目录下文件的提交”。用这个 SHA(记为shaWhenFilesChanged)而非latestSha作为写回水位线,可以让水位线精确对应“内容真正发生过变化的那一次提交”。从源码结构看,这样即使下游仓库在资产目录之外持续有大量提交,_build-info.json里的 SHA 也始终指向与已落地资产内容严格对应的版本,比对窗口更小、日志更可解释。- finally 保证清理:无论后续复制是否成功,临时目录都会被
rm -rf删除,不污染系统。
3.5 先删后拷的原子化文件替换
替换落地目录中的 JSON 文件采用“先全部删除旧文件,再全部复制新文件”的策略:
// Delete existing asset files.
const apiFilesUnlink = (await readdir(destPath))
.filter((f) => f.endsWith('.json'))
.map((f) => unlink(join(destPath, f)));
await Promise.allSettled(apiFilesUnlink);
// Copy new asset files
const tempAssetsDir = join(temporaryDir, assetsPath);
const assetFilesCopy = (await readdir(tempAssetsDir)).map((f) => {
const src = join(tempAssetsDir, f);
const dest = join(destPath, f);
return copyFile(src, dest, fsConstants.COPYFILE_FICLONE);
});
await Promise.allSettled(assetFilesCopy);
两个细节值得说明:
_build-info.json不会被误删:删除逻辑只针对destPath目录下已存在的.json文件。虽然_build-info.json也是.json,但它随后会被重写(见 3.7 节),且 adev/src/content/cli/BUILD.bazel 中的filegroup明确将其从资产 glob 中排除(注释写明 “Exclude _build-info.json as it is not a help entry”),因此它不参与文档渲染,只承担水位线职责。COPYFILE_FICLONE拷贝模式:copyFile(src, dest, fsConstants.COPYFILE_FICLONE)优先走文件系统的 clone(如 reflink),在同盘复制时几乎是零拷贝;不支持时回退为普通复制。对每个资产文件做并行拷贝,用Promise.allSettled而非Promise.all,避免单个文件失败导致剩余任务被取消。
3.6 无变更路径
} else {
console.log(`No '${assetsPath}/**' files changed between ${storedSha} and ${latestSha}.`);
}
若两个 SHA 之间资产目录无变化,脚本不做任何文件替换,只打印一条明确的“无变更”日志,然后继续执行水位线写回。
3.7 写回 _build-info.json
// Write SHA to file.
await writeFile(
buildInfoPath,
JSON.stringify(
{
branchName: downstreamBranch,
sha: shaWhenFilesChanged ?? storedSha,
},
undefined,
2,
),
);
写回规则:
branchName取本次实际用于解析 SHA 的分支downstreamBranch(正常情况即当前分支,回退情况下是storedBranch);sha取“文件实际变更的提交”shaWhenFilesChanged;若本次没有变更则为undefined,用空值合并回退到storedSha。
这样 _build-info.json 的语义始终自洽:它所指向的下游版本,就是本目录中已落地资产内容的来源版本。
四、GithubClient:基于 Node.js 内置 https 的最小 GitHub API 客户端
github-client.mjs 没有引入任何第三方 SDK,完全基于 node:https 手写,提供了两个能力:
const GITHUB_API = 'https://api.github.com/repos/';
const SHA_REGEX = /^[0-9a-f]{40}$/i;
const BRANCH_REGEX = /^(?!.*\.\.)[a-zA-Z0-9/_.-]+$/;
export class GithubClient {
constructor(repo, token, ua) {
this.#token = token;
this.#ua = ua;
this.#api = posix.join(GITHUB_API, repo);
}
async getAffectedFiles(baseSha, headSha) { /* ... */ }
async getShaForBranch(branch) { /* ... */ }
}
getShaForBranch(branch):请求GET /repos/{owner}/{repo}/commits/{branch},并通过请求头Accept: application/vnd.github.VERSION.sha让 GitHub 直接返回纯 SHA 文本(而非完整提交 JSON),减少解析成本;分支名先经BRANCH_REGEX校验,防止非法分支名拼入 URL。getAffectedFiles(baseSha, headSha):请求GET /repos/{owner}/{repo}/compare/{baseSha}...{headSha},解析响应中的files数组并提取filename列表;两个 SHA 都先做 40 位十六进制校验。#httpGet私有方法:统一注入Authorization: token <token>与User-Agent请求头。User-Agent 是 GitHub REST API 的强制要求,脚本将其固定为ADEV_Cross_Repo_Docs_Update,便于在 API 审计中识别该调用方。- 凭据使用只读 Token:
update-assets.mjs中使用的环境变量是ANGULAR_READONLY_GITHUB_TOKEN,即整个同步过程只需要下游仓库的只读访问权,最小化凭据权限面。
五、落地资产如何被 adev 站点管线消费
这些 JSON 并不是终点。从 adev/src/assets/BUILD.bazel 的构建配置可以看到,三个目录的资产被分别作为 Bazel 依赖注入到 adev 站点的构建产物中:
"adev/src/content/cli/cli_docs_html": "cli/",
"adev/src/content/aria/aria_docs_html": "api/",
"adev/src/content/cdk/cdk_docs_html": "api/",
即 CLI 帮助 JSON 渲染后的 HTML 落到站点的 cli/ 路由,Aria 与 CDK 的 API JSON 渲染后落到 api/ 路由。以 adev/src/content/cli/BUILD.bazel 为例,其规则为:
load("//adev/shared-docs/pipeline/api-gen/rendering:render_api_to_html.bzl", "render_api_to_html")
package(default_visibility = ["//visibility:public"])
exports_files(["_build-info.json"])
filegroup(
name = "cli",
srcs = glob(
["*.json"],
exclude = [
# Exclude _build-info.json as it is not a help entry.
"_build-info.json",
],
),
)
render_api_to_html(
name = "cli_docs",
srcs = [
":cli",
],
)
这印证了两点:_build-info.json 被 exports_files 显式导出但不进入 render_api_to_html 的输入(仅作为同步元数据被脚本读取);渲染规则来自 adev/shared-docs/pipeline/api-gen/rendering,说明 JSON 到 HTML 的转换是 adev 共享文档管线的一部分。也就是说,本脚本保证“数据新鲜”,Bazel 管线保证“数据可渲染”,二者职责分离。
六、运行前提与操作方式
综合 index.mjs 与 update-assets.mjs 的断言与调用,运行该脚本需要满足以下前提:
- 环境变量
GITHUB_REF:指明当前运行所在的分支引用(如refs/heads/main)。脚本assert该变量存在,说明它设计为在 GitHub Actions 等 CI 环境中运行,而不是本地手工裸跑。 - 环境变量
ANGULAR_READONLY_GITHUB_TOKEN:一个具备目标仓库(angular/aria-builds、angular/cdk-builds、angular/cli-builds)只读访问权的 GitHub Token,用于调用 compare 与 commits 两个 REST 端点。 - Git 可用:脚本依赖系统 PATH 中的
git命令完成临时仓库的init/fetch/reset/rev-list操作。 - 三个目标目录均已有
_build-info.json:缺失会直接抛错终止,因为水位线是增量比对的基础。
满足上述条件后,脚本执行方式为直接以 Node.js 运行入口文件(脚本内部无额外构建步骤):
node adev/scripts/update-cross-repo-docs/index.mjs
执行后控制台会依次输出三个仓库的处理段落(Processing: <repo>)、比对区间(Comparing <storedSha>...<latestSha>)、变更文件列表或“无变更”提示,最后以 git status --porcelain 汇总本次在 adev 仓库内产生的全部文件变更,便于在 PR 中直接审阅。
七、小结
update-cross-repo-docs 脚本是 angular.dev 站点保持文档资产与上游构建产物同步的核心机制。它的工程价值体现在几处细节设计上:
- 水位线锚定内容变更:
_build-info.json记录的不是“上次检查时的分支头”,而是“资产内容实际发生变更的提交”(git rev-list -1求解),使增量比对窗口始终最小、可解释; - 分支不对称容错:针对 Angular 各仓库分支名不对齐的场景(如异常 minor 发布周期),提供基于历史水位线的分支回退,避免同步任务在维护分支上直接失败;
- 隔离式文件获取:用临时目录 + 单提交 fetch 的方式取文件,不向工作区引入下游仓库的完整历史;
- 最小权限与零依赖 API 客户端:只读 Token +
node:https手写客户端,把攻击面与依赖面都压到最低。
对于维护 adev 站点内容的开发者而言,当 adev/src/content/aria、adev/src/content/cdk 或 adev/src/content/cli 下的 JSON 资产需要刷新时,理解这套“水位线 + 增量比对 + 临时检出 + 先删后拷”的流程,是排查同步失败(如 SHA 校验报错、分支回退日志)与确认资产版本来源的关键。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00