以观察、判断和行动三个面板呈现产物交接检查。

CI 产物需要明确的交接契约

CI 的上游任务可以成功结束,下游却拿到错误文件。产物是某个步骤生成并保存下来、交给其他任务、人员或发布流程使用的输出,仅靠名字通常不足以识别它。应把交接视为一份契约,说明生产它的运行、源码版本、负载内容、完整性证据、访问策略和可用时间窗口。这样,“下载构建结果”才成为能够核对的操作,而不是在共享目录里猜测哪个文件最新。 分清传递目的与保存期限 产物可以是二进制、测试报告、覆盖率数据,也可以是之后进入发布流程的软件包;不能断言它天然不属于发布输入。依赖缓存的目的则是加速可以重建的工作,二者不应混为一谈。选择存储和保留期之前,先确定审批、诊断和回滚需要哪些文件、需要多久。过期以后重新构建,可能生成不同字节,也需要重新验证,不能把重建视为取回原产物。 GitHub Actions 允许自定义产物保留时间,但受到仓库、组织或企业上限约束。安排流程时,要按真实配置考虑周末和延迟审批;请求一个较长保留值,并不能突破平台上限。确实需要长期留存的发布输入,应转入项目使用的持久发布存储或对象存储,并明确生命周期。短期任务存储即使今天能下载,也不应被说明成无限期的发布归档。 坏掉的样子 随手下载“最近一次成功运行”,可能拿到另一个提交、分支或工作流的文件。应明确解析目标生产任务,并核对它的源码与当前批准发布的变化之间的关系。下游还需要等待所需文件真正产生;空上传、缺失产物不能悄悄变成部署工作区里碰巧残留的旧文件。任务变绿与正确负载已经交付,是需要分别检查的事实。 完整性与来源真实性回答不同问题。校验和可以发现负载变化,但能同时替换文件和校验和的人,也能造出自洽的一对文件。来源声明还需要验证它指向的产物、签名方或工作流身份,以及项目要求的策略。二进制旁边存在一份证明文件,并不代表消费方已经执行了验证。必须看真实的验证结果,而不是只看相关配置或附件存在。 谁能访问产物,取决于平台、仓库可见性与授予的权限,不能一概等同于“能看到工作流的人”。即便如此,也只应上传交接必需的文件。凭据、环境变量全集、私有请求头和未经筛选的日志,一旦打包就可能留下额外副本。保存之前要检查成员列表及其内容,尤其当构建输入来自尚未信任的贡献时,不能只靠压缩包名称判断它是否适合交给下游。 快速验证 保存脚本为 artifacts.py 并执行 python3 artifacts.py,只需 Python 标准库。生产方在临时目录创建 ZIP,包含 app.txt 与清单;清单记录本地运行编号、示意性的源码摘要和负载摘要。消费方只读取约定成员,先比较预期生产者,再返回实际负载。这里没有解压任意归档成员,也没有连接真实 CI 平台。 from hashlib import sha256 from pathlib import Path from tempfile import TemporaryDirectory from zipfile import ZipFile import json payload = b'local build output\n' source_digest = sha256(b'example source revision\n').hexdigest() manifest = {'producer_run': 'local-run-41', 'source_sha256': source_digest, 'files': {'app.txt': sha256(payload).hexdigest()}} def produce(path, data): with ZipFile(path, 'w') as archive: archive.writestr('manifest.json', json.dumps(manifest, sort_keys=True)) archive.writestr('app.txt', data) def consume(path, expected_run): with ZipFile(path) as archive: if sorted(archive.namelist()) != ['app.txt', 'manifest.json']: raise ValueError('unexpected member set') received = json.loads(archive.read('manifest.json')) if (received['producer_run'] != expected_run or received['source_sha256'] != source_digest): raise ValueError('producer identity mismatch') data = archive.read('app.txt') if sha256(data).hexdigest() != received['files']['app.txt']: raise ValueError('payload digest mismatch') return data with TemporaryDirectory() as directory: archive = Path(directory) / 'handoff.zip' produce(archive, payload) assert consume(archive, 'local-run-41') == payload rejected = [] try: consume(archive, 'local-run-42') except ValueError as error: rejected.append(str(error)) produce(archive, payload + b'changed\n') try: consume(archive, 'local-run-41') except ValueError as error: rejected.append(str(error)) archive.unlink() try: consume(archive, 'local-run-41') except FileNotFoundError: rejected.append('artifact missing') assert rejected == ['producer identity mismatch', 'payload digest mismatch', 'artifact missing'] print(json.dumps({'valid_payload_bytes': len(payload), 'rejected': rejected, 'platform_retention_tested': False})) 正常交接返回十九字节。用 local-run-42 去领取实际来自 local-run-41 的归档,会得到生产者身份不匹配;保留旧清单、替换文件内容,会得到摘要不匹配;删除归档后继续下载,则得到文件缺失。最终结果必须列出这三种拒绝情况,任何失败都不会转而消费另一个构建。这样可以看清下游契约的失败分支确实执行了。 ...

九月 3, 2027 · 1 分钟 · XB Field Notes
一份工作流文件把事件绑定到 runner 上的一串 job。

GitHub Actions 工作流给构建把门槛

