本地与 CI 构建契约:让同一命令产出同一结果
“本地能过”与“CI 能过”经常被当成两个环境问题,实际上它们首先是合同问题。开发机执行 IDE 内置任务,CI 复制一段 YAML;本地复用半年缓存,CI 每次冷启动;开发者加载用户目录凭证,CI 使用服务身份。两边命令看起来都叫 build,输入却不是同一组。
构建契约不是一份流程说明,而是一组可执行约束:谁提供权威命令,工具怎样锁定,哪些输入允许变化,缓存何时可信,测试和产物怎样证明相同,发生漂移时由谁处理。
先建立唯一的权威入口
CI 配置不应重新实现构建逻辑。它只负责准备受控环境、注入允许的外部输入,然后调用仓库中的权威入口。
developer shell ─┐
IDE task ────────┼─> repository build entry ─> dependency restore
CI job ──────────┘ ├> compile
├> test
└> artifact manifest一个跨平台项目可以这样组织:
scripts/
build.ps1
build.sh
build-contract.ps1
verify-artifacts.ps1
build-contract.json
.tool-versionsPowerShell 与 Shell 入口可以不同,但必须调用同一底层工具、目标和参数。不要让 build.ps1 跑完整测试,build.sh 只打包;这种“同名不同义”比没有脚本更危险。
param(
[ValidateSet('Debug','Release')]
[string]$Configuration = 'Release'
)
$ErrorActionPreference = 'Stop'
& ./gradlew clean check assemble `
"-PbuildConfiguration=$Configuration" `
--no-daemon
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& ./scripts/verify-artifacts.ps1 -Configuration $Configuration
exit $LASTEXITCODE入口脚本必须透传退出码,明确工作目录,不静默修改 lockfile,不自动安装“任意最新版”,也不在失败后切换公共源重试。
把合同写成机器可以比较的数据
口头约定“使用项目规定版本”无法阻止漂移。仓库应保存一份简洁的合同描述:
{
"schemaVersion": 1,
"entrypoint": {
"windows": "pwsh -File scripts/build.ps1 -Configuration Release",
"linux": "bash scripts/build.sh Release"
},
"tools": {
"java": "21",
"gradle": "wrapper",
"node": "22",
"packageManager": "pnpm@10"
},
"inputs": {
"manifest": ["package.json", "build.gradle.kts"],
"lock": ["pnpm-lock.yaml", "gradle/verification-metadata.xml"],
"environmentNames": ["CI", "BUILD_PROFILE", "SOURCE_DATE_EPOCH"]
},
"modes": ["warm-cache", "cold-cache", "offline"],
"outputs": ["dist/app.jar"],
"checks": ["unit", "integration", "artifact-manifest"]
}这份文件不保存秘密、真实源地址或 CI 平台语法。它表达项目构建的稳定接口;具体工具配置仍留在各自标准文件中。
合同变更应像 API 变更一样审查。升级 Node、调整 JDK、替换包管理器、增加环境变量或改变产物路径,都可能让开发机、CI 和下游消费方产生不同结果。
Wrapper 和工具版本组成第一道边界
“CI 镜像里有 Maven”不是版本合同。“开发者用新版 Gradle 也能跑”也不能证明兼容。
优先级通常是:
使用项目 Wrapper,例如 mvnw、gradlew。使用工具自己的项目版本声明,例如 packageManager、.nvmrc、.python-version、global.json、rust-toolchain.toml。使用团队版本管理器或受控开发容器安装合同版本。
系统全局工具只作为引导入口,不作为最终构建身份。
每次构建先输出身份摘要:
[ordered]@{
Commit = (git rev-parse HEAD)
Java = (& java -version 2>&1 | Select-Object -First 1)
Node = (& node --version)
PackageManager = (& pnpm --version)
OS = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription
Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
} | ConvertTo-Json | Set-Content build/tool-identity.json大版本相同仍可能不够。编译器补丁、C 标准库、SDK workload、Xcode、Windows SDK 和原生链接器都可能改变产物。纯 JavaScript 项目与带原生扩展的 Node 项目应使用不同粒度的版本合同。
Wrapper 也需要完整性保护
Wrapper 脚本、JAR、下载 URL 与校验和都属于供应链输入。以 Gradle 为例,官方的 构建安全指南把 Wrapper 分发包和 Wrapper JAR 校验列为工具链保护的一部分。
团队应检查:
Wrapper 文件是否全部提交,是否被代码审查覆盖。分发 URL 是否指向批准来源。校验和是否存在并与批准版本匹配。
CI 是否绕过 Wrapper 直接调用全局工具。升级是否由单独变更完成,避免与业务代码混在一起。
manifest 和 lockfile 必须作为一个原子输入
manifest 描述期望,lockfile 记录解析结果。CI 的默认动作应是冻结恢复:manifest 与 lock 不一致就失败,而不是在构建期间自动重写 lock。
npm ci
pnpm install --frozen-lockfile
yarn install --immutable
uv sync --locked
cargo build --locked
bundle install --deployment不同生态的冻结语义并不完全相同,项目要锁定具体命令和工具版本。判断标准统一:CI 运行后 git status --short 不能出现 manifest、lockfile 或生成配置变化。
& ./scripts/build.ps1
$buildExit = $LASTEXITCODE
$dirty = git status --porcelain -- `
package.json pnpm-lock.yaml `
build.gradle.kts gradle/verification-metadata.xml
if ($dirty) {
Write-Error "构建修改了受保护输入:`n$dirty"
exit 31
}
exit $buildExitlockfile 冲突应由依赖变更流程解决。删除 lockfile 再装一次,会把受控升级变成全图重新解析,也会让本地成功与 CI 失败之间失去可比较基础。
平台与运行时矩阵要表达真实支持面
开发者只有 Windows,CI 只有 Linux,并不能证明 macOS 产物可用。矩阵不应为了“看起来全面”罗列所有组合,而应覆盖会改变解析、编译或运行结果的维度。
| 维度 | 何时必须进入矩阵 | 典型证据 |
|---|---|---|
| OS | 路径、权限、脚本或原生依赖不同 | OS、文件系统、换行、测试结果 |
| CPU | 包含原生代码、交叉编译或 SIMD | architecture、target triple、产物元数据 |
| 运行时 | 支持多个 JDK、Node、Python 或 .NET | 实际版本、依赖图、退出码 |
| 编译器 / SDK | 二进制兼容性或系统 API 变化 | 编译器、SDK、linker、哈希 |
| locale / timezone | 生成代码、排序、快照或日期敏感 | locale、timezone、差异文件 |
最小矩阵通常包含一个主支持环境和一个差异最大环境。没有业务承诺的组合不要长期运行;承诺支持的组合也不能只在发布日前手工测试。
平台差异要进入合同,而不是散落在 CI 条件表达式中:
{
"matrix": [
{ "os": "windows", "arch": "x64", "runtime": "21" },
{ "os": "linux", "arch": "x64", "runtime": "21" },
{ "os": "linux", "arch": "arm64", "runtime": "21" }
]
}环境变量只允许白名单输入
构建继承整个用户环境,会让 PATH、代理、locale 和用户配置悄悄成为输入。合同只声明允许影响结果的变量名、类型、默认值和是否敏感。
| 名称 | 类型 | 默认值 | 敏感 | 进入产物 |
|---|---|---|---|---|
BUILD_PROFILE | enum | release | 否 | 是 |
SOURCE_DATE_EPOCH | integer | commit time | 否 | 可能 |
APP_VERSION | semver | 无 | 否 | 是 |
REGISTRY_TOKEN | secret | 无 | 是 | 禁止 |
入口脚本先检查名称,不打印敏感值:
$required = 'BUILD_PROFILE','APP_VERSION'
$missing = $required | Where-Object {
-not (Get-Item "Env:$_" -ErrorAction SilentlyContinue)
}
if ($missing) {
Write-Error "缺少构建输入:$($missing -join ', ')"
exit 32
}
$unexpected = Get-ChildItem Env: |
Where-Object Name -Like 'BUILD_*' |
Where-Object Name -NotIn @('BUILD_PROFILE','BUILD_NUMBER')
if ($unexpected) {
Write-Error "发现未声明的 BUILD_* 输入:$($unexpected.Name -join ', ')"
exit 33
}CI 平台内置变量不能直接成为业务版本号来源。先在适配层把平台变量转换成合同变量,再调用权威入口,避免迁移 CI 平台时改变产物语义。
来源、凭证和网络行为也属于合同
开发机可能通过系统登录访问私有源,CI 使用 token;身份可以不同,但授权边界和解析结果必须等价。
合同至少规定:
允许访问的 source 名称与命名空间映射。凭证由哪个机制注入,只允许哪些权限。公网不可达时是失败、使用批准镜像,还是进入离线模式。
TLS 和企业 CA 从哪里加载,禁止哪些绕过开关。解析结果如何证明没有回退到未批准来源。
不要把真实 URL 和 token 放进合同文件。仓库保存逻辑名称,例如 approved-public、approved-private;开发机和 CI 分别把逻辑名称绑定到受管配置。
凭证可用性不等于构建可复现。一次构建若因为身份权限更大而解析到额外仓库,即使版本号相同,来源合同也已经破坏。
缓存合同必须同时验证热、冷和离线
只跑热缓存会把缺失依赖、错误 cache key 和不可用上游藏起来;只跑冷缓存又无法发现缓存污染和权限问题。
至少建立三种模式:
热缓存
用于日常反馈,验证缓存命中不会跳过必要测试,也不会复用其他分支或平台的不兼容结果。
冷缓存
使用隔离的空缓存恢复并构建,验证依赖来源、网络、锁文件和生成步骤完整。
$env:GRADLE_USER_HOME = Join-Path $PWD '.cache-test/gradle-cold'
$env:PNPM_HOME = Join-Path $PWD '.cache-test/pnpm-home'
Remove-Item -LiteralPath '.cache-test/gradle-cold' -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory '.cache-test/gradle-cold' | Out-Null
& ./scripts/build.ps1
exit $LASTEXITCODE清理范围只能指向项目内专用目录。不要在共享机器上递归删除用户主目录缓存。
受控离线
先按批准流程准备依赖材料,再阻断网络或使用工具离线开关。离线成功必须依赖可归档、可校验的材料清单,不能依赖某位开发者的历史缓存。
./gradlew build --offline
pnpm install --offline --frozen-lockfile
cargo build --locked --offline工具的 offline 只表示“不主动访问网络”,不自动证明缓存完整、来源可信或材料可迁移。离线合同还要记录材料生成方式、校验值、目标平台和失效条件。
cache key 要覆盖会改变输出的输入
cache-key = hash(
toolchain identity,
target platform,
manifest and lock,
build configuration,
relevant source inputs,
compiler flags,
generator version
)只用 lockfile 作为 key,无法区分 Node 主版本、JDK、编译器、平台、构建 profile 和插件行为。把 commit SHA 全部放进 key 又会失去跨提交复用价值。
缓存分层比一个大目录更容易治理:
下载缓存按来源、校验和与工具版本约束。依赖解析缓存按 lock、平台和解析器约束。编译输出按源码、编译器、参数和目标约束。
测试结果按测试输入、运行时和外部依赖约束。
CI 对共享缓存默认只读,经过可信分支验证后再写入。来自外部贡献分支的构建不能覆盖主分支缓存,也不能读取含私有依赖的高权限缓存。
测试合同要说明“跑了什么”
test passed 信息不足以比较本地与 CI。至少记录测试集合、过滤条件、分片、重试、跳过数量、失败数量和退出码。
{
"suite": "unit",
"selected": 842,
"passed": 840,
"failed": 0,
"skipped": 2,
"retried": 0,
"exitCode": 0
}本地快捷入口可以只跑受影响测试,但合并判断必须调用合同规定的完整集合。自动重试要单独计数;重试后通过的 flaky 测试不应伪装成第一次成功。
测试依赖数据库、浏览器、时区或外部服务时,这些依赖的版本和初始化数据也是合同输入。CI 使用容器、开发机使用共享测试库,会让“相同测试命令”得到不同语义。
产物身份是合同的输出
构建结束时生成机器可读清单:
$files = Get-ChildItem dist -File -Recurse | Sort-Object FullName
$manifest = [ordered]@{
Commit = (git rev-parse HEAD)
ContractVersion = 1
Target = 'linux-x64'
Files = @($files | ForEach-Object {
[ordered]@{
Path = $_.FullName.Substring((Resolve-Path dist).Path.Length + 1).Replace('\','/')
Size = $_.Length
Sha256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
})
}
$manifest | ConvertTo-Json -Depth 5 | Set-Content dist/artifact-manifest.json判断“相同产物”前先约定比较级别:
文件集合一致:适合初级门禁。API / ABI 与行为一致:适合跨平台产物。解包后的逻辑内容一致:可忽略压缩元数据。
字节级哈希一致:适合已经完成可复现治理的目标。
签名通常包含时间、证书链或平台服务输入。比较时把未签名构建产物与签名封装分层,避免把签名差异误判为编译漂移。
用漂移指标发现合同正在失效
构建漂移不应等到“CI 全红”才出现。持续记录以下指标:
本地复现 CI 失败的成功率和平均时间。冷缓存与热缓存成功率、耗时和下载量差异。同提交重复构建的产物差异率。
lockfile 被构建过程修改的次数。未声明环境变量、全局工具和用户级配置命中次数。cache miss 原因中“key 设计错误”的占比。
flaky 重试率和跳过测试增长率。主支持矩阵外的临时例外数量与存活时间。
指标必须能触发动作。例如同提交产物差异率大于零时,阻止把“偶发哈希变化”当成正常噪声;冷缓存连续失败时,检查来源和离线材料,而不是继续扩大缓存保留期。
开发机与 CI 容器不会天然一致
把 CI 放进容器只能固定一部分用户空间。宿主内核、CPU 特性、文件系统、时钟、网络、Docker 配置和挂载权限仍可能不同。
开发机容器也可能比 CI 更宽松:
本地挂载源码保留宿主文件权限与换行。本地 Docker 继承已登录 registry 凭证。IDE 自动注入代理和证书。
用户缓存通过 volume 长期复用。Apple Silicon 或 ARM 开发机通过模拟运行 x64 镜像。
合同应记录容器镜像的不可变摘要、目标平台、挂载模式和允许的外部依赖。镜像 tag 只能用于可读性,真正身份使用 digest。
builder image: registry.example.invalid/build/java@sha256:<digest>
target platform: linux/amd64
workspace mount: read-write
dependency cache: isolated, non-root
network: approved registries onlyRunner 安装、扩缩容和平台维护由 CI 基础设施负责;项目合同只声明运行构建所需的资源、权限和输入,不把流水线平台部署复制进仓库手册。
升级采用双轨,不在原轨上直接换引擎
工具链升级至少经过四步:
保留当前轨道,固定旧 Wrapper、镜像和合同版本。新建候选轨道,只改变一个升级维度。在同一提交上比较依赖图、任务、测试、性能和产物身份。
达到进入标准后切换权威入口,旧轨道保留一个明确回滚窗口。
stable: contract v3 + JDK 21 + Gradle wrapper A
candidate: contract v4 + JDK 21 + Gradle wrapper B候选轨道不能与依赖大升级、业务重构和 CI 平台迁移同时进行。变量越多,差异越难归因。
进入标准应可测量,例如:
全部支持矩阵通过。依赖图变化都有批准原因。冷、热、离线模式达到基线。
产物比较符合约定级别。构建耗时与资源变化在预算内。高风险插件、脚本和来源没有扩大权限。
回滚不是把版本号改回去
回滚材料包括旧 Wrapper、旧 builder digest、旧 lockfile、旧缓存命名空间和旧合同入口。新工具写出的 lockfile 或缓存可能无法被旧版本读取,切回命令却继续复用新状态,会得到第二次故障。
回滚演练要验证:
source commit
+ previous contract
+ previous toolchain
+ isolated previous cache namespace
-> previous tests
-> previous artifact identity旧缓存保留有成本和安全风险,应设置到期时间;到期前仍未完成升级验收,就不能假设随时可退。
责任矩阵让合同长期有人维护
| 责任 | 项目团队 | 平台团队 | 安全 / 供应链 | 工具 owner |
|---|---|---|---|---|
| 权威命令与测试集合 | 主责 | 协助 | 审查风险 | 协助 |
| Wrapper 与工具版本 | 参与 | 协助 | 校验策略 | 主责 |
| 来源与凭证注入 | 声明需求 | 实施运行边界 | 主责策略 | 协助 |
| 缓存 key 与隔离 | 提供输入模型 | 主责平台实现 | 审查跨信任域 | 协助 |
| 产物身份与保留 | 主责语义 | 提供存储 | 审查完整性 | 协助 |
| 漂移指标与例外 | 主责处置 | 提供观测 | 审查高风险例外 | 主责工具问题 |
| 升级与回滚 | 主责业务验证 | 协助环境 | 审查供应链 | 主责方案 |
任何例外都要有 owner、原因、影响面和到期时间。永久存在的“临时跳过”说明合同已经失效。
一次完整的合同检查
项目可以把检查入口独立出来:
./scripts/build-contract.ps1 -Mode warm-cache
./scripts/build-contract.ps1 -Mode cold-cache
./scripts/build-contract.ps1 -Mode offline每次运行按相同顺序完成:
校验源码状态、合同 schema 和权威入口。校验 Wrapper、工具版本与 builder 身份。校验 manifest、lockfile 和受保护配置未漂移。
校验允许的环境变量名、来源与凭证机制。按模式准备隔离缓存和网络边界。执行恢复、编译、测试和产物生成,透传退出码。
保存依赖图、任务摘要、测试结果和产物清单。比较基线,输出结构化漂移原因。
失败输出应说明违反了哪条合同,而不是只返回一个通用 build failed:
{
"status": "failed",
"contractRule": "lockfile-immutable",
"evidence": "build/contract/changed-inputs.txt",
"exitCode": 31,
"nextAction": "由依赖 owner 重新生成并审查 lockfile"
}合同足够清楚后,本地与 CI 不必拥有完全相同的硬件和身份,但它们必须能证明:差异是声明过的,输入是受控的,命令是同义的,测试集合是可比的,产物身份是可追溯的。这样,“本地能过”才会从个人经验变成团队可依赖的工程事实。
