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 的归档,会得到生产者身份不匹配;保留旧清单、替换文件内容,会得到摘要不匹配;删除归档后继续下载,则得到文件缺失。最终结果必须列出这三种拒绝情况,任何失败都不会转而消费另一个构建。这样可以看清下游契约的失败分支确实执行了。
这是实际的本地归档实验,没有执行 GitHub 的过期服务,也没有生成或验证来源证明。示例源码摘要只是标识符,不是已经核实的 Git 提交。能改写清单的一方仍可能构造一致的谎言。本地删除模拟的是“文件不存在”,不能证明平台会在某个准确时刻执行过期清理。这些界限应随结果一起说明,避免把有限检查扩大成完整供应链验证。
把消费方的发布门写清楚
需要阅读实际下载组件怎样处理失败。GitHub 文档说明,摘要不一致时会在界面和任务日志中展示警告,不能直接假设所有完整性警告都会让部署失败。若发布策略要求拒绝,就应落实对应门禁,并把结果与生产者身份一起保存。还要记录持久发布字节放在哪里、谁能取回,以及临时交接副本什么时候可以过期;这些信息才能让后续审批和恢复真正依赖同一份产物。
延伸阅读
来源
- GitHub: store and share workflow data,产物传递、保留期限制和摘要校验,已核验 2026-09-13。
- GitHub: artifact attestations,构建来源证据及其验证边界,已核验 2026-09-13。