我理想中的 Rust 工作流:从代码编辑到自动发布的全链路实践

7 阅读13分钟

本文是对My ideal Rust workflow 的整理与翻译

写 Rust 代码本身当然很有意思。但更有意思的是什么?是持续测试、持续发布、最终把 Rust 代码交付到生产环境的整套流程。而要做到这一点,光靠一个编辑器插件是远远不够的。

我们需要的是一套完整的工作流。


一、为什么我格外在意这件事

在解释具体方案之前,我想先说清楚背景——为什么我愿意花大量时间在工具链上?

我在 Patreon 上写技术文章,系列文章动辄十几二十篇。当我开始写"可执行文件打包"这个系列时,nom crate 还在 5.x 版本。系列还没写完,nom 6.x 就出来了,API 有不兼容的变化,很多刚开始跟着系列学习的读者直接遇到编译错误。系列刚收尾,nom 7.x 又出来了。

更新一个 18 篇的系列,基本上要从头再来一遍:升级 crate 版本、更新错误信息、更新终端输出、重新截图……这些不是写新内容,是纯粹的维护成本。而随着我写的系列越来越多,维护老内容的时间会吞噬掉创作新内容的时间。这是我不能接受的结果。

我的解决思路是:让 Markdown 源文件成为系列内容的唯一真实来源,所有 Rust 代码文件、终端会话、甚至截图,都作为"构建产物"由内容管道从 Markdown 自动生成。这样,每次工具版本变化,我只需要更新 Markdown 源文件,其余内容自动重新生成。

这是一个巨大的工程。而要让它落地,我需要把工作拆成小步骤,并且用上我所知道的最好的工具和最佳实践——这正是这篇文章的主题。


二、代码编辑器

我长期使用 Visual Studio Code,尽管我开玩笑地称它为"没人最喜欢的代码编辑器"。我在里面装了 Vim 键位,模拟效果虽然不完美,但 VS Code 提供了几个对我来说不可替代的能力:

  • rust-analyzer 等语言服务器的一流支持
  • 可用的 Markdown 预览
  • 不错的 Git 图形界面
  • 支持 Dev Containers、SSH 远程、WSL 远程等
  • 集成终端,支持分屏
  • 强大的全局搜索与替换(支持正则)
  • Error Lens、Git Lens 等实用扩展

键盘快捷键我做了深度定制,比如 t j 切换标签页、meta g 跳转到定义,几乎不需要用鼠标。

如果你想参考我的具体配置,可以在 GitHub Gist 上找到我的 settings.json


三、仓库管理

我所有与网站相关的仓库都放在 GitHub 的 bearcove 组织下,大多数是私有仓库。

关于开源这件事,我想说清楚:为自己开发软件,和为整个社区维护开源项目,是两件截然不同的事情。前者你可以完全按自己的判断推进,后者意味着无休止的 PR review、方向讨论、兼容性争议。在这里我选择了后者的反面——保持私有。

这个选择其实也给这篇文章带来了一个有趣的约束:我必须展示不依赖各平台"开源免费"福利的情况下,如何把成本控制在合理范围内,这对中小型团队同样适用。

GitHub 的免费计划现在已经支持无限数量的私有/公开仓库,以及无限的私有仓库协作者,对我目前的规模完全够用。

每个仓库都应该有一个 README.md,简明说明:这个仓库为什么存在、它依赖什么、它如何构建、它在哪里被使用。


四、构建、检查、测试与 Lint

4.1 锁定工具链版本

第一步,也是投入产出比最高的一步:在每个 Rust 仓库根目录添加 rust-toolchain.toml 文件。

[toolchain]
channel = "1.56.0"
components = [ "rustfmt", "clippy" ]

有了这个文件,只要安装了 rustupcargo 等命令就会自动使用指定版本。如果该版本尚未安装,rustup 会无缝下载安装后再运行。

对于可执行文件(bin crate),Cargo.lock 应该提交到版本控制,且在所有 cargo installcargo check 等命令中尽量使用 --locked 参数。库 crate(lib crate)则不建议提交 Cargo.lock

4.2 用 Clippy 替代 cargo check

我从不用 cargo checkcargo clippy 做了所有 check 该做的事,还额外做了更多。

在 VS Code 中,设置:

