6.1 持续集成、可复现构建与流水线安全
持续集成(Continuous Integration,CI)要求开发者频繁把小变更集成到共享主线,并通过自动化构建快速发现冲突和回归。测试服务器要服务于这条反馈路径。分支活得越久,集成反馈越迟,合并风险越容易累积。
CI 怎样缩短反馈
一次变更至少经过:
本地快速检查
-> 提交/推送
-> 隔离环境重新构建
-> 编译、静态分析、测试
-> 生成不可变制品
-> 报告结果并控制合并不必在每次按键后运行所有测试。快检查应尽早返回确定反馈,高成本兼容、性能和安全测试可以在合并、定时或发布候选阶段运行。关键是失败可见、有负责人,主线尽快恢复绿色。
一条最小 GitHub Actions 验证流水线
下面是 Gradle Java 项目的完整工作流,保存到 .github/workflows/verify.yml。前提是已提交可执行的 gradlew、Wrapper JAR/properties、构建文件,并把测试接入 check。前面的 Maven 练习不包含这个 Gradle 工程;若沿用它们,应改用 mvn verify 与 Maven 缓存。此工作流验证代码并上传失败报告,没有发布正式制品。
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 同时执行这些代码并持有部署密钥,恶意测试脚本就可能窃取凭据。
安全边界应是:
PR 验证:读取源码 + 无生产凭据 + 最小 token 权限
主线构建:受保护分支 + 写制品仓库的最小权限
部署:受保护环境 + 身份联合/短期凭据 + 审批或策略门禁不要把不受信任 PR 改成 pull_request_target 后直接 checkout 并执行其代码。确需特权自动化时,拆成不执行 PR 内容的受控工作流,并审查事件语义。
用 OIDC 替代长期云密钥
GitHub Actions 可以请求带工作流身份声明的 OIDC token,再向云平台换取短期凭据。下面是需要云身份的 job 的权限片段;还需配置云端信任策略,并执行相应云平台的登录/凭据交换步骤:
permissions:
contents: read
id-token: writeid-token: write 只允许请求 OIDC token,本身不直接授予云资源写权限。真正的权限由云端信任策略决定,应约束仓库、分支/环境、受众和角色,不能接受任意仓库或任意引用。
依赖服务:真实性与速度的选择
CI 中数据库有三种常见方式:
- job service container:启动快,适合固定拓扑;
- Testcontainers:测试代码掌控生命周期,便于本地与 CI 一致;
- 远程共享环境:更接近完整系统,但隔离、成本和诊断更复杂。
不论哪种,都要执行真实迁移、隔离测试数据,并对目标数据库版本做验证。不要因为 CI 方便就用 H2 代替生产 PostgreSQL,然后把方言和事务差异留到上线。
矩阵测试只覆盖有承诺的组合
库项目可能承诺多个 JDK/操作系统,需要矩阵;内部服务通常只需重点验证生产目标和升级候选。矩阵越大反馈越慢,不能把所有可能版本都塞进每个 PR。
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 从门禁退化成背景噪声。
下一节从“验证通过”继续到“构建一次、按同一身份逐级晋升”,并处理数据库、灰度和回滚。