依赖更新策略:自动更新 vs 手动更新、版本锁文件、CI/CD中的依赖更新流程

依赖管理这件事,说白了就是「请神容易送神难」。你把别人的代码拉进来,爽是爽了,但哪天人家更新了,你的项目可能就炸了。我见过太多团队,因为依赖更新没管好,半夜被叫起来修构建。

今天咱们就聊聊,在 CMake FetchContent 的体系下,怎么管好依赖的版本更新。核心就三个问题:什么时候更新?怎么锁定版本?CI/CD 里怎么自动化?

自动更新 vs 手动更新

先说说两种极端做法。

自动更新,就是每次构建都去拉最新的代码。比如你写 GIT_TAG main 或者 GIT_TAG v1.0.x 这种模糊匹配。好处是永远用最新,坏处是——你永远不知道今天构建和昨天构建是不是同一份代码。

我曾经在一个快速迭代的项目里用过自动更新,结果第三天就出事了。上游库修了一个 bug,但顺手改了个接口签名,我们这边直接编译失败。整个团队花了半天排查,最后发现是依赖偷偷变了。

警告:生产项目永远不要用 mainlatest 或模糊分支名作为依赖版本。你今天能编译,不代表明天也能。

手动更新,就是明确指定一个具体的 commit hash 或 tag。比如:

FetchContent_Declare(
  mylib
  GIT_REPOSITORY https://github.com/example/mylib.git
  GIT_TAG v1.2.3
)

这样每次构建都用同一份代码,稳如老狗。但问题来了——你想升级依赖怎么办?手动改这个 GIT_TAG,然后祈祷别出问题。

我个人习惯的做法是:日常开发用手动锁定,定期做依赖升级评审。说白了,就是「平时锁死,定期解锁」。

版本锁文件(.lock)

说到锁定版本,很多从 npm 或 Rust 过来的同学会问:「CMake 有没有 lock 文件?」

答案是:CMake 原生没有 lock 文件机制。但我们可以自己造一个。

思路很简单:每次解析完依赖的真实版本后,把结果写到一个文件里。下次构建时,先读这个文件,如果文件存在且内容匹配,就直接用锁定的版本,不再去远程查询。

我给你们看一个我项目里用的脚本思路:

# 在 CMakeLists.txt 中
set(LOCK_FILE "${CMAKE_SOURCE_DIR}/deps.lock")

if(EXISTS ${LOCK_FILE})
  file(STRINGS ${LOCK_FILE} LOCKED_VERSIONS)
  # 解析锁定文件,直接使用锁定的 commit hash
  # 跳过 FetchContent 的远程查询
else()
  # 正常执行 FetchContent,解析最新版本
  # 解析完成后,把实际使用的版本写入 lock 文件
  FetchContent_GetProperties(mylib)
  file(APPEND ${LOCK_FILE} "mylib=${mylib_GIT_TAG}\n")
endif()
提示:lock 文件要提交到 Git 仓库。这样所有人都用同一份锁定版本,CI/CD 也保持一致。

这个做法虽然简陋,但够用。你想想看,npm 的 lock 文件本质也是干这个事——记录「实际解析出来的版本号」。我们只是用手动方式实现了同样的效果。

CI/CD 中的依赖更新流程

好了,现在我们有锁文件了。那怎么更新呢?总不能每次都手动改吧?

我建议在 CI/CD 里专门加一个 「依赖更新」流水线。流程大概是这样的:

  1. 定时触发(比如每周一凌晨)或手动触发
  2. 删除旧的 lock 文件
  3. 重新执行 CMake 配置,解析最新版本
  4. 运行完整的测试套件
  5. 如果测试通过,自动提交一个新的 PR,包含更新后的 lock 文件
  6. 如果测试失败,发通知给团队,人工介入

这个流程的好处是:依赖更新是可追溯、可回滚的。每次更新都是一个独立的 commit,出了问题直接 revert 就行。

我曾经在一个嵌入式项目里用过这个方案。当时有个底层库更新了,我们的测试第一时间发现了性能回退。因为更新是单独一个 PR,我们直接关掉 PR,等上游修好了再试。整个过程没影响主线开发。

下面这张图展示了整个依赖更新的核心逻辑:

依赖更新流程 触发更新 删除旧 lock 文件 重新解析依赖 测试失败 → 人工介入 测试通过 → 自动 PR 运行测试 通知团队 提交 PR + 更新 lock

版本锁文件的内容格式

lock 文件怎么写?我推荐用简单的键值对格式,每行一个依赖:

# deps.lock
# 格式: 依赖名=解析后的版本标识
fmtlib=7.1.3
nlohmann_json=v3.11.2
spdlog=v1.12.0
googletest=release-1.12.1

为什么不用 JSON 或 YAML?因为简单。你想想看,这个文件的主要读者是 CI 脚本,不是人。键值对足够解析,也容易 diff。

核心原则:lock 文件只记录「实际使用的版本」,不记录「期望的版本」。期望的版本写在 CMakeLists.txt 里,lock 文件是执行结果。

CI/CD 中的具体实现

在 GitHub Actions 或 GitLab CI 里,可以这样实现:

# .github/workflows/deps-update.yml
name: Dependency Update
on:
  schedule:
    - cron: '0 2 * * 1'  # 每周一凌晨2点
  workflow_dispatch:      # 也支持手动触发

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Remove lock file
        run: rm -f deps.lock
      - name: Configure with CMake
        run: cmake -B build -S .
      - name: Run tests
        run: cmake --build build && ctest --test-dir build
      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v5
        with:
          title: 'chore: update dependencies'
          body: '自动依赖更新,请 review 测试结果。'
          branch: 'deps/auto-update'

这个 workflow 做了几件事:

  • 定时触发,也支持手动点按钮
  • 删掉 lock 文件,强制重新解析
  • 跑完整的构建和测试
  • 如果一切顺利,自动创建一个 PR

嗯,这里要注意:测试必须足够全面。如果测试覆盖率低,自动更新就是定时炸弹。我见过一个项目,测试只覆盖了 20% 的代码,自动更新 PR 合并后,线上直接挂了。

总结一下

依赖更新这事,没有银弹。我的建议是:

  • 日常开发:用手动锁定,lock 文件提交到仓库
  • 定期更新:用 CI/CD 自动化流程,单独 PR
  • 紧急修复:手动改 lock 文件,快速发版

说白了,就是「平时锁死,定期解锁,紧急开锁」。这套策略我在好几个项目里验证过,稳得很。


公众号:蓝海资料掘金营,微信deep3321