"rust-analyzer.checkOnSave.command": "clippy"

在终端中:

cargo clippy --locked -- -D warnings

-D warnings 的作用是把所有警告升级为错误。在我的代码编辑器里,警告仍然是警告(颜色提示),但在终端和 CI 中,我只接受零警告的构建结果。

需要注意:用 RUSTFLAGS 环境变量来禁用警告会覆盖 .cargo/config.toml 中的 rustflags 配置。升级 Rust 版本时会出现新的警告,这时 cargo fixcargo clippy --fix 可以批量自动修复大部分问题。

4.3 检查所有 feature 组合

如果你的仓库是 cargo workspace,包含多个 crate,且各 crate 有不同的 cargo feature,那么只检查"所有 feature"或"默认 feature"是不够的。

我用 cargo-hack 来检查所有 feature 的幂集(powerset):

cargo hack --feature-powerset --exclude-no-default-features clippy --locked -- -D warnings

4.4 用 just 管理命令

上面的命令有点长,每次都手打当然不现实。我用 just 作为命令运行器。Justfile 看起来有点像 Makefile,但没有隐式规则、没有依赖追踪、没有奇怪的 GNU 语法:

# just manual: https://github.com/casey/just/#readme

_default:
    @just --list

# Runs clippy on the sources
check:
  cargo clippy --locked -- -D warnings

# Runs unit tests
test:
  cargo test --locked

运行 just 就能看到所有可用命令列表:

$ just
Available recipes:
    check # Runs clippy on the sources
    test  # Runs unit tests

在多仓库结构下,还可以用 just futile/checkjust salvage/check 这样的方式跨目录调用。这些 recipe 在本地和 CI 中都能用,接口统一。

just 还支持 .env 文件、变量、内置函数、条件判断,甚至可以嵌入 JavaScript,整体上比 GNU make 更符合直觉。

4.5 Cargo.toml 配置优化

使用 v2 resolver

对于 workspace,v2 resolver 修复了 v1 存在的许多微妙问题:

[workspace]
members = [...]
resolver = "2"  # 注意,是字符串

Rust 2021 edition 默认使用 v2 resolver,如果还在用 2015 或 2018 edition,建议显式声明。

本地 release 构建的配置

如果需要在生产环境调试或性能分析,需要保留调试信息。debug = 2 太重,debug = 1 对大多数场景足够:

[profile.release]
debug = 1
incremental = true
lto = "off"

这三个设置一起可以显著缩短本地 release 构建时间:ThinLTO 比完整 LTO 快,但仍比关闭 LTO 慢;增量编译减少了"优化机会",但加速了重新构建。

链接器优化

2021 年的 GNU ld 并不是最快的链接器。最快的是 mold,但当时还处于早期阶段,支持的平台和特性有限。LLVM 的 lld 是一个折中选择:比 GNU ld 快,跨平台支持好,从未出现过让我归咎于它的链接失败。

.cargo/config.toml 中配置(注意这是仓库级别的配置,不是 home 目录下的全局配置):

[target.x86_64-unknown-linux-gnu]
rustflags = [
    "-C", "link-arg=-fuse-ld=lld",
]

在 macOS 上,使用 lld 有一些复杂性,可以考虑 zld

CI 中的 release 构建配置

在 CI 环境中,我会通过环境变量覆盖本地的 release 配置:

# 关闭增量编译
export CARGO_INCREMENTAL=0
# 启用 ThinLTO
export CARGO_PROFILE_RELEASE_LTO=thin

同时设置 RUSTFLAGS,启用 debug section 压缩和强制保留帧指针(便于用 perf 分析生产环境中的性能问题):

rustflags=(
  "-C link-arg=-fuse-ld=lld"
  "-C link-arg=-Wl,--compress-debug-sections=zlib"
  "-C force-frame-pointers=yes"
)
export RUSTFLAGS="${rustflags[*]}"

rustflags=(...) 是 bash 数组语法,${rustflags[*]} 将数组展开为空格分隔的字符串,避免了反斜杠续行的麻烦。


五、CI 方案的选择

5.1 历史回顾

我用过的 CI 系统:

  • Jenkins:功能齐全,但配置复杂到需要写一个 YAML 转 XML 的编译器
  • Travis CI:已被收购并实际上被摧毁,不再推荐
  • AppVeyor:当时的卖点是 Windows 支持,但用起来很笨重
  • GitLab CI:曾经自建 GitLab 实例专门用于 CI,配合自定义 runner,性价比高,但需要大量运维工作

