首页
/ Deno CI 体系详解:生成式 Workflow、pre-build 门控与 docs-only 快速通道

Deno CI 体系详解:生成式 Workflow、pre-build 门控与 docs-only 快速通道

2026-09-05 18:04:46作者:邓越浪Henry

本文基于 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_publishnode_compat_testversion_bump 等)都遵循同样的 *.ts*.generated.yml 模式,每个目录内成对出现。

从源码结构看,ci.ts 使用了 jsr:@david/gagen 库提供的 createWorkflowjobstepconditionsdefineMatrixdefineExprObj 等构建块,以类型化、可组合的 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/checkoutactions/cache 等第三方 action 按 SHA 钉死版本,生成的 YAML 中出现的是完整 commit 哈希而非 @v6 之类的标签,保证 CI 行为可复现。

值得一提的是,tools/lint.js 中的 ensureWorkflowYmlsUpToDate() 检查会在 lint 作业中校验所有生成的 .yml 是否与对应 .ts 的最新生成结果一致——即"忘记重新生成 YAML"的 PR 会在 CI 的 lint 阶段被直接拦下。

触发条件

生成的 ci 工作流监听三类事件(见 ci.generated.yml 头部):

  • pushmain 分支以及所有 tag(*);
  • pull_requestopenedreopenedsynchronize,以及 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(每平台一组) 编译 denodenorttest_server 三个二进制 skip_builddocs_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 成本。测试矩阵方面,specsintegration 各分 2 片、node_compat 分 3 片(shardedCrates 映射),且 shard_index > 0 的分片只在 PR 上运行——main 分支不分片,一次跑全量。

三、pre-build 如何门控其余作业

ci.ts 中定义的 preBuildJobpre_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 测试

denoCoreChangesCheckStepgit 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));

两个细节值得注意:

  1. 空 diff 默认为 false。没有任何变更文件(或 diff 执行失败)时,脚本都会写 docs_only=false 让完整流水线运行——"宁可多跑,不可漏跑"。
  2. 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-miriskip_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,其余全跳过"。完整机制分四步:

  1. 判定pre-build 中,tools/check_docs_only_changes.js 将 PR 与其 base SHA 做 diff。若变更文件非空且全部位于 doc/ 之下,则向 $GITHUB_OUTPUT 写入 docs_only=true;diff 为空或执行失败时默认 false,保证拿不准时走完整流水线。

  2. 裁剪buildbuild-libstestbenchdeno-core-testdeno-core-miri 的作业级 if: 均包含 && docs_only != 'true',在纯文档 PR 上被跳过。test 作业依赖 buildneeds: buildJob),上游跳过会自然级联到下游。

  3. 保留 lintlint 作业故意不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 关卡,但省去了最昂贵的编译与测试。

  4. 状态灯仍会绿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.tsshardedCratesspecsintegration 各分 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 行为

当需要调整"哪些作业运行"或"新增一道门控"时,正确的流程是:

  1. 编辑 .github/workflows/ci.ts(而非任何 .generated.yml);
  2. 运行 deno run -A .github/workflows/ci.ts 重新生成 YAML;
  3. 两个文件一起提交。生成文件入库的目的,就是让评审者能在 PR diff 中直接看到实际生效的 workflow;同时 tools/lint.jsensureWorkflowYmlsUpToDate() 检查会兜底拦截"漏生成"的提交。

新增门控输出时,可参照 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_buildskip_deno_core_testdocs_only——按 PR 形态裁剪昂贵作业)、聚合状态判定ci-statusalways() + "无 failure/cancelled 即通过"充当分支保护的单一检查点)。三者叠加,使纯文档 PR 只付出一次 lint 的成本,而 draft PR、deno_core 无关 PR 也都能省掉对应的大头开销。

登录后查看全文
热门项目推荐
相关项目推荐