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

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