5.2 GitHub Actions 的问题

GitHub Actions 集成得很好、对开源免费,这是它的核心卖点。但作为一套 CI 系统,它有很多问题:

  • YAML 里内嵌了一套奇怪的微型子语言来处理条件
  • 跨仓库复用配置非常困难,选的抽象层次从根本上就不对
  • Ubuntu 虚拟机使用微软自定义的 APT 仓库,曾经每两周就会导致构建失败
  • 本地验证 CI 配置极其困难,第三方的 act 工具与真实运行环境差异很大,非常脆弱
  • 2021 年上半年,GitHub Actions 经常直接宕机,作业挂在队列里几小时毫无进展
  • 构建失败后,无法登录到执行器上做手动调试,而本地又无法可靠复现

最致命的一点:GitHub 托管的 runner 太小。以下是 Linux 和 Windows runner 的硬件规格(截至写作时):

  • 2 核 CPU
  • 7 GB RAM
  • 14 GB SSD

2 核 CPU 来构建 Rust 或 C++ 项目,是远远不够的。即使有构建缓存,速度依然很慢。如果 CI 是你部署流水线的一部分,或者合并 PR 需要 CI 通过,你实际上是在用工程师的时间换来缓慢的 CI 体验。

我的观点:整个开源世界选择了一套次优的 CI 系统,仅仅是因为它对开源免费。

5.3 CircleCI:我的选择

最终我选择了 CircleCI,并且非常满意。

定价:Performance 计划每月 30 美元,获得 5 万积分。一个用户账号消耗 2.5 万积分,剩余 2.5 万积分用于构建时间等消耗。根据我的实际使用情况,这个计划完全够用。

Docker executor:CircleCI 支持自定义 Docker 镜像作为执行环境。这意味着不需要在 CI 中花时间安装 APT 包——每次执行都使用你预先构建好的镜像,里面已经包含了所有工具链和依赖。

你还可以同时启动多个容器,比如一个 MongoDB 实例、一个 mock S3/GCS 存储、一个 Redis 实例——统统不是问题。

远程 Docker 构建:使用 CircleCI 的 Remote Docker 特性,可以在 CI 中构建 Docker 镜像并推送到私有镜像仓库(如 AWS ECR)。

我建立了一个 docker-images 仓库,基于 ubuntu:20.04 构建了一个包含 rustup、C/C++ 工具链、各种构建工具、lld 链接器、just 等工具的镜像。镜像托管在 AWS Elastic Container Registry(us-east-1),与 CircleCI 的执行器在同一区域,下载速度极快,不会产生出站流量费用。

.circleci/config.yml 示例(docker-images 仓库):

version: 2.1

orbs:
  aws-ecr: circleci/aws-ecr@7.3.0

workflows:
  version: 2
  build:
    jobs:
      - rust:
          context: [aws]

jobs:
  rust:
    executor: aws-ecr/default
    steps:
      - aws-ecr/build-and-push-image:
          path: rust
          repo: bearcove-rust
          create-repo: true

所需的 AWS 凭证通过 CircleCI 的 context 传入,包括 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_ECR_ACCOUNT_URLAWS_REGION

GitHub Checks 集成:CircleCI 与 GitHub Checks 集成,在 GitHub PR 页面上会显示对勾标记,就像使用 GitHub Actions 一样。点击链接会跳转到 CircleCI 的 UI,展示完整的作业图。

本地执行:CircleCI CLI 工具可以在本地运行任何 CI 作业,执行环境与真实 CI 完全一致:

circleci local execute --job check \
  --env AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
  --env AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY

在 CI 上验证配置合法性:

circleci config validate
# Config file at .circleci/config.yml is valid.

失败后 SSH 调试:当作业失败时,可以选择"以 SSH 方式重新运行"。CircleCI 会给出一个 SSH 地址,你可以用 GitHub 上已有的 SSH key 直接连进去,在失败之后继续检查现场。我无法用文字充分描述这个功能有多省时间。

CircleCI Orbs:Orbs 是 CircleCI 中可复用的配置包,包含 executor、job、command 等。我为自己的所有项目创建了一个私有 orb,里面封装了 Rust executor 和 with-rust command。

