跳到内容

第 6 卷 / 第 6 章 / 第 1 课

6.1 持续集成、可复现构建与流水线安全 ​

持续集成(Continuous Integration,CI)要求开发者频繁把小变更集成到共享主线,并通过自动化构建快速发现冲突和回归。测试服务器要服务于这条反馈路径。分支活得越久,集成反馈越迟,合并风险越容易累积。

CI 怎样缩短反馈 ​

一次变更至少经过:

text
本地快速检查
  -> 提交/推送
    -> 隔离环境重新构建
      -> 编译、静态分析、测试
        -> 生成不可变制品
          -> 报告结果并控制合并

不必在每次按键后运行所有测试。快检查应尽早返回确定反馈,高成本兼容、性能和安全测试可以在合并、定时或发布候选阶段运行。关键是失败可见、有负责人,主线尽快恢复绿色。

一条最小 GitHub Actions 验证流水线 ​

下面是 Gradle Java 项目的完整工作流,保存到 .github/workflows/verify.yml。前提是已提交可执行的 gradlew、Wrapper JAR/properties、构建文件,并把测试接入 check。前面的 Maven 练习不包含这个 Gradle 工程;若沿用它们,应改用 mvn verify 与 Maven 缓存。此工作流验证代码并上传失败报告,没有发布正式制品。

yaml
name: verify

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: verify-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false

      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: '21'
          cache: gradle

      - name: Verify Gradle wrapper
        uses: gradle/actions/wrapper-validation@v6

      - name: Compile, analyze and test
        run: ./gradlew --no-daemon check

      - name: Upload test reports on failure
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: test-reports
          path: '**/build/reports/tests/'
          retention-days: 7

示例中的主版本标签已于 2026-09-05 核对,保留标签是为了便于阅读;它们仍可移动。生产仓库应把外部 Action 固定到经核验的完整 commit SHA;GitHub 文档指出,完整 SHA 才是不可变引用。再用 Dependabot 或同类机制提出升级 PR,避免“永不升级”和“每次自动漂移”两个极端。

permissions 显式缩到 contents: read,避免无需要的写权限。只有发布 job 才获得 packages: write、attestations: write 或 id-token: write,不要在整个 workflow 顶层一次性放大。

构建必须由版本控制输入驱动 ​

可复现构建要求:在相同源码、已声明构建环境与步骤下,其他人能重建出逐字节相同的指定制品。这比“再跑一次也能成功”更严格,但不要求任意 JDK 或 CPU 目标都生成相同字节。先声明制品和环境,再控制:

  • JDK、构建工具和插件版本;
  • Gradle Wrapper 或 Maven Wrapper;
  • 直接与传递依赖的锁定/校验策略;
  • 操作系统、CPU 架构和本地工具差异;
  • 构建时间、随机数、文件排序等非确定输入;
  • 从网络下载的脚本、镜像和 Action 版本。

验证时在两个干净构建目录按声明工具链构建,比较指定制品的哈希,并在比对时禁用任务输出缓存。两次测试都通过不能代替字节比对;归档时间戳、条目顺序、绝对路径和生成的构建元数据都可能改变字节。只写 java-version: 21 或 ubuntu-24.04 仍允许补丁级工具链与 runner 漂移,声称可复现时要保留实际版本或构建镜像 digest。

缓存用于减少重复工作,缓存未命中不应破坏正确性。缓存未命中时流水线仍应成功;缓存内容不能绕过依赖校验。

PR 流水线与受信任发布流水线要分权 ​

来自 fork 的 PR 包含贡献者可修改的代码。若同一 job 同时执行这些代码并持有部署密钥,恶意测试脚本就可能窃取凭据。

安全边界应是:

text
PR 验证:读取源码 + 无生产凭据 + 最小 token 权限
主线构建:受保护分支 + 写制品仓库的最小权限
部署:受保护环境 + 身份联合/短期凭据 + 审批或策略门禁

不要把不受信任 PR 改成 pull_request_target 后直接 checkout 并执行其代码。确需特权自动化时,拆成不执行 PR 内容的受控工作流,并审查事件语义。

用 OIDC 替代长期云密钥 ​

GitHub Actions 可以请求带工作流身份声明的 OIDC token,再向云平台换取短期凭据。下面是需要云身份的 job 的权限片段;还需配置云端信任策略,并执行相应云平台的登录/凭据交换步骤:

yaml
permissions:
  contents: read
  id-token: write

id-token: write 只允许请求 OIDC token,本身不直接授予云资源写权限。真正的权限由云端信任策略决定,应约束仓库、分支/环境、受众和角色,不能接受任意仓库或任意引用。

依赖服务:真实性与速度的选择 ​

CI 中数据库有三种常见方式:

  • job service container:启动快,适合固定拓扑;
  • Testcontainers:测试代码掌控生命周期,便于本地与 CI 一致;
  • 远程共享环境:更接近完整系统,但隔离、成本和诊断更复杂。

不论哪种,都要执行真实迁移、隔离测试数据,并对目标数据库版本做验证。不要因为 CI 方便就用 H2 代替生产 PostgreSQL,然后把方言和事务差异留到上线。

矩阵测试只覆盖有承诺的组合 ​

库项目可能承诺多个 JDK/操作系统,需要矩阵;内部服务通常只需重点验证生产目标和升级候选。矩阵越大反馈越慢,不能把所有可能版本都塞进每个 PR。

yaml
strategy:
  fail-fast: false
  matrix:
    java: ['21', '25']

这段 strategy 放在 jobs.test 下,同时把 setup-java 改为 java-version: ${{ matrix.java }},报告名改为 test-reports-${{ matrix.java }} 等不同名称;否则两份 job 仍都使用 21,上传报告还可能重名。Gradle toolchain 也应对应配置,避免固定工具链覆盖矩阵 JDK。说明两个版本是正式支持,还是一个为升级预检,并选择能在两者上运行的 Wrapper 版本。

合并门禁与紧急处理 ​

受保护主线通常要求:

  • 必需状态检查通过;
  • 必要评审完成;
  • 分支与主线没有未验证差异;
  • 高风险路径由代码所有者评审;
  • 绕过门禁的权限和审计受到限制。

主线失败时,优先回退或修复导致失败的小变更。把失败测试长期标成“允许失败”,会让 CI 从门禁退化成背景噪声。

下一节从“验证通过”继续到“构建一次、按同一身份逐级晋升”,并处理数据库、灰度和回滚。

参考资料 ​

Built with VitePress | Software Systems Atlas