Deno CI 体系详解:生成式 Workflow、pre-build 门控与 docs-only 快速通道
本文基于 Deno 仓库的 CI 说明文档,深入解析 Deno 主 CI 工作流的组织方式:workflow 为何由 ci.ts 脚本生成而非手写、pre-build 作业如何通过输出量(outputs)门控下游任务、纯文档 PR 如何走"只跑 lint"的快速通道,以及 ci-status 聚合作业如何充当分支保护规则的判定依据。读完本文,你将理解一套"生成式 CI + 廉价预检 + 按变更裁剪"的完整 CI 工程化方案。
一、工作流是生成的,不是手写的
Deno 的主 CI 工作流不是手工编写的 YAML。真正的源头(source of truth)是 ci.ts——一个用 Deno 编写的脚本,它构建 workflow 对象并写入 ci.generated.yml。生成的 YAML 文件第一行带有固定头部:
# GENERATED BY ./ci.ts -- DO NOT DIRECTLY EDIT
直接编辑 .yml 文件是无效的,改动一定会在下次重新生成时被覆盖。修改 ci.ts 后重新生成的命令是:
deno run -A .github/workflows/ci.ts
.github/workflows/ 目录下的其他工作流(npm_publish、node_compat_test、version_bump 等)都遵循同样的 *.ts → *.generated.yml 模式,每个目录内成对出现。
从源码结构看,ci.ts 使用了 jsr:@david/gagen 库提供的 createWorkflow、job、step、conditions、defineMatrix、defineExprObj 等构建块,以类型化、可组合的 DSL 方式描述 GitHub Actions 语法。脚本末尾的 generate() 函数负责序列化为 YAML 并附加"DO NOT DIRECTLY EDIT"头部;以主模块方式运行时则调用 workflow.writeOrLint() 写盘:
// .github/workflows/ci.ts
export function generate() {
return workflow.toYamlString({
header: "# GENERATED BY ./ci.ts -- DO NOT DIRECTLY EDIT",
});
}
if (import.meta.main) {
workflow.writeOrLint({
filePath: CI_YML_URL,
header: "# GENERATED BY ./ci.ts -- DO NOT DIRECTLY EDIT",
});
}
这种"代码生成 YAML"的方式带来两个好处:一是条件逻辑(if: 表达式、矩阵分片)在写的时候就经过 TypeScript 静态检查;二是生成的 YAML 会入库,评审者可以在 PR diff 里直接看到实际生效的 workflow 全文(当前约 7700 行),而不是让评审者去脑补 DSL 展开结果。此外,ci.ts 会把 actions/checkout、actions/cache 等第三方 action 按 SHA 钉死版本,生成的 YAML 中出现的是完整 commit 哈希而非 @v6 之类的标签,保证 CI 行为可复现。
值得一提的是,tools/lint.js 中的 ensureWorkflowYmlsUpToDate() 检查会在 lint 作业中校验所有生成的 .yml 是否与对应 .ts 的最新生成结果一致——即"忘记重新生成 YAML"的 PR 会在 CI 的 lint 阶段被直接拦下。
触发条件
生成的 ci 工作流监听三类事件(见 ci.generated.yml 头部):
push:main分支以及所有 tag(*);pull_request:opened、reopened、synchronize,以及ready_for_review——专门用于 PR 从 draft 转回正式状态时重新触发,因为 draft 阶段可能并未跑全所有步骤;concurrency:以github.head_ref(PR 分支名)分组并cancel-in-progress: true,同一分支的旧运行会被新 push 取消;带ci-test-flaky标签的运行改用run_id分组,避免互相取消。
二、Job 总览:一次 PR 会跑哪些作业
在 ci.ts 中,workflow 的 jobs 数组汇总了所有作业。一次 pull request 触发的流水线大致包含:
| 作业 | 作用 | 触发条件要点 |
|---|---|---|
pre_build |
快速预检,通过 outputs 决定下游是否运行 | 仅 PR 事件 |
build(每平台一组) |
编译 deno、denort、test_server 三个二进制 |
受 skip_build 与 docs_only 门控 |
test(每平台,分片矩阵) |
spec / unit / integration / node_compat 等测试 crate | 依赖 build,同样受门控 |
test-libs / build-libs |
各平台 workspace crate 的库测试与 wasm 兼容性 check | 受 docs_only 门控 |
wpt |
Web Platform Tests(仅 Linux release) | 受 docs_only 门控 |
bench |
基准测试 | 需 ci-bench 标签或 main 分支 |
deno-core-test |
libs/*(deno_core 系)crate 的 nextest 测试 |
额外受 skip_deno_core_test 门控 |
deno-core-miri |
deno_core 的 Miri 内存正确性测试 |
受 docs_only 门控 |
lint |
格式化、jsdoc 检查、tools/lint.js(Rust + JS) |
不受 docs_only 门控 |
ci-status |
聚合器,分支保护规则所依赖的检查 | status: always(),只要无依赖失败/取消即通过 |
publish-canary |
将 main 的最新 SHA 发布为 canary | 仅 denoland/deno 仓库的 main 分支 |
从 ci.ts 的源码可以看到,build 矩阵覆盖 macOS(x86_64 与 aarch64)、Windows(x86_64 与 arm64)、Linux(x86_64 与 aarch64),每个组合分 debug / release 两个 profile;标记 skip_pr: true 的 release 构建(如 macOS 和 ARM 的 release)在没有 ci-full 标签的 PR 上会被跳过,只在 main 分支和 tag 上运行,以控制 PR 的 CI 成本。测试矩阵方面,specs 与 integration 各分 2 片、node_compat 分 3 片(shardedCrates 映射),且 shard_index > 0 的分片只在 PR 上运行——main 分支不分片,一次跑全量。
三、pre-build 如何门控其余作业
ci.ts 中定义的 preBuildJob(pre_build)在 ubuntu-latest 上运行,且所有步骤仅在 pull_request 事件下执行。它做几件廉价检查,并把结果作为 job outputs 暴露给下游:
// .github/workflows/ci.ts(节选)
const preBuildJob = job("pre_build", {
name: "pre-build",
runsOn: "ubuntu-latest",
steps: step.if(isPr)(
cloneRepoStep,
installDenoStep,
step.if(conditions.isDraftPr())(preBuildCheckStep),
denoCoreChangesCheckStep,
docsOnlyChangesCheckStep,
),
outputs: {
skip_build: preBuildCheckStep.outputs.skip_build,
skip_deno_core_test: denoCoreChangesCheckStep.outputs.skip_deno_core_test,
docs_only: docsOnlyChangesCheckStep.outputs.docs_only,
},
});
3.1 skip_build:draft PR 默认不跑
preBuildCheckStep 只在"draft 且没有 ci-draft 标签"时执行。它读取最新提交标题,若其中不含 [ci] 字样,则写入 skip_build=true:
GIT_MESSAGE=$(git log --format=%s -n 1 ${{github.event.after}})
echo $GIT_MESSAGE | grep '\[ci\]' || (echo 'Exiting due to draft PR. Commit with [ci] to bypass or add the ci-draft label.' ; echo 'skip_build=true' >> $GITHUB_OUTPUT)
也就是说:draft PR 默认整体跳过构建/测试/lint/基准/deno_core 作业;作者想触发时,在提交标题里加 [ci] 即可。反之,给 PR 打上 ci-draft 标签会跳过整个检查步骤,让 draft PR 也完整跑 CI。
3.2 skip_deno_core_test:按变更范围裁剪 deno_core 测试
denoCoreChangesCheckStep 先 git fetch 到 PR base SHA(浅克隆下需要),然后运行 tools/check_deno_core_changes.js,以 git diff --name-only <base_sha>..HEAD 获取变更文件,判断是否命中下列目录之一或根 Cargo.toml / Cargo.lock:
libs/core_testing
libs/core
libs/core/examples/snapshot
libs/dcore
libs/ops
libs/ops/compile_test_runner
libs/serde_v8
只有未命中任何一项时才输出 skip_deno_core_test=true,从而跳过 deno-core-test 作业。脚本对失败采取保守策略:git diff 出错时输出 skip=false(即"拿不准就跑全量")。注意 ci.ts 中维护的 denoCorePackageDirs 与脚本内的 DENO_CORE_PACKAGE_DIRS 是同一份清单,二者需保持一致。
3.3 docs_only:纯文档 PR 的快速通道开关
docsOnlyChangesCheckStep 运行 tools/check_docs_only_changes.js,其核心逻辑非常简洁:
// tools/check_docs_only_changes.js
const DOCS_DIR = "doc/";
// ... git diff --name-only `${baseSha}..HEAD` ...
// Only treat the PR as docs-only when there is at least one changed file and
// every changed file lives under `doc/`. An empty diff defaults to running the
// full CI to be safe.
const docsOnly = changedFiles.length > 0 &&
changedFiles.every((file) => file.startsWith(DOCS_DIR));
两个细节值得注意:
- 空 diff 默认为
false。没有任何变更文件(或 diff 执行失败)时,脚本都会写docs_only=false让完整流水线运行——"宁可多跑,不可漏跑"。 - base SHA 无需额外 fetch,上一步
deno_core_changes已经git fetch --depth=1 origin <base.sha>,两个检查脚本共享这一次网络往返。
3.4 下游作业的 if: 条件
被门控的作业把相关 outputs 组合进各自的 if:。以 build 作业为例,ci.ts 中的写法是:
if: preBuildJob.outputs.skip_build.notEquals("true").and(notDocsOnly),
其中 notDocsOnly 定义为:
// Jobs that compile or test code should not run when a PR only edits docs.
const notDocsOnly = preBuildJob.outputs.docs_only.notEquals("true");
生成到 ci.generated.yml 后,即为文档中给出的标准形式:
if: needs.pre_build.outputs.skip_build != 'true' && needs.pre_build.outputs.docs_only != 'true'
各作业的门控差异一览(均可在 ci.ts 中逐行核对):
build/build-libs/test(经needs: build级联)/bench/deno-core-test/deno-core-miri:skip_build != 'true'且docs_only != 'true';deno-core-test额外多一条skip_deno_core_test != 'true';lint:只看skip_build,不看docs_only——这是快速通道的设计关键(见下一节);bench另有一层独立条件:所有步骤仅在有ci-bench标签或运行于 main 分支时执行((hasCiBenchLabel.or(isMainBranch)).and(isNotTag)),普通 PR 即使作业"跑起来",步骤也整体跳过。
四、docs-only 快速通道的工作原理
一个只改动 doc/ 目录下文件的 PR 不需要编译任何东西,也不需要跑任何测试。对这类 PR,CI 的净效果是"跑 lint,其余全跳过"。完整机制分四步:
-
判定。
pre-build中,tools/check_docs_only_changes.js将 PR 与其 base SHA 做 diff。若变更文件非空且全部位于doc/之下,则向$GITHUB_OUTPUT写入docs_only=true;diff 为空或执行失败时默认false,保证拿不准时走完整流水线。 -
裁剪。
build、build-libs、test、bench、deno-core-test、deno-core-miri的作业级if:均包含&& docs_only != 'true',在纯文档 PR 上被跳过。test作业依赖build(needs: buildJob),上游跳过会自然级联到下游。 -
保留 lint。
lint作业故意不受docs_only门控,纯文档 PR 上它照跑不误。其步骤包括(Linux 节点上):deno run ... ./tools/format.js --check—— 覆盖整个仓库(含doc/)的格式化检查,即deno fmt --check语义,保证 Markdown 排版规范;deno run ... ./tools/jsdoc_checker.js—— JSDoc 标签检查;deno run ... ./tools/lint.js—— 同时做 Rust 侧(clippy 等)与 JS 侧(deno lint、bootstrap 检查、workflow YAML 同步性等)检查。
这就是文档所说的"lint and nothing else":文档 PR 仍须通过格式与 lint 关卡,但省去了最昂贵的编译与测试。
-
状态灯仍会绿。
ci-status依旧运行并通过(见下节):被跳过的依赖既不算失败也不算取消,lint成功,分支保护要求的 status check 即变绿,PR 可合并。
一旦 PR 中有任何改动落在 doc/ 之外,docs_only 即为 false,全量流水线照常执行。
五、ci-status:分支保护依赖的聚合器
ci-status 作业(ciStatusJob)是整条流水线的"汇总灯"。它的 needs 显式列出了所有需要在 PR 上通过的作业:
// .github/workflows/ci.ts(节选)
const ciStatusJob = job("ci-status", {
name: "ci status",
// We use this job in the main branch rule status checks for PRs.
// All jobs that are required to pass on a PR should be listed here.
needs: [
benchJob,
...buildJobs.map((j) => [j.buildJob, ...j.additionalJobs]).flat(),
lintJob,
denoCoreTestJob,
denoCoreMiriJob,
],
if: preBuildJob.outputs.skip_build.notEquals("true")
.and(conditions.status.always()),
runsOn: "ubuntu-latest",
steps: step({
name: "Ensure CI success",
run: [
"if [[ \"${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}\" == \"true\" ]]; then",
" echo 'CI failed'",
" exit 1",
"fi",
],
}),
});
关键设计有两点:
if中组合status: always():即使上游有作业失败,ci-status自身也要运行,这样它才能把失败"翻译"成一个确定失败的 status check,而不是让分支保护规则面对一堆零散的作业状态。- 判定规则是"无 failure 且无 cancelled 即通过":被跳过(skipped)的依赖不计入失败。这正是 docs-only PR 能顺利合并的原因——build/test/bench 等全部 skipped,但
lint成功,ci-status判定通过。
六、支撑细节:缓存、分片与生成器约束
虽然与文档主线不直接相关,但以下实现细节解释了为什么 Deno 敢用"生成 + 门控"来组织如此大的 CI:
- 缓存版本化。
ci.ts顶部维护一个cacheVersion = 124,作为所有缓存 key 的前缀(124-cargo-home-...、124-cargo-target-...),需要作废全部缓存时只需 bump 该数字;注释还说明发布脚本tools/release/01_bump_crate_versions.ts会以正则自动更新它。PR 只恢复缓存不保存,main 分支每次运行都强制保存新缓存(key 为${prefix}-${github.sha}),保证 PR 总能拿到最新构建产物。 - 测试分片。
ci.ts的shardedCrates将specs、integration各分 2 片、node_compat分 3 片,分片索引/总数通过CI_SHARD_INDEX/CI_SHARD_TOTAL环境变量注入测试 harness;failFast: false保证单片失败不中止其他分片。 - 生成器的结构性约束。
resolveTestCrateTests()/resolveWorkspaceCrates()在生成 YAML 时就解析根 Cargo.toml 的 workspace members:前者只把显式关闭autotests且声明了[[test]]的tests/*crate 纳入测试矩阵,后者为test-libs作业收集cargo test --lib -p ...的包列表,并对"在非 tests/ 的 crate 里放集成测试"直接抛错(因为构建与测试分布在不同 runner 上,这类测试不会被 CI 执行)。这类约束把"测试放错地方"的错误从 CI 运行期提前到了 workflow 生成期。
七、如何修改 CI 行为
当需要调整"哪些作业运行"或"新增一道门控"时,正确的流程是:
- 编辑 .github/workflows/ci.ts(而非任何
.generated.yml); - 运行
deno run -A .github/workflows/ci.ts重新生成 YAML; - 两个文件一起提交。生成文件入库的目的,就是让评审者能在 PR diff 中直接看到实际生效的 workflow;同时
tools/lint.js的ensureWorkflowYmlsUpToDate()检查会兜底拦截"漏生成"的提交。
新增门控输出时,可参照 pre-build 的既有模式:写一个输出到 $GITHUB_OUTPUT 的 Deno 脚本(如 check_docs_only_changes.js),在 denoCoreChangesCheckStep 之后注册一个 step 并声明 outputs,再把对应条件以 notEquals("true") 的形式并入目标作业的 if:。所有判断失败路径都应默认"跑全量",这与仓库中现有脚本的保守策略保持一致。
小结
Deno 的 CI 组织可以概括为三层:生成式定义(ci.ts 以类型化 DSL 生成并入库 YAML,配合 lint 校验同步性)、廉价预检门控(pre-build 用三个 outputs——skip_build、skip_deno_core_test、docs_only——按 PR 形态裁剪昂贵作业)、聚合状态判定(ci-status 以 always() + "无 failure/cancelled 即通过"充当分支保护的单一检查点)。三者叠加,使纯文档 PR 只付出一次 lint 的成本,而 draft PR、deno_core 无关 PR 也都能省掉对应的大头开销。
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 StartedRust0623
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