with-rust command 的核心作用:配置 sccache、设置 Rust 编译选项、处理 S3 缓存认证。command 接受一个 steps 参数,用户在其中放入实际的构建步骤:

# bearcove-orb/src/commands/with-rust.yml
description: >
  Sets up caching, cargo authentication etc.
parameters:
  steps:
    type: steps
    description: Rust compilation steps to run
  sccache-bucket:
    type: string
    default: bearcove-sccache
  sccache-region:
    type: string
    default: us-east-1
steps:
  - run:
      name: Start sccache and configure rust
      command: <<include(scripts/with-rust-pre.sh)>>
  - steps: << parameters.steps >>
  - run:
      name: Stop sccache
      command: <<include(scripts/with-rust-post.sh)>>

with-rust-pre.sh 脚本完成以下工作:启动 sccache 服务,并将 RUSTC_WRAPPERCARGO_INCREMENTALCARGO_PROFILE_RELEASE_LTORUSTFLAGS 等环境变量追加写入 $BASH_ENV(CircleCI 的环境变量传递机制):

#!/bin/bash
set -eux

sccache --start-server

rustflags=(
  "-C link-arg=-fuse-ld=lld"
  "-C link-arg=-Wl,--compress-debug-sections=zlib"
  "-C force-frame-pointers=yes"
)

cat << EOF >> "${BASH_ENV}"
  export RUSTC_WRAPPER="sccache"
  export CARGO_INCREMENTAL="0"
  export CARGO_PROFILE_RELEASE_LTO="thin"
  export RUSTFLAGS="${rustflags[*]}"
EOF

with-rust-post.sh 停止 sccache 并打印统计信息:

#!/bin/bash
set -eux

sccache --stop-server
grep -F "'403" /tmp/sccache.log | head || true

CircleCI 鼓励把 bash 脚本放在单独的 .sh 文件中(而不是直接内联在 YAML 里),这样可以用 shellcheck 进行静态检查,也可以用 Bats 进行单元测试。

sccache 的效果:sccache 是一个编译器缓存工具,工作原理是:以所有输入(编译选项、环境变量、源文件等)为哈希键,将编译结果缓存到 S3。以下是 salvage 仓库的实际统计:

Compile requests                    182
Compile requests executed           152
Cache hits                          152
Cache hits (Rust)                   152
Cache misses                          0

命中率 100%。S3 缓存桶在所有作业、所有分支、所有版本之间共享,没有复杂的缓存 key 配置——只需设置 30 天的对象过期策略即可。

简洁的仓库级 CI 配置:有了 orb,每个业务仓库的 .circleci/config.yml 可以非常简洁:

version: 2.1

workflows:
  version: 2
  rust:
    jobs:
      - build:
          context: [aws]

jobs:
  build:
    executor: bearcove/rust
    steps:
      - checkout
      - bearcove/with-rust:
          steps:
            - run: |
                just check
                just test
orbs:
  bearcove: bearcove/bearcove@1.0.0

这与 GitHub Actions 的差距之大,不亲自用过很难感受到。


六、私有 Crate 注册中心

6.1 为什么需要私有注册中心

如果不想把代码开源,就无法发布到 crates.io。私有代码的依赖管理有三种方案:

方案一:单一巨型 monorepo,使用 path dependencies

所有代码放在同一个 cargo workspace 里。问题:rust-analyzer 会索引全部代码,clippy 会 lint 全部代码,内存消耗会成为瓶颈。

方案二:多仓库,使用 Git dependencies

问题:没有真正的版本号,没有 changelog,无法在一个 crate 被用作依赖之前验证测试通过,无法用工具检查"是否有依赖更新"。

方案三:搭建私有 crate 注册中心

我选择了这个方案。

6.2 搭建 ktra

我选用了 ktra,一个用 Rust 写的轻量级私有 crate 注册中心。

首先,建立一个私有 GitHub 仓库(比如 bearcove/crates)作为 index,config.json 内容如下:

{
    "dl": "http://localhost:8000/dl",
    "api": "http://localhost:8000"
}

在服务器上克隆并编译 ktra:

git clone https://github.com/moriturus/ktra
cd ktra
cargo build --release