每个 PR 都叠加 CI 结果,会让 review 更诚实。那份 YAML 与要检查的代码一起版本化,就是这层契约。 YAML 就是契约 工作流 YAML 同时声明了「何时运行」和「以什么权限运行」,所以它就是整份契约,而不只是信任的起点:on: 列出触发器,permissions: 钉住它能碰的东西,jobs: 声明作业和步骤。因为 on 在任何 job 跑之前就被解析,它也正是你决定「push 必须验证通过才允许合并」或「release 标签可以无人值守触发」的地方。把制定文件跟代码一起入库,让一个分支的 CI 和该分支自己的契约始终同版本、同步调。 job 与 step 有序执行 job 默认并行,step 在 job 内顺序执行;两张规则叠在一起,才组成整张图。一个失败的 step 会让它所在 job 后面的步骤取决于条件;能否继续后面 job,由 needs、if 和 job 级并发决定。这些声明把工作流变成一座真的闸门——红了就拦住合并,而不是跑完就撒手。实际工程里把必检的和快的放在前面、贵重的放在后面,让一次失败在廉价处就显形,而不是耗半天才喊停。 ...

九月 2, 2027 · 2 分钟 · XB Field Notes
把频繁编辑的应用代码放到安装之后,稳定依赖层就能继续复用。

先复制依赖输入,再复制应用代码

Dockerfile 顺序决定昂贵构建操作依赖哪些先前输入。如果应用源码在依赖安装之前被复制,即使依赖声明未变,修改源码也可能让安装重跑。先复制依赖输入、安装依赖,再复制应用代码,可以建立更小的依赖边界,让它与安装器真正需要的工作匹配。这里优化的是依赖关系,而不只是把几行命令挪到看起来更整齐的位置。 坏掉的样子 在软件包安装前使用宽泛的 COPY . .,会让文档、生成输出或无关源码编辑,都可能成为重做昂贵工作的原因。问题不在于 COPY 天生很慢,而在于后续安装依赖了一份包含无关变化的文件系统状态。这些变化并不是选择和安装依赖所必需,却通过前置步骤扩大了失效范围。 如果安装还读取锁文件、本地包源码、补丁、工作区元数据或包管理器配置,仅复制一个清单也不够。遗漏真实输入,可能保留已经不符合构建意图的缓存结果。正确边界必须包含安装器实际使用的全部输入,而不是只选择常见 Dockerfile 示例里出现的某个文件名。否则,表面命中率提高,实际正确性却下降。 缓存顺序也不能修复可变依赖。相同安装命令可能一直保留旧结果,而干净构建却从远端解析到不同内容。固定版本和校验哈希解决产物选择问题,控制 COPY 顺序解决复用问题。两者都需要处理,构建更快并不能证明软件包图可复现,也不能证明依赖已经更新到本次发布要求的版本。 快速验证 使用宿主 Python、Docker 构建器和已取得的固定基础镜像运行脚本。它先通过 HTTPS 下载两个公开的 idna wheel,并在构建前验证固定 SHA-256。容器构建关闭网络,pip 从本地 wheel 目录安装,并强制要求哈希。实测环境为 Docker 29.3.1,Linux arm64 基础镜像内 Python 为 3.11.16: from pathlib import Path from tempfile import TemporaryDirectory from urllib.request import urlopen import hashlib,json,subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' WHEELS={ '3.10':('https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl','946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3'), '3.11':('https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl','771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea')} def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result name='xbcatm-order-'+uuid.uuid4().hex[:10];tags=[name+':early',name+':late'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'wheels').mkdir() for version,(url,digest) in WHEELS.items(): with urlopen(url,timeout=40) as response:data=response.read() assert hashlib.sha256(data).hexdigest()==digest (root/'wheels'/url.rsplit('/',1)[1]).write_bytes(data) install='RUN pip install --no-index --no-deps --require-hashes --find-links=/wheels -r /app/requirements.txt && python -c "import time;open(\'/installed\',\'w\').write(str(time.time_ns()))"\n' common=f'FROM {BASE}\nLABEL example.run="{name}"\nCOPY wheels /wheels\n' (root/'early.Dockerfile').write_text(common+'COPY requirements.txt app.py /app/\n'+install+'CMD ["python", "/app/app.py"]\n') (root/'late.Dockerfile').write_text(common+'COPY requirements.txt /app/\n'+install+'COPY app.py /app/\nCMD ["python", "/app/app.py"]\n') def requirement(version): (root/'requirements.txt').write_text(f'idna=={version} --hash=sha256:{WHEELS[version][1]}\n') def application(message): (root/'app.py').write_text(f'import idna\nprint({message!r},idna.__version__,idna.encode("bücher.example").decode())\n') def build(): for tag,kind in zip(tags,['early','late']):docker('build','--network','none','-f',str(root/(kind+'.Dockerfile')),'-t',tag,str(root)) def marks(): return [docker('run','--rm','--network','none',tag,'cat','/installed').stdout for tag in tags] requirement('3.10');application('first');build();first=marks() application('second');build();second=marks() assert first[0]!=second[0] and first[1]==second[1] for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.10 xn--bcher-kva.example' print('editing only application code reruns the early-copy dependency install but reuses the late-copy install') requirement('3.11');build();third=marks() assert all(a!=b for a,b in zip(second,third)) for tag in tags:assert docker('run','--rm','--network','none',tag).stdout.strip()=='second 3.11 xn--bcher-kva.example' print('changing the hashed requirement reruns both installs and actually changes idna from 3.10 to 3.11') finally:docker('image','rm',*tags,check=False) 提前复制版本的 Dockerfile 在 pip 之前一起复制 requirements.txt 与 app.py。延后复制版本先复制依赖声明,执行相同安装命令,再复制应用。两者都提前加入经过验证的 wheel 目录,因为这些文件确实是安装输入。pip 之后立即写入标记,用来观察安装所在操作是否真的执行,而不是凭耗时猜测。 ...

八月 17, 2027 · 2 分钟 · XB Field Notes