添加配置文件:

# ktra.toml
[index_config]
remote_url = "https://github.com/bearcove/crates.git"
https_username = "fasterthanlime"
https_password = "A_GITHUB_PERSONAL_ACCESS_TOKEN"
branch = "main"

启动 ktra:

RUST_LOG=debug ./target/release/ktra

创建用户并获取 token:

curl -X POST -H 'Content-Type: application/json' \
  -d '{"password":"REDACTED"}' \
  http://localhost:8000/ktra/api/v1/new_user/fasterthanlime

在本地仓库的 .cargo/config.toml 中添加注册中心配置:

[registries]
bearcove = { index = "https://github.com/bearcove/crates.git" }

登录:

cargo login --registry=bearcove YOUR_TOKEN

然后就可以发布了:

cargo publish --registry=bearcove

注意:发布时需要 API 请求能够到达 ktra 服务器的 8000 端口。如果从本地发布,需要先建立 SSH 隧道:

ssh -L 8000:localhost:8000 ftl
# 保持这个连接,另开窗口执行 cargo publish

发布成功后,ktra 会向 index 仓库推送一个提交,记录 crate 的元数据。.crate 文件本体则存储在服务器本地:

./crates/salvage/0.1.0/download   # 实际上是 gzip 压缩的 .crate 文件

ktra 的一大优点是数据存储非常简单——就是一堆目录和文件,备份和迁移只需要 tar

tar czvf snapshot.tar.gz crates crates_io_caches db index ktra.toml

6.3 安全性:Caddy 反向代理

ktra 的部分端点(下载接口 /dl/*、管理接口 /ktra/*)默认不需要认证,将 ktra 直接暴露在公网上是不安全的。

我选用了 Caddy 作为反向代理,它能自动通过 Let's Encrypt 申请 TLS 证书:

crates.bearcove.net

tls REDACTED_LETSENCRYPT_EMAIL {
    dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

@restricted {
    path /dl/*
    path /ktra/*
}

basicauth @restricted {
    token REDACTED_SECRET_TOKEN_HASH
}

reverse_proxy 127.0.0.1:8000

log

这个配置的效果:

  • /dl/*/ktra/* 需要 HTTP basic auth
  • /api/v1/*(标准 crate 注册中心 API,用于发布/撤回 crate)由 ktra 自身的认证保护
  • 其余路径直接转发

把 basic auth 的凭证写入 config.json,让 cargo 在下载 crate 时自动携带认证信息:

{
    "dl": "https://token:YOUR_PASSWORD@crates.bearcove.net/dl",
    "api": "https://crates.bearcove.net"
}

这样就不再需要 SSH 隧道了。

关于资源消耗:我把 ktra 部署在 AWS 最小的 ARM 实例 t4g.nano(1 核,512 MiB RAM),费用极低。Caddy 有 Linux ARM64 的预编译二进制,无需编译。

为了让 caddy 不以 root 身份运行,给它赋予绑定低端口的能力:

sudo setcap 'cap_net_bind_service=+ep' $(which caddy)

七、检测未使用的依赖

私有注册中心搭好之后,来看几个可以加入 CI 的质量检查项。

第一个:用 cargo-udeps 检测未使用的依赖。它技术上需要 nightly,但可以用 RUSTC_BOOTSTRAP=1 绕过:

# in Justfile

# Finds unused dependencies
udeps:
  RUSTC_BOOTSTRAP=1 cargo udeps --all-targets --backend depinfo

效果演示:先故意添加一个无用依赖:

cargo add seahash
just udeps
# unused dependencies:
# `pithy v0.1.0`
# └─── dependencies
#      └─── "seahash"

发现了!在 CI Dockerfile 中安装 cargo-udeps

ENV CARGO_UDEPS_VERSION=v0.1.24
RUN set -eux; \
    curl --fail --location \
      "https://github.com/est31/cargo-udeps/releases/download/${CARGO_UDEPS_VERSION}/cargo-udeps-${CARGO_UDEPS_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \
      --output /tmp/cargo-udeps.tar.gz; \
    tar --directory "/usr/local/bin" -xzvf "/tmp/cargo-udeps.tar.gz" \
      --strip-components 2 --wildcards "*/cargo-udeps"; \
    rm /tmp/cargo-udeps.tar.gz; \
    chmod +x /usr/local/bin/cargo-udeps

在 CI 配置中加入 udeps job,如果有未使用的依赖,构建会失败,倒逼你保持 Cargo.toml 的整洁。


八、自动化依赖升级

cargo-outdated 可以查找可更新的依赖,但它更适合本地手动使用。在 CI 中让"有依赖可更新"触发构建失败并不合适——真正想要的是一个机器人:当有新版本时,自动开 PR,让 CI 验证,然后由人工决定是否合并

GitHub 收购了 Dependabot,但它对私有 crate 注册中心的支持在 2022 年之前都不在计划内。于是我贡献了代码,把私有注册中心和 cargo workspace 支持加入了 Renovate

Renovate 不仅是一个 GitHub App,也是一个 npm 包,可以完全由自己控制运行。

8.1 搭建 Renovate 的步骤

Step 1:在 docker-images 仓库的 Dockerfile 中添加 renovate 镜像目标(多阶段构建):

FROM base AS renovate

# 安装 node.js
RUN set -eux; \
    curl -fsSL https://deb.nodesource.com/setup_17.x | bash -; \
    apt-get install -y nodejs

# 安装 renovate
ENV RENOVATE_VERSION=28.10.3
RUN set -eux; \
    npm install --global renovate@${RENOVATE_VERSION}; \
    renovate --version

Step 2:创建 renovate-config 仓库,存放基础 Renovate 配置(rust.json):

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "dependencyDashboard": true,
  "semanticCommitType": "fix",
  "packageRules": [
    { "packagePatterns": ["^futures[-_]?"], "groupName": "futures packages" },
    { "packagePatterns": ["^serde[-_]?"], "groupName": "serde packages" },
    { "packagePatterns": ["^tokio[-_]?"], "groupName": "tokio packages" },
    { "packagePatterns": ["^tracing[-_]?"], "groupName": "tracing packages" },
    { "packagePatterns": ["^liquid[-_]?", "^kstring$"], "groupName": "liquid packages" }
  ],
  "regexManagers": [
    {
      "fileMatch": ["^rust-toolchain\\.toml?$"],
      "matchStrings": [
        "channel\\s*=\\s*\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""
      ],
      "depNameTemplate": "rust",
      "lookupNameTemplate": "rust-lang/rust",
      "datasourceTemplate": "github-releases"
    }
  ]
}

注意 regexManagers 配置——它让 Renovate 能够识别 rust-toolchain.toml 中的 Rust 版本并自动开 PR 升级。

CircleCI 配置设置两个触发器:每次 commit 触发一次,以及每天定时触发:

workflows:
  version: 2
  commit:
    jobs:
      - renovate:
          context: [aws, renovate]
  nightly:
    jobs:
      - renovate:
          context: [aws, renovate]
    triggers:
      - schedule:
          cron: "30 11 * * *"
          filters:
            branches:
              only:
                - main

jobs:
  renovate:
    docker:
      - image: .../bearcove-renovate:latest
    steps:
      - run:
          command: |
            renovate \
              --onboarding false \
              --require-config true \
              --allow-custom-crate-registries true \
              --token ${RENOVATE_TOKEN} \
              --git-author "Renovate Bot <bot@example.com>" \
              --autodiscover \
              ;

Step 3:在每个 Rust 仓库根目录添加 .renovaterc.json,引用共享配置:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "github>bearcove/renovate-setup:rust"
  ]
}

Step 4:触发一次 renovate-setup 的 workflow,Renovate 会自动扫描所有配置了 .renovaterc.json 的仓库,对每个有可更新依赖的 crate 开 PR。

Renovate 还会维护一个"Dependency Dashboard" issue,让你一眼看清所有仓库的依赖状态。

通过 packageRules 的 groupName 配置,Renovate 会把相关的 crate(比如所有 liquid-* 相关 crate)合并成一个 PR,而不是一个 crate 一个 PR,减少了 PR 数量。


九、发布自动化

有了 CI,有了依赖自动升级,接下来是发布流程自动化。

手动发布流程的问题:你需要记得在发布前跑测试、维护 changelog、按拓扑顺序发布 workspace 中的多个相互依赖的 crate……任何一个步骤忘了都可能出问题。

我的方案是两个工具的组合:release-please + cargo-workspaces

9.1 release-please

release-please 是一个命令行工具,在每次向默认分支合并代码时运行(在 CI 中)。它扫描自上次发布以来的所有 semantic commit,并维护一个"发布 PR"。

Semantic commit 格式:

  • fix: xxx → patch 版本升级
  • feat: xxx → minor 版本升级
  • feat!: xxxfix!: xxx → major 版本升级(破坏性变更)

发布 PR 会自动:

  • 更新 CHANGELOG.md
  • 修改 Cargo.toml 中的版本号

如果你认为系统推断的版本号不对,可以在 commit message 中通过 Release-As: x.y.z 指令强制指定版本:

feat!: Upgrade to nom v7 (major bump)

Release-As: 2.0.0

在 CI 中调用 release-please(单 crate 仓库):

for command in release-pr github-release; do
  release-please --debug "${command}" \
    --token "${GITHUB_TOKEN_BEARCOVEBOT}" \
    --release-type "rust" \
    --repo-url "${CIRCLE_REPOSITORY_URL}"
done

manifest releaser(多 crate workspace)

对于包含多个互相依赖的 crate 的 workspace,release-please 还支持 manifest 模式:

for command in manifest-pr manifest-release; do
  release-please --debug "${command}" \
    --token "${GITHUB_TOKEN_BEARCOVEBOT}" \
    --repo-url "${CIRCLE_REPOSITORY_URL}"
done

需要 release-please-config.json

{
  "bootstrap-sha": "COMMIT_SHA_BEFORE_RELEASE_PLEASE",
  "plugins": ["cargo-workspace"],
  "release-type": "rust",
  "packages": {
    "crates/foo": {},
    "crates/bar": {},
    "crates/baz": {}
  }
}

release-please 会自动维护 .release-please-manifest.json,记录每个包当前发布的版本:

{
  "crates/foo": "0.3.0",
  "crates/bar": "0.13.7",
  "crates/baz": "0.1.5"
}

manifest 模式的核心优势:自动处理传递依赖。假设 foobarbazbaz 有一个 fix commit,release-please 会生成一个同时 bump bazbarfoo 的单一发布 PR,并为每个包维护独立的 changelog。

注意:在 manifest 模式下,只有触及子目录(如 crates/foo)的 commit 才算作该 crate 的变更。如果你忘记给某个 commit 加上 feat: 前缀,可以再加一个修正 commit,甚至在 crates/foo/Cargo.toml 里加减一个空行来"触碰"这个目录。

9.2 cargo-workspaces

cargo-workspaces 可以按拓扑顺序(从叶子节点到根节点)发布 workspace 中的所有 crate,并跳过已发布的版本:

cargo workspaces publish \
  --from-git \
  --token "${KTRA_TOKEN}" \
  --yes

--from-git 表示从 Git tag 中读取版本号(release-please 在创建 GitHub release 时会打 tag)。

在 CI 的作业图中,cargo workspaces publish 作为最后一个作业,在所有 check/test/lint/udeps 全部通过后才运行:

check  ─┐
test   ─┤─→ publish-crates
udeps  ─┘

发布 PR 合并后的完整流程:

  1. release-please 检测到合并,创建 GitHub release 并打 tag
  2. CircleCI 触发 publish-crates 作业
  3. cargo-workspaces 检测到有未发布的新版本,按顺序发布到私有注册中心
  4. 私有注册中心(ktra)收到新 crate,更新 index
  5. Renovate 下次运行时,检测到有新版本可用,向依赖该 crate 的其他仓库开 PR

整个流程闭合,无需任何手工干预。


十、实际使用私有依赖:完整闭环

下面用一个具体例子展示整套流程如何运转。

我有两个仓库:pithy(私有发布的 crate)和 futile(依赖 pithy 的 web 服务)。

futile/.cargo/config.toml 中注册私有注册中心:

[target.x86_64-unknown-linux-gnu]
rustflags = [
    "-C", "link-arg=-fuse-ld=lld",
]

[registries]
bearcove = { index = "https://github.com/bearcove/crates.git" }

futile/Cargo.toml 中声明私有发布和依赖:

[package]
name = "futile"
publish = ["bearcove"]

[dependencies]
pithy = { version = "2.0.0", registry = "bearcove" }

publish = ["bearcove"] 确保不会误发布到 crates.io。

现在,假设我在 pithy 仓库做了一个重大变更——切换到 Rust 2021 edition,并用 feat!: 前缀标记 commit:

  1. release-please 检测到这是 major bump,开 release-v3.0.0 PR
  2. 合并 PR 后,ktra 中出现 pithy 3.0.0
  3. 手动或定时触发 Renovate workflow
  4. Renovate 在 futile 仓库开 PR:fix(deps): update dependency pithy to v3
  5. CircleCI 对这个 PR 运行 check/test,确认不破坏 futile
  6. 合并 PR

整个依赖升级过程有 CI 验证保驾护航,不会引入不兼容的变更,且全程几乎不需要手动操作。


十一、应对大量 PR 的实战策略

当你第一次为一个长期未升级依赖的大型项目设置 Renovate 时,可能会面对几十个甚至更多的 PR。

11.1 GitHub CLI

GitHub 官方提供了命令行工具 gh,大幅简化了 PR 操作:

# 列出所有 PR
gh pr list

# 检出某个 PR(本地可以运行、测试、修改)
gh pr checkout 123

# 在终端中查看 PR 内容(不用开浏览器)
gh pr view 123

# 合并 PR
gh pr merge 123

配合 just checkjust test,可以快速验证每个 PR 的安全性,然后决定是否合并。

11.2 Kodiak:自动合并机器人

gh pr merge 每次只能处理一个 PR,效率有限。更高效的方案是部署一个合并机器人,比如 Kodiak

工作流程:

  1. 给一批通过 CI 的 Renovate PR 打上 automerge label
  2. Kodiak 维护一个合并队列
  3. Kodiak 自动将 PR rebase 到最新的 main 分支,等待 CI 通过,然后合并

这样你不需要一个一个地手动合并 PR。

Renovate 与 Kodiak 的配合

当大量 PR 同时存在时,可能出现合并冲突(因为前一个 PR 更新了 Cargo.lock,导致后续 PR 的 Cargo.lock 过期)。解决方案:

  • 在 Renovate 配置中设置 "rebaseWhen": "conflicted":Renovate 会在运行时自动 rebase 有冲突的 PR
  • 在 GitHub 仓库设置中启用 "Require branches to be up to date before merging"(需要 Team 计划)
  • Kodiak 在合并前会自动 rebase PR

这套组合拳可以让依赖升级变成一个近乎全自动的持续过程。


总结

让我把这套工作流的完整工具栈列出来:

代码编辑

  • VS Code + rust-analyzer + Error Lens + Git Lens

工具链与构建

  • rust-toolchain.toml:锁定 Rust 版本
  • cargo clippy --locked -- -D warnings:lint 即是构建
  • cargo-hack:检查所有 feature 组合
  • just:统一本地和 CI 的命令接口
  • lld / zld:更快的链接器
  • 本地 release 构建优化(关 LTO,开增量编译)
  • CI release 构建优化(开 ThinLTO,关增量编译,启用帧指针)

CI

  • CircleCI:自定义 Docker 镜像、本地调试、SSH 访问失败作业、Orbs 复用配置
  • AWS ECR:存储自定义构建镜像
  • sccache + S3:跨作业跨分支的编译缓存

私有 crate 管理

  • ktra:轻量级私有 crate 注册中心
  • Caddy:反向代理 + 自动 TLS + basic auth

质量保障

  • cargo-udeps:检测未使用依赖
  • shellcheck + Bats:CI 脚本的静态检查和测试

依赖升级

  • Renovate(自托管):自动开 PR 升级依赖,支持私有注册中心
  • GitHub CLI:高效处理大量 PR
  • Kodiak:自动化合并队列

发布

  • release-please:基于 semantic commits 自动维护发布 PR 和 changelog
  • cargo-workspaces:按拓扑顺序发布 workspace 中的多个 crate

这套工作流投入了相当多的时间去搭建,但一旦跑通,日常的维护成本极低。你只需要专注于写代码,在 commit message 前加上 fix:feat: 前缀,其余的——版本升级、changelog 生成、crate 发布、依赖更新——都会自动完成。

这不是"过度工程"。这是在认真对待自己的工具,把工程师从重复劳动中解放出来,让他们能把精力放在真正重要的事情上。